1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
|
package keepass
import (
"context"
"fmt"
"os"
"regexp"
gokeepasslib "github.com/tobischo/gokeepasslib/v3"
"codeberg.org/snonux/foostore/internal/backend"
"codeberg.org/snonux/foostore/internal/config"
"codeberg.org/snonux/foostore/internal/store"
)
// Backend implements backend.Backend for a KeePass (.kdbx) database.
// It is intentionally read-oriented; write methods (Add, Import, etc.) are
// currently stubs that return "not supported" errors — they will be wired in a
// later task once the read path is validated. The database is loaded once at
// construction time and held in memory.
type Backend struct {
cfg *config.Config
db *gokeepasslib.Database
dbPath string
regexCache map[string]*regexp.Regexp
}
// Compile-time assertion: *Backend must satisfy backend.Backend.
var _ backend.Backend = (*Backend)(nil)
// New opens the KeePass database at cfg.KDBXPath using the supplied password
// and optional key-file bytes. The database is fully decrypted and held in
// memory; the file is closed immediately after loading.
//
// Pass a non-nil keyFileData only when cfg.KDBXKeyFile is non-empty; otherwise
// pass nil to use password-only credentials.
func New(cfg *config.Config, password string, keyFileData []byte) (*Backend, error) {
creds, err := buildCredentials(password, keyFileData)
if err != nil {
return nil, fmt.Errorf("building keepass credentials: %w", err)
}
db, err := openDatabase(cfg.KDBXPath, creds)
if err != nil {
return nil, err
}
return &Backend{
cfg: cfg,
db: db,
dbPath: cfg.KDBXPath,
regexCache: make(map[string]*regexp.Regexp),
}, nil
}
// buildCredentials returns the appropriate gokeepasslib credentials pointer.
// When keyFileData is non-empty a combined password+keyfile credential is used;
// otherwise a password-only credential is returned.
func buildCredentials(password string, keyFileData []byte) (*gokeepasslib.DBCredentials, error) {
if len(keyFileData) > 0 {
return gokeepasslib.NewPasswordAndKeyDataCredentials(password, keyFileData)
}
return gokeepasslib.NewPasswordCredentials(password), nil
}
// openDatabase opens, decodes, and unlocks a KeePass database from the given
// path using the supplied credentials.
func openDatabase(path string, creds *gokeepasslib.DBCredentials) (*gokeepasslib.Database, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("opening kdbx %q: %w", path, err)
}
defer f.Close()
db := gokeepasslib.NewDatabase()
db.Credentials = creds
if err := gokeepasslib.NewDecoder(f).Decode(db); err != nil {
return nil, fmt.Errorf("decoding kdbx %q: %w", path, err)
}
if err := db.UnlockProtectedEntries(); err != nil {
return nil, fmt.Errorf("unlocking kdbx %q: %w", path, err)
}
ensureRootGroup(db)
return db, nil
}
// ensureRootGroup guarantees that the database has a Content, Root, and at
// least one top-level group so that all navigation helpers can assume a valid
// tree structure.
func ensureRootGroup(db *gokeepasslib.Database) {
if db.Content == nil {
db.Content = gokeepasslib.NewContent()
}
if db.Content.Root == nil {
db.Content.Root = gokeepasslib.NewRootData()
}
if len(db.Content.Root.Groups) == 0 {
root := gokeepasslib.NewGroup()
root.Name = "Root"
db.Content.Root.Groups = append(db.Content.Root.Groups, root)
}
}
// root returns the single top-level KeePass group that acts as the tree root.
func (b *Backend) root() *gokeepasslib.Group {
return &b.db.Content.Root.Groups[0]
}
// WalkIndexes iterates over every virtual entry in the database whose
// description matches searchTerm (empty matches all) and calls fn for each.
func (b *Backend) WalkIndexes(ctx context.Context, searchTerm string, fn func(*store.Index) error) error {
regex, err := b.compileRegex(searchTerm)
if err != nil {
return err
}
for _, ve := range walkEntries(b.root()) {
if searchTerm != "" && !regex.MatchString(ve.description) {
continue
}
if err := fn(ve.toIndex()); err != nil {
return err
}
}
return nil
}
// compileRegex returns a cached compiled regexp for the given search term.
func (b *Backend) compileRegex(searchTerm string) (*regexp.Regexp, error) {
if r, ok := b.regexCache[searchTerm]; ok {
return r, nil
}
r, err := regexp.Compile(searchTerm)
if err != nil {
return nil, fmt.Errorf("invalid search term %q: %w", searchTerm, err)
}
b.regexCache[searchTerm] = r
return r, nil
}
// LoadData builds a *store.Data for the given index entry. For text entries
// the Content is the formatted Password/User/URL/Notes block; for binary
// attachment entries the Content is the raw attachment bytes.
//
// WriteBack is populated so that edits via ReimportAfterExport parse the
// updated text back into KeePass fields and persist the database.
func (b *Backend) LoadData(ctx context.Context, idx *store.Index) (*store.Data, error) {
for _, ve := range walkEntries(b.root()) {
if ve.description != idx.Description {
continue
}
return b.virtualEntryToData(ctx, &ve)
}
return nil, fmt.Errorf("keepass: entry %q not found", idx.Description)
}
// virtualEntryToData converts a resolved virtualEntry into a *store.Data.
func (b *Backend) virtualEntryToData(ctx context.Context, ve *virtualEntry) (*store.Data, error) {
if ve.isBinary {
return b.binaryData(ve)
}
return b.textData(ve)
}
// textData builds a *store.Data for a text (non-attachment) virtual entry.
func (b *Backend) textData(ve *virtualEntry) (*store.Data, error) {
password := getEntryField(ve.entry, "Password")
user := getEntryField(ve.entry, "UserName")
url := getEntryField(ve.entry, "URL")
notes := getEntryField(ve.entry, "Notes")
content := formatContent(password, user, url, notes)
d := &store.Data{Content: content}
d.WriteBack = b.makeWriteBack(ve.description)
return d, nil
}
// binaryData builds a *store.Data for a binary attachment virtual entry.
// It resolves the BinaryReference against the database-level binaries store
// and returns the decompressed content bytes.
func (b *Backend) binaryData(ve *virtualEntry) (*store.Data, error) {
for _, binRef := range ve.entry.Binaries {
if binRef.Name != ve.attachmentName {
continue
}
bin := binRef.Find(b.db)
if bin == nil {
return nil, fmt.Errorf("keepass: binary ID %d not found in db", binRef.Value.ID)
}
content, err := bin.GetContentBytes()
if err != nil {
return nil, fmt.Errorf("keepass: reading attachment %q: %w", ve.attachmentName, err)
}
return &store.Data{Content: content}, nil
}
return nil, fmt.Errorf("keepass: attachment %q not found on entry %q", ve.attachmentName, ve.description)
}
// makeWriteBack returns a WriteBack function that parses updated text content
// back into KeePass fields and saves the database. The d *store.Data parameter
// is intentionally absent — the closure only needs the description and db ref.
func (b *Backend) makeWriteBack(description string) func([]byte) error {
return func(newContent []byte) error {
password, user, url, notes := parseContent(newContent)
groupPath, title, err := SplitDescriptionPath(description)
if err != nil {
return fmt.Errorf("keepass writeback: %w", err)
}
g := EnsureGroup(b.root(), groupPath)
entry, _ := UpsertEntryByTitle(g, title)
SetEntryField(entry, "Title", title)
SetEntryField(entry, "Password", password)
SetEntryField(entry, "UserName", user)
SetEntryField(entry, "URL", url)
SetEntryField(entry, "Notes", notes)
return b.save()
}
}
// save locks protected entries and atomically replaces the database file.
func (b *Backend) save() error {
if err := b.db.LockProtectedEntries(); err != nil {
return fmt.Errorf("keepass: locking entries: %w", err)
}
defer func() {
// Re-unlock so in-memory state stays usable after a save.
_ = b.db.UnlockProtectedEntries()
}()
return AtomicSave(b.db, b.dbPath)
}
// AtomicSave encodes db to a temporary file then renames it over dbPath,
// ensuring the database file is never left in a partial state.
// The file is closed exactly once via the explicit close below; no defer is
// used to avoid double-close on the already-closed file handle.
// Exported so that cli.kdbxStore.Save() can reuse the same pattern without
// duplicating the tmp→rename logic.
func AtomicSave(db *gokeepasslib.Database, dbPath string) error {
tmpPath := dbPath + ".tmp"
out, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("keepass: creating tmp file %q: %w", tmpPath, err)
}
if err := gokeepasslib.NewEncoder(out).Encode(db); err != nil {
_ = out.Close()
return fmt.Errorf("keepass: encoding to %q: %w", tmpPath, err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("keepass: closing tmp file %q: %w", tmpPath, err)
}
if err := os.Rename(tmpPath, dbPath); err != nil {
return fmt.Errorf("keepass: replacing db %q: %w", dbPath, err)
}
return nil
}
|