diff options
Diffstat (limited to 'internal/keepass/keepass.go')
| -rw-r--r-- | internal/keepass/keepass.go | 256 |
1 files changed, 256 insertions, 0 deletions
diff --git a/internal/keepass/keepass.go b/internal/keepass/keepass.go new file mode 100644 index 0000000..dedf302 --- /dev/null +++ b/internal/keepass/keepass.go @@ -0,0 +1,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 +} |
