lockbox

password manager
Log | Files | Refs | README | LICENSE

commit 539bb4bbaf05c7e03b8adfbec1506a5407ffc866
parent 2075ff7e2ce084d66512f5d31bea87d3761a50af
Author: Sean Enck <sean@ttypty.com>
Date:   Sat,  7 Jun 2025 17:10:24 -0400

rename backend -> kdbx

Diffstat:
Minternal/app/conv.go | 10+++++-----
Minternal/app/core.go | 10+++++-----
Minternal/app/help/core.go | 4++--
Minternal/app/insert.go | 14+++++++-------
Minternal/app/insert_test.go | 4++--
Minternal/app/list.go | 8++++----
Minternal/app/list_test.go | 8++++----
Minternal/app/move.go | 18+++++++++---------
Minternal/app/move_test.go | 16++++++++--------
Minternal/app/rekey_test.go | 4++--
Minternal/app/remove.go | 4++--
Minternal/app/showclip.go | 8++++----
Minternal/app/totp.go | 6+++---
Minternal/app/totp_test.go | 16++++++++--------
Minternal/app/unset.go | 10+++++-----
Minternal/app/unset_test.go | 4++--
Dinternal/backend/actions.go | 281-------------------------------------------------------------------------------
Dinternal/backend/actions_test.go | 337-------------------------------------------------------------------------------
Dinternal/backend/core.go | 228-------------------------------------------------------------------------------
Dinternal/backend/core_test.go | 155-------------------------------------------------------------------------------
Dinternal/backend/query.go | 241-------------------------------------------------------------------------------
Dinternal/backend/query_test.go | 376-------------------------------------------------------------------------------
Ainternal/kdbx/actions.go | 281+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/kdbx/actions_test.go | 337+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/kdbx/core.go | 228+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/kdbx/core_test.go | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/kdbx/query.go | 241+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/kdbx/query_test.go | 376+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
28 files changed, 1690 insertions(+), 1690 deletions(-)

diff --git a/internal/app/conv.go b/internal/app/conv.go @@ -8,7 +8,7 @@ import ( "io" "strings" - "git.sr.ht/~enckse/lockbox/internal/backend" + "git.sr.ht/~enckse/lockbox/internal/kdbx" ) // Conv will convert 1-N files @@ -19,7 +19,7 @@ func Conv(cmd CommandOptions) error { } w := cmd.Writer() for _, a := range args { - t, err := backend.Load(a) + t, err := kdbx.Load(a) if err != nil { return err } @@ -30,8 +30,8 @@ func Conv(cmd CommandOptions) error { return nil } -func serialize(w io.Writer, tx *backend.Transaction, isJSON bool, filter string) error { - e, err := tx.QueryCallback(backend.QueryOptions{Mode: backend.ListMode, Values: backend.JSONValue, PathFilter: filter}) +func serialize(w io.Writer, tx *kdbx.Transaction, isJSON bool, filter string) error { + e, err := tx.QueryCallback(kdbx.QueryOptions{Mode: kdbx.ListMode, Values: kdbx.JSONValue, PathFilter: filter}) if err != nil { return err } @@ -51,7 +51,7 @@ func serialize(w io.Writer, tx *backend.Transaction, isJSON bool, filter string) if isJSON { fmt.Fprint(w, "\n") } - b, err := json.MarshalIndent(map[string]backend.EntityValues{item.Path: item.Values}, "", " ") + b, err := json.MarshalIndent(map[string]kdbx.EntityValues{item.Path: item.Values}, "", " ") if err != nil { return err } diff --git a/internal/app/core.go b/internal/app/core.go @@ -6,7 +6,7 @@ import ( "io" "os" - "git.sr.ht/~enckse/lockbox/internal/backend" + "git.sr.ht/~enckse/lockbox/internal/kdbx" "git.sr.ht/~enckse/lockbox/internal/platform" ) @@ -15,7 +15,7 @@ type ( CommandOptions interface { Confirm(string) bool Args() []string - Transaction() *backend.Transaction + Transaction() *kdbx.Transaction Writer() io.Writer } @@ -28,14 +28,14 @@ type ( // DefaultCommand is the default CLI app type for actual execution DefaultCommand struct { - tx *backend.Transaction + tx *kdbx.Transaction args []string } ) // NewDefaultCommand creates a new app command func NewDefaultCommand(args []string) (*DefaultCommand, error) { - t, err := backend.NewTransaction() + t, err := kdbx.NewTransaction() if err != nil { return nil, err } @@ -53,7 +53,7 @@ func (a *DefaultCommand) Writer() io.Writer { } // Transaction will return the backend transaction -func (a *DefaultCommand) Transaction() *backend.Transaction { +func (a *DefaultCommand) Transaction() *kdbx.Transaction { return a.tx } diff --git a/internal/app/help/core.go b/internal/app/help/core.go @@ -11,7 +11,7 @@ import ( "text/template" "git.sr.ht/~enckse/lockbox/internal/app/commands" - "git.sr.ht/~enckse/lockbox/internal/backend" + "git.sr.ht/~enckse/lockbox/internal/kdbx" "git.sr.ht/~enckse/lockbox/internal/config" "git.sr.ht/~enckse/lockbox/internal/output" ) @@ -118,7 +118,7 @@ func Usage(verbose bool, exe string) ([]string, error) { document.ReKey.KeyFile = setDocFlag(commands.ReKeyFlags.KeyFile) document.ReKey.NoKey = commands.ReKeyFlags.NoKey var fields []string - for _, field := range backend.AllowedFields { + for _, field := range kdbx.AllowedFields { fields = append(fields, strings.ToLower(field)) } sort.Strings(fields) diff --git a/internal/app/insert.go b/internal/app/insert.go @@ -7,7 +7,7 @@ import ( "slices" "strings" - "git.sr.ht/~enckse/lockbox/internal/backend" + "git.sr.ht/~enckse/lockbox/internal/kdbx" ) // Insert will execute an insert @@ -18,15 +18,15 @@ func Insert(cmd UserInputOptions) error { return errors.New("invalid insert, no entry given") } entry := args[0] - base := backend.Base(entry) - if !slices.ContainsFunc(backend.AllowedFields, func(v string) bool { + base := kdbx.Base(entry) + if !slices.ContainsFunc(kdbx.AllowedFields, func(v string) bool { return base == strings.ToLower(v) }) { return fmt.Errorf("'%s' is not an allowed field name", base) } - dir := backend.Directory(entry) - existing, err := t.Get(dir, backend.SecretValue) + dir := kdbx.Directory(entry) + existing, err := t.Get(dir, kdbx.SecretValue) if err != nil { return err } @@ -40,11 +40,11 @@ func Insert(cmd UserInputOptions) error { } } } - password, err := cmd.Input(!isPipe && !strings.EqualFold(base, backend.NotesField)) + password, err := cmd.Input(!isPipe && !strings.EqualFold(base, kdbx.NotesField)) if err != nil { return fmt.Errorf("invalid input: %w", err) } - vals := make(backend.EntityValues) + vals := make(kdbx.EntityValues) if existing != nil { vals = existing.Values } diff --git a/internal/app/insert_test.go b/internal/app/insert_test.go @@ -7,7 +7,7 @@ import ( "testing" "git.sr.ht/~enckse/lockbox/internal/app" - "git.sr.ht/~enckse/lockbox/internal/backend" + "git.sr.ht/~enckse/lockbox/internal/kdbx" ) type ( @@ -56,7 +56,7 @@ func (m *mockInsert) IsNoTOTP() (bool, error) { return m.noTOTP() } -func (m *mockInsert) Transaction() *backend.Transaction { +func (m *mockInsert) Transaction() *kdbx.Transaction { return m.command.Transaction() } diff --git a/internal/app/list.go b/internal/app/list.go @@ -7,7 +7,7 @@ import ( "sort" "strings" - "git.sr.ht/~enckse/lockbox/internal/backend" + "git.sr.ht/~enckse/lockbox/internal/kdbx" ) // List will list/find entries @@ -27,8 +27,8 @@ func List(cmd CommandOptions, groups bool) error { } func doList(attr, filter string, cmd CommandOptions, groups bool) error { - opts := backend.QueryOptions{} - opts.Mode = backend.ListMode + opts := kdbx.QueryOptions{} + opts.Mode = kdbx.ListMode opts.PathFilter = filter e, err := cmd.Transaction().QueryCallback(opts) if err != nil { @@ -54,7 +54,7 @@ func doList(attr, filter string, cmd CommandOptions, groups bool) error { continue } } - results = append(results, backend.NewPath(f.Path, k)) + results = append(results, kdbx.NewPath(f.Path, k)) } if len(results) == 0 { continue diff --git a/internal/app/list_test.go b/internal/app/list_test.go @@ -7,7 +7,7 @@ import ( "testing" "git.sr.ht/~enckse/lockbox/internal/app" - "git.sr.ht/~enckse/lockbox/internal/backend" + "git.sr.ht/~enckse/lockbox/internal/kdbx" "git.sr.ht/~enckse/lockbox/internal/config/store" "git.sr.ht/~enckse/lockbox/internal/platform" ) @@ -21,7 +21,7 @@ func testFile() string { return file } -func fullSetup(t *testing.T, keep bool) *backend.Transaction { +func fullSetup(t *testing.T, keep bool) *kdbx.Transaction { file := testFile() if !keep { os.Remove(file) @@ -30,14 +30,14 @@ func fullSetup(t *testing.T, keep bool) *backend.Transaction { store.SetArray("LOCKBOX_CREDENTIALS_PASSWORD", []string{"test"}) store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "plaintext") store.SetString("LOCKBOX_TOTP_ENTRY", "totp") - tr, err := backend.NewTransaction() + tr, err := kdbx.NewTransaction() if err != nil { t.Errorf("failed: %v", err) } return tr } -func setup(t *testing.T) *backend.Transaction { +func setup(t *testing.T) *kdbx.Transaction { return fullSetup(t, false) } diff --git a/internal/app/move.go b/internal/app/move.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" - "git.sr.ht/~enckse/lockbox/internal/backend" + "git.sr.ht/~enckse/lockbox/internal/kdbx" ) type ( @@ -37,17 +37,17 @@ func Move(cmd CommandOptions) error { case 0: break default: - if !backend.IsDirectory(dst) { + if !kdbx.IsDirectory(dst) { return fmt.Errorf("%s must be a path, not an entry", dst) } - srcDir := backend.Directory(src) - dir := backend.Directory(dst) + srcDir := kdbx.Directory(src) + dir := kdbx.Directory(dst) for _, e := range m { - srcPath := backend.Directory(e.Path) + srcPath := kdbx.Directory(e.Path) if srcPath != srcDir { return fmt.Errorf("multiple moves can only be done at a leaf level") } - r := moveRequest{cmd: cmd, src: e.Path, dst: backend.NewPath(dir, backend.Base(e.Path)), overwrite: false} + r := moveRequest{cmd: cmd, src: e.Path, dst: kdbx.NewPath(dir, kdbx.Base(e.Path)), overwrite: false} if err := r.do(true); err != nil { return err } @@ -69,21 +69,21 @@ func Move(cmd CommandOptions) error { func (r moveRequest) do(dryRun bool) error { tx := r.cmd.Transaction() if !dryRun { - use, err := backend.NewTransaction() + use, err := kdbx.NewTransaction() if err != nil { return err } tx = use } - srcExists, err := tx.Get(r.src, backend.SecretValue) + srcExists, err := tx.Get(r.src, kdbx.SecretValue) if err != nil { return errors.New("unable to get source entry") } if srcExists == nil { return errors.New("no source object found") } - dstExists, err := tx.Get(r.dst, backend.BlankValue) + dstExists, err := tx.Get(r.dst, kdbx.BlankValue) if err != nil { return errors.New("unable to get destination object") } diff --git a/internal/app/move_test.go b/internal/app/move_test.go @@ -6,7 +6,7 @@ import ( "testing" "git.sr.ht/~enckse/lockbox/internal/app" - "git.sr.ht/~enckse/lockbox/internal/backend" + "git.sr.ht/~enckse/lockbox/internal/kdbx" ) type ( @@ -21,12 +21,12 @@ type ( func newMockCommand(t *testing.T) *mockCommand { setup(t) - fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test1"), map[string]string{"notes": "something", "password": "pass"}) - fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test2"), map[string]string{"notes": "something", "password": "pass"}) - fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test3"), map[string]string{"notes": "something", "password": "pass"}) - fullSetup(t, true).Insert(backend.NewPath("test", "test3", "test1"), map[string]string{"notes": "something", "password": "pass"}) - fullSetup(t, true).Insert(backend.NewPath("test", "test3", "test2"), map[string]string{"notes": "something", "password": "pass"}) - fullSetup(t, true).Insert(backend.NewPath("test", "test4", "test5"), map[string]string{"notes": "something", "password": "pass"}) + fullSetup(t, true).Insert(kdbx.NewPath("test", "test2", "test1"), map[string]string{"notes": "something", "password": "pass"}) + fullSetup(t, true).Insert(kdbx.NewPath("test", "test2", "test2"), map[string]string{"notes": "something", "password": "pass"}) + fullSetup(t, true).Insert(kdbx.NewPath("test", "test2", "test3"), map[string]string{"notes": "something", "password": "pass"}) + fullSetup(t, true).Insert(kdbx.NewPath("test", "test3", "test1"), map[string]string{"notes": "something", "password": "pass"}) + fullSetup(t, true).Insert(kdbx.NewPath("test", "test3", "test2"), map[string]string{"notes": "something", "password": "pass"}) + fullSetup(t, true).Insert(kdbx.NewPath("test", "test4", "test5"), map[string]string{"notes": "something", "password": "pass"}) return &mockCommand{t: t, confirmed: false, confirm: true} } @@ -35,7 +35,7 @@ func (m *mockCommand) Confirm(string) bool { return m.confirm } -func (m *mockCommand) Transaction() *backend.Transaction { +func (m *mockCommand) Transaction() *kdbx.Transaction { return fullSetup(m.t, true) } diff --git a/internal/app/rekey_test.go b/internal/app/rekey_test.go @@ -6,7 +6,7 @@ import ( "testing" "git.sr.ht/~enckse/lockbox/internal/app" - "git.sr.ht/~enckse/lockbox/internal/backend" + "git.sr.ht/~enckse/lockbox/internal/kdbx" ) type ( @@ -24,7 +24,7 @@ func (m *mockKeyer) Confirm(string) bool { return m.confirm } -func (m *mockKeyer) Transaction() *backend.Transaction { +func (m *mockKeyer) Transaction() *kdbx.Transaction { return fullSetup(m.t, true) } diff --git a/internal/app/remove.go b/internal/app/remove.go @@ -6,7 +6,7 @@ import ( "fmt" "io" - "git.sr.ht/~enckse/lockbox/internal/backend" + "git.sr.ht/~enckse/lockbox/internal/kdbx" ) // Remove will remove an entry @@ -18,7 +18,7 @@ func Remove(cmd CommandOptions) error { return remove(cmd.Transaction(), cmd.Writer(), args[0], cmd) } -func remove(t *backend.Transaction, w io.Writer, entry string, cmd CommandOptions) error { +func remove(t *kdbx.Transaction, w io.Writer, entry string, cmd CommandOptions) error { deleting := entry postfixRemove := "y" existings, err := t.MatchPath(deleting) diff --git a/internal/app/showclip.go b/internal/app/showclip.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" - "git.sr.ht/~enckse/lockbox/internal/backend" + "git.sr.ht/~enckse/lockbox/internal/kdbx" "git.sr.ht/~enckse/lockbox/internal/platform/clip" ) @@ -39,9 +39,9 @@ func ShowClip(cmd CommandOptions, isShow bool) error { } func getEntity(entry string, cmd CommandOptions) (string, error) { - base := backend.Base(entry) - dir := backend.Directory(entry) - existing, err := cmd.Transaction().Get(dir, backend.SecretValue) + base := kdbx.Base(entry) + dir := kdbx.Directory(entry) + existing, err := cmd.Transaction().Get(dir, kdbx.SecretValue) if err != nil { return "", err } diff --git a/internal/app/totp.go b/internal/app/totp.go @@ -13,7 +13,7 @@ import ( otp "github.com/pquerna/otp/totp" "git.sr.ht/~enckse/lockbox/internal/app/commands" - "git.sr.ht/~enckse/lockbox/internal/backend" + "git.sr.ht/~enckse/lockbox/internal/kdbx" "git.sr.ht/~enckse/lockbox/internal/config" "git.sr.ht/~enckse/lockbox/internal/platform/clip" ) @@ -97,7 +97,7 @@ func (args *TOTPArguments) display(opts TOTPOptions) error { if !interactive && clipMode { return errors.New("clipboard not available in non-interactive mode") } - if !backend.IsLeafAttribute(args.Entry, backend.OTPField) { + if !kdbx.IsLeafAttribute(args.Entry, kdbx.OTPField) { return fmt.Errorf("'%s' is not a TOTP entry", args.Entry) } entity, err := getEntity(args.Entry, opts.app) @@ -219,7 +219,7 @@ func (args *TOTPArguments) Do(opts TOTPOptions) error { return ErrNoTOTP } if args.Mode == ListTOTPMode { - return doList(backend.OTPField, args.Entry, opts.app, false) + return doList(kdbx.OTPField, args.Entry, opts.app, false) } return args.display(opts) } diff --git a/internal/app/totp_test.go b/internal/app/totp_test.go @@ -8,20 +8,20 @@ import ( "testing" "git.sr.ht/~enckse/lockbox/internal/app" - "git.sr.ht/~enckse/lockbox/internal/backend" + "git.sr.ht/~enckse/lockbox/internal/kdbx" "git.sr.ht/~enckse/lockbox/internal/config/store" ) type ( mockOptions struct { - tx *backend.Transaction + tx *kdbx.Transaction buf bytes.Buffer } ) func newMock(t *testing.T) (*mockOptions, app.TOTPOptions) { - fullTOTPSetup(t, true).Insert(backend.NewPath("test", "test3", "totp"), map[string]string{"password": "pass", "otp": "5ae472abqdekjqykoyxk7hvc2leklq5n"}) - fullTOTPSetup(t, true).Insert(backend.NewPath("test", "test2", "totp"), map[string]string{"password": "pass", "otp": "5ae472abqdekjqykoyxk7hvc2leklq5n"}) + fullTOTPSetup(t, true).Insert(kdbx.NewPath("test", "test3", "totp"), map[string]string{"password": "pass", "otp": "5ae472abqdekjqykoyxk7hvc2leklq5n"}) + fullTOTPSetup(t, true).Insert(kdbx.NewPath("test", "test2", "totp"), map[string]string{"password": "pass", "otp": "5ae472abqdekjqykoyxk7hvc2leklq5n"}) m := &mockOptions{ buf: bytes.Buffer{}, tx: fullTOTPSetup(t, true), @@ -38,7 +38,7 @@ func newMock(t *testing.T) (*mockOptions, app.TOTPOptions) { return m, opts } -func fullTOTPSetup(t *testing.T, keep bool) *backend.Transaction { +func fullTOTPSetup(t *testing.T, keep bool) *kdbx.Transaction { store.Clear() file := testFile() if !keep { @@ -49,7 +49,7 @@ func fullTOTPSetup(t *testing.T, keep bool) *backend.Transaction { store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "plaintext") store.SetString("LOCKBOX_TOTP_ENTRY", "totp") store.SetInt64("LOCKBOX_TOTP_TIMEOUT", 1) - tr, err := backend.NewTransaction() + tr, err := kdbx.NewTransaction() if err != nil { t.Errorf("failed: %v", err) } @@ -64,7 +64,7 @@ func (m *mockOptions) Args() []string { return nil } -func (m *mockOptions) Transaction() *backend.Transaction { +func (m *mockOptions) Transaction() *kdbx.Transaction { return m.tx } @@ -72,7 +72,7 @@ func (m *mockOptions) Writer() io.Writer { return &m.buf } -func setupTOTP(t *testing.T) *backend.Transaction { +func setupTOTP(t *testing.T) *kdbx.Transaction { return fullTOTPSetup(t, false) } diff --git a/internal/app/unset.go b/internal/app/unset.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" - "git.sr.ht/~enckse/lockbox/internal/backend" + "git.sr.ht/~enckse/lockbox/internal/kdbx" ) // Unset enables clearing an entry @@ -16,9 +16,9 @@ func Unset(cmd CommandOptions) error { return errors.New("invalid unset, no entry given") } entry := args[0] - base := backend.Base(entry) - dir := backend.Directory(entry) - existing, err := t.Get(dir, backend.SecretValue) + base := kdbx.Base(entry) + dir := kdbx.Directory(entry) + existing, err := t.Get(dir, kdbx.SecretValue) if err != nil { return err } @@ -26,7 +26,7 @@ func Unset(cmd CommandOptions) error { return fmt.Errorf("%s does not exist", entry) } w := cmd.Writer() - unsetRemove := func(v backend.EntityValues) (bool, error) { + unsetRemove := func(v kdbx.EntityValues) (bool, error) { if len(v) == 0 { fmt.Fprintf(w, "removing empty group: %s\n", dir) return true, remove(t, w, dir, cmd) diff --git a/internal/app/unset_test.go b/internal/app/unset_test.go @@ -4,12 +4,12 @@ import ( "testing" "git.sr.ht/~enckse/lockbox/internal/app" - "git.sr.ht/~enckse/lockbox/internal/backend" + "git.sr.ht/~enckse/lockbox/internal/kdbx" ) func TestUnset(t *testing.T) { m := newMockCommand(t) - fullSetup(t, true).Insert(backend.NewPath("test", "test2", "testz"), map[string]string{"notes": "something"}) + fullSetup(t, true).Insert(kdbx.NewPath("test", "test2", "testz"), map[string]string{"notes": "something"}) if err := app.Unset(m); err == nil || err.Error() != "invalid unset, no entry given" { t.Errorf("invalid error: %v", err) } diff --git a/internal/backend/actions.go b/internal/backend/actions.go @@ -1,281 +0,0 @@ -// Package backend handles kdbx interactions -package backend - -import ( - "errors" - "fmt" - "os" - "strings" - "time" - - "git.sr.ht/~enckse/lockbox/internal/config" - "git.sr.ht/~enckse/lockbox/internal/platform" - "github.com/tobischo/gokeepasslib/v3" -) - -type ( - action func(t Context) error -) - -func (t *Transaction) act(cb action) error { - if !t.valid { - return errors.New("invalid transaction") - } - key, err := config.NewKey(config.DefaultKeyMode) - if err != nil { - return err - } - k, err := key.Read(platform.ReadInteractivePassword) - if err != nil { - return err - } - file := config.EnvKeyFile.Get() - if !t.exists { - if err := create(t.file, k, file); err != nil { - return err - } - } - f, err := os.Open(t.file) - if err != nil { - return err - } - defer f.Close() - db := gokeepasslib.NewDatabase() - creds, err := getCredentials(k, file) - if err != nil { - return err - } - db.Credentials = creds - if err := gokeepasslib.NewDecoder(f).Decode(db); err != nil { - return err - } - if len(db.Content.Root.Groups) != 1 { - return errors.New("kdbx must have ONE root group") - } - err = cb(Context{db: db}) - if err != nil { - return err - } - if t.write { - if err := db.LockProtectedEntries(); err != nil { - return err - } - if err := f.Close(); err != nil { - return err - } - f, err = os.Create(t.file) - if err != nil { - return err - } - defer f.Close() - return encode(f, db) - } - return err -} - -// ReKey will change the credentials on a database -func (t *Transaction) ReKey(pass, keyFile string) error { - creds, err := getCredentials(pass, keyFile) - if err != nil { - return err - } - return t.change(func(c Context) error { - c.db.Credentials = creds - return nil - }) -} - -func (t *Transaction) change(cb action) error { - if t.readonly { - return errors.New("unable to alter database in readonly mode") - } - return t.act(func(c Context) error { - if err := c.db.UnlockProtectedEntries(); err != nil { - return err - } - t.write = true - return cb(c) - }) -} - -func (c Context) alterEntities(isAdd bool, offset []string, title string, entity *gokeepasslib.Entry) bool { - g, e, ok := findAndDo(isAdd, title, offset, entity, c.db.Content.Root.Groups[0].Groups, c.db.Content.Root.Groups[0].Entries) - c.db.Content.Root.Groups[0].Groups = g - c.db.Content.Root.Groups[0].Entries = e - return ok -} - -func (c Context) removeEntity(offset []string, title string) bool { - return c.alterEntities(false, offset, title, nil) -} - -func findAndDo(isAdd bool, entityName string, offset []string, opEntity *gokeepasslib.Entry, g []gokeepasslib.Group, e []gokeepasslib.Entry) ([]gokeepasslib.Group, []gokeepasslib.Entry, bool) { - done := false - if len(offset) == 0 { - if isAdd { - e = append(e, *opEntity) - } else { - var entries []gokeepasslib.Entry - for _, entry := range e { - if getPathName(entry) == entityName { - continue - } - entries = append(entries, entry) - } - e = entries - } - done = true - } else { - name := offset[0] - remaining := []string{} - if len(offset) > 1 { - remaining = offset[1:] - } - if isAdd { - match := false - for _, group := range g { - if group.Name == name { - match = true - } - } - if !match { - newGroup := gokeepasslib.NewGroup() - newGroup.Name = name - g = append(g, newGroup) - } - } - var updateGroups []gokeepasslib.Group - for _, group := range g { - if !done && group.Name == name { - groups, entries, ok := findAndDo(isAdd, entityName, remaining, opEntity, group.Groups, group.Entries) - group.Entries = entries - group.Groups = groups - if ok { - done = true - } - } - updateGroups = append(updateGroups, group) - } - g = updateGroups - if !isAdd { - var groups []gokeepasslib.Group - for _, group := range g { - if group.Name == name && len(group.Entries) == 0 && len(group.Groups) == 0 { - continue - } - groups = append(groups, group) - } - g = groups - } - } - return g, e, done -} - -// Move will move a src object to a dst location -func (t *Transaction) Move(src *Entity, dst string) error { - if src == nil { - return errors.New("source entity is not set") - } - if strings.TrimSpace(src.Path) == "" { - return errors.New("empty path not allowed") - } - if len(src.Values) == 0 { - return errors.New("empty secrets not allowed") - } - values := make(map[string]string) - for k, v := range src.Values { - found := false - for _, mapping := range AllowedFields { - if strings.EqualFold(k, mapping) { - values[mapping] = v - found = true - break - } - } - if !found { - return fmt.Errorf("unknown entity field: %s", k) - } - } - mod := config.EnvDefaultModTime.Get() - modTime := time.Now() - if mod != "" { - p, err := time.Parse(config.ModTimeFormat, mod) - if err != nil { - return err - } - modTime = p - } - dOffset, dTitle, err := splitComponents(dst) - if err != nil { - return err - } - sOffset, sTitle, err := splitComponents(src.Path) - if err != nil { - return err - } - isMove := dst != src.Path - return t.change(func(c Context) error { - c.removeEntity(sOffset, sTitle) - if isMove { - c.removeEntity(dOffset, dTitle) - } - e := gokeepasslib.NewEntry() - e.Values = append(e.Values, value(titleKey, dTitle)) - e.Values = append(e.Values, value(modTimeKey, modTime.Format(time.RFC3339))) - for k, v := range values { - val := v - switch k { - case OTPField, PasswordField: - if strings.Contains(val, "\n") { - return fmt.Errorf("%s can NOT be multi-line", strings.ToLower(k)) - } - if k == OTPField { - val = config.EnvTOTPFormat.Get(v) - } - } - e.Values = append(e.Values, protectedValue(k, val)) - } - c.alterEntities(true, dOffset, dTitle, &e) - return nil - }) -} - -// Insert is a move to the same location -func (t *Transaction) Insert(path string, val EntityValues) error { - return t.Move(&Entity{Path: path, Values: val}, path) -} - -// Remove will remove a single entity -func (t *Transaction) Remove(entity *Entity) error { - if entity == nil { - return errors.New("entity is empty/invalid") - } - return t.RemoveAll([]Entity{*entity}) -} - -// RemoveAll handles removing elements -func (t *Transaction) RemoveAll(entities []Entity) error { - if len(entities) == 0 { - return errors.New("no entities given") - } - type removal struct { - title string - parts []string - } - removals := []removal{} - for _, entity := range entities { - offset, title, err := splitComponents(entity.Path) - if err != nil { - return err - } - removals = append(removals, removal{parts: offset, title: title}) - } - return t.change(func(c Context) error { - for _, entity := range removals { - if ok := c.removeEntity(entity.parts, entity.title); !ok { - return errors.New("failed to remove entity") - } - } - return nil - }) -} diff --git a/internal/backend/actions_test.go b/internal/backend/actions_test.go @@ -1,337 +0,0 @@ -package backend_test - -import ( - "fmt" - "os" - "path/filepath" - "testing" - - "git.sr.ht/~enckse/lockbox/internal/backend" - "git.sr.ht/~enckse/lockbox/internal/config/store" - "git.sr.ht/~enckse/lockbox/internal/platform" -) - -const ( - testDir = "testdata" -) - -func testFile(name string) string { - file := filepath.Join(testDir, name) - if !platform.PathExists(testDir) { - os.Mkdir(testDir, 0o755) - } - return file -} - -func fullSetup(t *testing.T, keep bool) *backend.Transaction { - file := testFile("test.kdbx") - if !keep { - os.Remove(file) - } - store.SetBool("LOCKBOX_READONLY", false) - store.SetString("LOCKBOX_STORE", file) - store.SetArray("LOCKBOX_CREDENTIALS_PASSWORD", []string{"test"}) - store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "plaintext") - store.SetString("LOCKBOX_TOTP_ENTRY", "totp") - tr, err := backend.NewTransaction() - if err != nil { - t.Errorf("failed: %v", err) - } - return tr -} - -func TestKeyFile(t *testing.T) { - store.Clear() - defer store.Clear() - file := testFile("keyfile_test.kdbx") - keyFile := testFile("file.key") - os.Remove(file) - store.SetString("LOCKBOX_STORE", file) - store.SetArray("LOCKBOX_CREDENTIALS_PASSWORD", []string{"test"}) - store.SetString("LOCKBOX_CREDENTIALS_KEY_FILE", keyFile) - store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "plaintext") - store.SetString("LOCKBOX_TOTP_ENTRY", "totp") - os.WriteFile(keyFile, []byte("test"), 0o644) - tr, err := backend.NewTransaction() - if err != nil { - t.Errorf("failed: %v", err) - } - if err := tr.Insert(backend.NewPath("a", "b"), map[string]string{"password": "t"}); err != nil { - t.Errorf("no error: %v", err) - } -} - -func setup(t *testing.T) *backend.Transaction { - return fullSetup(t, false) -} - -func TestNoWriteOnRO(t *testing.T) { - setup(t) - store.SetBool("LOCKBOX_READONLY", true) - tr, _ := backend.NewTransaction() - if err := tr.Insert("a/a/a", map[string]string{"password": "xyz"}); err.Error() != "unable to alter database in readonly mode" { - t.Errorf("wrong error: %v", err) - } -} - -func TestBadAction(t *testing.T) { - tr := &backend.Transaction{} - if err := tr.Insert("a/a/a", map[string]string{"notes": "xyz"}); err.Error() != "invalid transaction" { - t.Errorf("wrong error: %v", err) - } -} - -func TestMove(t *testing.T) { - setup(t) - fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test1"), map[string]string{"passworD": "pass"}) - fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test3"), map[string]string{"NoTES": "pass", "password": "xxx"}) - if err := fullSetup(t, true).Move(nil, ""); err == nil || err.Error() != "source entity is not set" { - t.Errorf("no error: %v", err) - } - if err := fullSetup(t, true).Move(&backend.Entity{Path: backend.NewPath("test", "test2", "test3"), Values: map[string]string{"Notes": "abc"}}, backend.NewPath("test1", "test2", "test3")); err != nil { - t.Errorf("no error: %v", err) - } - q, err := fullSetup(t, true).Get(backend.NewPath("test1", "test2", "test3"), backend.SecretValue) - if err != nil { - t.Errorf("no error: %v", err) - } - if val, ok := q.Value("notes"); !ok || val != "abc" { - t.Errorf("invalid retrieval") - } - if err := fullSetup(t, true).Move(&backend.Entity{Path: backend.NewPath("test", "test2", "test1"), Values: map[string]string{"password": "test"}}, backend.NewPath("test1", "test2", "test3")); err != nil { - t.Errorf("no error: %v", err) - } - q, err = fullSetup(t, true).Get(backend.NewPath("test1", "test2", "test3"), backend.SecretValue) - if err != nil { - t.Errorf("no error: %v", err) - } - if val, ok := q.Value("password"); !ok || val != "test" { - t.Errorf("invalid retrieval") - } -} - -func TestInserts(t *testing.T) { - if err := setup(t).Insert("", nil); err.Error() != "empty path not allowed" { - t.Errorf("wrong error: %v", err) - } - if err := setup(t).Insert("a", map[string]string{"randomfield": "1"}); err.Error() != "unknown entity field: randomfield" { - t.Errorf("wrong error: %v", err) - } - if err := setup(t).Insert("tests", map[string]string{"notes": "1"}); err.Error() != "input paths must contain at LEAST 2 components" { - t.Errorf("wrong error: %v", err) - } - if err := setup(t).Insert("tests//l", map[string]string{"notes": "test"}); err.Error() != "unwilling to operate on path with empty segment" { - t.Errorf("wrong error: %v", err) - } - if err := setup(t).Insert("tests/", map[string]string{"password": "test"}); err.Error() != "path can NOT end with separator" { - t.Errorf("wrong error: %v", err) - } - if err := setup(t).Insert("/tests", map[string]string{"password": "test"}); err.Error() != "path can NOT be rooted" { - t.Errorf("wrong error: %v", err) - } - if err := setup(t).Insert("test", map[string]string{"otp": "test"}); err.Error() != "input paths must contain at LEAST 2 components" { - t.Errorf("wrong error: %v", err) - } - if err := setup(t).Insert("a", nil); err.Error() != "empty secrets not allowed" { - t.Errorf("wrong error: %v", err) - } - if err := setup(t).Insert("a", make(map[string]string)); err.Error() != "empty secrets not allowed" { - t.Errorf("wrong error: %v", err) - } - if err := setup(t).Insert(backend.NewPath("test", "offset", "value"), map[string]string{"password": "pass"}); err != nil { - t.Errorf("no error: %v", err) - } - if err := fullSetup(t, true).Insert(backend.NewPath("test", "offset", "value"), map[string]string{"NoTes": "pass2"}); err != nil { - t.Errorf("wrong error: %v", err) - } - if err := fullSetup(t, true).Insert(backend.NewPath("test", "offset", "value2"), map[string]string{"NOTES": "pass\npass", "password": "xxx", "otP": "zzz"}); err != nil { - t.Errorf("no error: %v", err) - } - q, err := fullSetup(t, true).Get(backend.NewPath("test", "offset", "value"), backend.SecretValue) - if err != nil { - t.Errorf("no error: %v", err) - } - if val, ok := q.Value("notes"); !ok || val != "pass2" { - t.Errorf("invalid retrieval") - } - q, err = fullSetup(t, true).Get(backend.NewPath("test", "offset", "value2"), backend.SecretValue) - if err != nil { - t.Errorf("no error: %v", err) - } - if val, ok := q.Value("notes"); !ok || val != "pass\npass" { - t.Errorf("invalid retrieval: %s", val) - } - if val, ok := q.Value("password"); !ok || val != "xxx" { - t.Errorf("invalid retrieval: %s", val) - } - if val, ok := q.Value("otp"); !ok || val != "otpauth://totp/lbissuer:lbaccount?algorithm=SHA1&digits=6&issuer=lbissuer&period=30&secret=zzz" { - t.Errorf("invalid retrieval: %s", val) - } - if err := fullSetup(t, true).Insert(backend.NewPath("test", "offset"), map[string]string{"otp": "5ae472sabqdekjqykoyxk7hvc2leklq5n"}); err != nil { - t.Errorf("no error: %v", err) - } - if err := fullSetup(t, true).Insert(backend.NewPath("test", "offset"), map[string]string{"OTP": "ljaf\n5ae472abqdekjqykoyxk7hvc2leklq5n"}); err == nil || err.Error() != "otp can NOT be multi-line" { - t.Errorf("wrong error: %v", err) - } - if err := fullSetup(t, true).Insert(backend.NewPath("test", "offset"), map[string]string{"password": "ljaf\n5ae472abqdekjqykoyxk7hvc2leklq5n"}); err == nil || err.Error() != "password can NOT be multi-line" { - t.Errorf("wrong error: %v", err) - } -} - -func TestRemoves(t *testing.T) { - if err := setup(t).Remove(nil); err.Error() != "entity is empty/invalid" { - t.Errorf("wrong error: %v", err) - } - if err := setup(t).Remove(&backend.Entity{}); err.Error() != "input paths must contain at LEAST 2 components" { - t.Errorf("wrong error: %v", err) - } - tx := backend.Entity{Path: backend.NewPath("test1", "test2", "test3")} - if err := setup(t).Remove(&tx); err.Error() != "failed to remove entity" { - t.Errorf("wrong error: %v", err) - } - setup(t) - for _, i := range []string{"test1", "test2"} { - fullSetup(t, true).Insert(backend.NewPath(i, i, i), map[string]string{"PASSWORD": "pass"}) - } - tx = backend.Entity{Path: backend.NewPath("test1", "test1", "test1")} - if err := fullSetup(t, true).Remove(&tx); err != nil { - t.Errorf("wrong error: %v", err) - } - if err := check(t, backend.NewPath("test2", "test2", "test2")); err != nil { - t.Errorf("invalid check: %v", err) - } - tx = backend.Entity{Path: backend.NewPath("test2", "test2", "test2")} - if err := fullSetup(t, true).Remove(&tx); err != nil { - t.Errorf("wrong error: %v", err) - } - setup(t) - for _, i := range []string{backend.NewPath("test", "test", "test1"), backend.NewPath("test", "test", "test2"), backend.NewPath("test", "test", "test3"), backend.NewPath("test", "test1", "test2"), backend.NewPath("test", "test1", "test5")} { - fullSetup(t, true).Insert(i, map[string]string{"password": "pass"}) - } - tx = backend.Entity{Path: "test/test/test3"} - if err := fullSetup(t, true).Remove(&tx); err != nil { - t.Errorf("wrong error: %v", err) - } - if err := check(t, backend.NewPath("test", "test", "test2"), backend.NewPath("test", "test", "test1"), backend.NewPath("test", "test1", "test2"), backend.NewPath("test", "test1", "test5")); err != nil { - t.Errorf("invalid check: %v", err) - } - tx = backend.Entity{Path: "test/test/test1"} - if err := fullSetup(t, true).Remove(&tx); err != nil { - t.Errorf("wrong error: %v", err) - } - if err := check(t, backend.NewPath("test", "test", "test2"), backend.NewPath("test", "test1", "test2"), backend.NewPath("test", "test1", "test5")); err != nil { - t.Errorf("invalid check: %v", err) - } - tx = backend.Entity{Path: "test/test1/test5"} - if err := fullSetup(t, true).Remove(&tx); err != nil { - t.Errorf("wrong error: %v", err) - } - if err := check(t, backend.NewPath("test", "test", "test2"), backend.NewPath("test", "test1", "test2")); err != nil { - t.Errorf("invalid check: %v", err) - } - tx = backend.Entity{Path: "test/test1/test2"} - if err := fullSetup(t, true).Remove(&tx); err != nil { - t.Errorf("wrong error: %v", err) - } - if err := check(t, backend.NewPath("test", "test", "test2")); err != nil { - t.Errorf("invalid check: %v", err) - } - tx = backend.Entity{Path: "test/test/test2"} - if err := fullSetup(t, true).Remove(&tx); err != nil { - t.Errorf("wrong error: %v", err) - } -} - -func TestRemoveAlls(t *testing.T) { - if err := setup(t).RemoveAll(nil); err.Error() != "no entities given" { - t.Errorf("wrong error: %v", err) - } - if err := setup(t).RemoveAll([]backend.Entity{}); err.Error() != "no entities given" { - t.Errorf("wrong error: %v", err) - } - setup(t) - for _, i := range []string{backend.NewPath("test", "test", "test1"), backend.NewPath("test", "test", "test2"), backend.NewPath("test", "test", "test3"), backend.NewPath("test", "test1", "test2"), backend.NewPath("test", "test1", "test5")} { - fullSetup(t, true).Insert(i, map[string]string{"PaSsWoRd": "pass"}) - } - if err := fullSetup(t, true).RemoveAll([]backend.Entity{{Path: "test/test/test3"}, {Path: "test/test/test1"}}); err != nil { - t.Errorf("wrong error: %v", err) - } - if err := check(t, backend.NewPath("test", "test", "test2"), backend.NewPath("test", "test1", "test2"), backend.NewPath("test", "test1", "test5")); err != nil { - t.Errorf("invalid check: %v", err) - } -} - -func check(t *testing.T, checks ...string) error { - tr := fullSetup(t, true) - for _, c := range checks { - q, err := tr.Get(c, backend.BlankValue) - if err != nil { - return err - } - if q == nil { - return fmt.Errorf("failed to find entity: %s", c) - } - } - return nil -} - -func TestKeyAndOrKeyFile(t *testing.T) { - keyAndOrKeyFile(t, true, true) - keyAndOrKeyFile(t, false, true) - keyAndOrKeyFile(t, true, false) - keyAndOrKeyFile(t, false, false) -} - -func keyAndOrKeyFile(t *testing.T, key, keyFile bool) { - store.Clear() - file := testFile("keyorkeyfile.kdbx") - os.Remove(file) - store.SetString("LOCKBOX_STORE", file) - if key { - store.SetArray("LOCKBOX_CREDENTIALS_PASSWORD", []string{"test"}) - store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "plaintext") - } else { - store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "none") - } - if keyFile { - key := testFile("keyfileor.key") - store.SetString("LOCKBOX_CREDENTIALS_KEY_FILE", key) - os.WriteFile(key, []byte("test"), 0o644) - } - tr, err := backend.NewTransaction() - if err != nil { - t.Errorf("failed: %v", err) - } - invalid := !key && !keyFile - err = tr.Insert(backend.NewPath("a", "b"), map[string]string{"password": "t"}) - if invalid { - if err == nil || err.Error() != "key and/or keyfile must be set" { - t.Errorf("invalid error: %v", err) - } - } else { - if err != nil { - t.Errorf("no error allowed: %v", err) - } - } -} - -func TestReKey(t *testing.T) { - store.Clear() - f := "rekey_test.kdbx" - file := testFile(f) - defer os.Remove(filepath.Join(testDir, f)) - store.SetString("LOCKBOX_STORE", file) - store.SetArray("LOCKBOX_CREDENTIALS_PASSWORD", []string{"test"}) - store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "plaintext") - store.SetString("LOCKBOX_TOTP_ENTRY", "totp") - tr, err := backend.NewTransaction() - if err != nil { - t.Errorf("failed: %v", err) - } - if err := tr.ReKey("", ""); err == nil || err.Error() != "key and/or keyfile must be set" { - t.Errorf("no error: %v", err) - } - if err := tr.ReKey("abc", ""); err != nil { - t.Errorf("no error: %v", err) - } -} diff --git a/internal/backend/core.go b/internal/backend/core.go @@ -1,228 +0,0 @@ -// Package backend handles kdbx interactions -package backend - -import ( - "errors" - "fmt" - "iter" - "os" - "strings" - - "git.sr.ht/~enckse/lockbox/internal/config" - "git.sr.ht/~enckse/lockbox/internal/platform" - "github.com/tobischo/gokeepasslib/v3" - "github.com/tobischo/gokeepasslib/v3/wrappers" -) - -var ( - errPath = errors.New("input paths must contain at LEAST 2 components") - // AllowedFields are the same of allowed names for storing in a kdbx entry - AllowedFields = []string{NotesField, OTPField, PasswordField} -) - -const ( - titleKey = "Title" - pathSep = "/" - isGlob = pathSep + "*" - modTimeKey = "ModTime" - // OTPField is the totp storage attribute - OTPField = "otp" - // NotesField is the multiline notes key - NotesField = "Notes" - // PasswordField is where the password is stored - PasswordField = "Password" -) - -type ( - // EntityValues are what is stored, from an entity, into kdbx backing store - EntityValues map[string]string - // QuerySeq2 wraps the iteration for query entities - QuerySeq2 iter.Seq2[Entity, error] - // Transaction handles the overall operation of the transaction - Transaction struct { - file string - valid bool - exists bool - write bool - readonly bool - } - // Context handles operating on the underlying database - Context struct { - db *gokeepasslib.Database - } - // Entity are database objects from results and transactional changes - Entity struct { - Values EntityValues - Path string - } -) - -// Load will load a kdbx file for transactions -func Load(file string) (*Transaction, error) { - return loadFile(file, true) -} - -func loadFile(file string, must bool) (*Transaction, error) { - if strings.TrimSpace(file) == "" { - return nil, errors.New("no store set") - } - if !strings.HasSuffix(file, ".kdbx") { - return nil, errors.New("should use a .kdbx extension") - } - exists := platform.PathExists(file) - if must { - if !exists { - return nil, errors.New("invalid file, does not exist") - } - } - ro := config.EnvReadOnly.Get() - return &Transaction{valid: true, file: file, exists: exists, readonly: ro}, nil -} - -// NewTransaction will use the underlying environment data store location -func NewTransaction() (*Transaction, error) { - return loadFile(config.EnvStore.Get(), false) -} - -func splitComponents(path string) ([]string, string, error) { - if len(strings.Split(path, pathSep)) < 2 { - return nil, "", errPath - } - if strings.HasPrefix(path, pathSep) { - return nil, "", errors.New("path can NOT be rooted") - } - if strings.HasSuffix(path, pathSep) { - return nil, "", errors.New("path can NOT end with separator") - } - if strings.Contains(path, pathSep+pathSep) { - return nil, "", errors.New("unwilling to operate on path with empty segment") - } - title := Base(path) - parts := strings.Split(Directory(path), pathSep) - return parts, title, nil -} - -func getCredentials(key, keyFile string) (*gokeepasslib.DBCredentials, error) { - hasKey := len(key) > 0 - hasKeyFile := len(keyFile) > 0 - if !hasKey && !hasKeyFile { - return nil, errors.New("key and/or keyfile must be set") - } - if hasKeyFile { - if !platform.PathExists(keyFile) { - return nil, errors.New("no keyfile found on disk") - } - if !hasKey { - return gokeepasslib.NewKeyCredentials(keyFile) - } - return gokeepasslib.NewPasswordAndKeyCredentials(key, keyFile) - } - return gokeepasslib.NewPasswordCredentials(key), nil -} - -func create(file, key, keyFile string) error { - root := gokeepasslib.NewGroup() - root.Name = "root" - db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4()) - creds, err := getCredentials(key, keyFile) - if err != nil { - return err - } - db.Credentials = creds - db.Content.Root = &gokeepasslib.RootData{ - Groups: []gokeepasslib.Group{root}, - } - if err := db.LockProtectedEntries(); err != nil { - return err - } - - f, err := os.Create(file) - if err != nil { - return err - } - defer f.Close() - return encode(f, db) -} - -func encode(f *os.File, db *gokeepasslib.Database) error { - return gokeepasslib.NewEncoder(f).Encode(db) -} - -func getPathName(entry gokeepasslib.Entry) string { - return entry.GetTitle() -} - -func value(key, value string) gokeepasslib.ValueData { - return gokeepasslib.ValueData{Key: key, Value: gokeepasslib.V{Content: value}} -} - -func protectedValue(key, value string) gokeepasslib.ValueData { - return gokeepasslib.ValueData{ - Key: key, - Value: gokeepasslib.V{Content: value, Protected: wrappers.NewBoolWrapper(true)}, - } -} - -// NewSuffix creates a new user 'name' suffix -func NewSuffix(name string) string { - return fmt.Sprintf("%s%s", pathSep, name) -} - -// NewPath creates a new storage location path. -func NewPath(segments ...string) string { - return strings.Join(segments, pathSep) -} - -// Value will read an entity value -func (e Entity) Value(key string) (string, bool) { - if e.Values == nil { - return "", false - } - val, ok := e.Values[key] - return val, ok -} - -// Base will get the base name of input path -func Base(s string) string { - parts := strings.Split(s, pathSep) - if len(parts) == 0 { - return s - } - return parts[len(parts)-1] -} - -// Directory will get the directory/group for the given path -func Directory(s string) string { - parts := strings.Split(s, pathSep) - return NewPath(parts[0 : len(parts)-1]...) -} - -func getValue(e gokeepasslib.Entry, key string) string { - v := e.Get(key) - if v == nil { - return "" - } - return v.Value.Content -} - -// IsDirectory will indicate if a path looks like a group/directory -func IsDirectory(path string) bool { - return strings.HasSuffix(path, pathSep) -} - -// IsLeafAttribute indicates if a path is leaved with a certain name -func IsLeafAttribute(path, attr string) bool { - return strings.HasSuffix(path, pathSep+attr) -} - -// Collect will create a slice from an iterable set of query sequence results -func (s QuerySeq2) Collect() ([]Entity, error) { - var entities []Entity - for entity, err := range s { - if err != nil { - return nil, err - } - entities = append(entities, entity) - } - return entities, nil -} diff --git a/internal/backend/core_test.go b/internal/backend/core_test.go @@ -1,155 +0,0 @@ -package backend_test - -import ( - "errors" - "testing" - - "git.sr.ht/~enckse/lockbox/internal/backend" -) - -func TestLoad(t *testing.T) { - if _, err := backend.Load(" "); err.Error() != "no store set" { - t.Errorf("invalid error: %v", err) - } - if _, err := backend.Load("garbage"); err.Error() != "should use a .kdbx extension" { - t.Errorf("invalid error: %v", err) - } - if _, err := backend.Load("garbage.kdbx"); err.Error() != "invalid file, does not exist" { - t.Errorf("invalid error: %v", err) - } -} - -func TestIsDirectory(t *testing.T) { - if backend.IsDirectory("") { - t.Error("invalid directory detection") - } - if !backend.IsDirectory("/") { - t.Error("invalid directory detection") - } - if backend.IsDirectory("/a") { - t.Error("invalid directory detection") - } -} - -func TestBase(t *testing.T) { - b := backend.Base("") - if b != "" { - t.Error("invalid base") - } - b = backend.Base("aaa") - if b != "aaa" { - t.Error("invalid base") - } - b = backend.Base("aaa/") - if b != "" { - t.Error("invalid base") - } - b = backend.Base("aaa/a") - if b != "a" { - t.Error("invalid base") - } -} - -func TestDirectory(t *testing.T) { - b := backend.Directory("") - if b != "" { - t.Error("invalid directory") - } - b = backend.Directory("/") - if b != "" { - t.Error("invalid directory") - } - b = backend.Directory("/a") - if b != "" { - t.Error("invalid directory") - } - b = backend.Directory("a") - if b != "" { - t.Error("invalid directory") - } - b = backend.Directory("b/a") - if b != "b" { - t.Error("invalid directory") - } -} - -func TestIsLeafAttr(t *testing.T) { - if backend.IsLeafAttribute("axyz", "z") { - t.Error("invalid result") - } - if !backend.IsLeafAttribute("axy/z", "z") { - t.Error("invalid result") - } -} - -func TestNewPath(t *testing.T) { - p := backend.NewPath("abc", "xyz") - if p != backend.NewPath("abc", "xyz") { - t.Error("invalid new path") - } -} - -func TestNewSuffix(t *testing.T) { - if backend.NewSuffix("test") != "/test" { - t.Error("invalid suffix") - } -} - -func generateTestSeq(hasError, extra bool) backend.QuerySeq2 { - return func(yield func(backend.Entity, error) bool) { - if !yield(backend.Entity{}, nil) { - return - } - if !yield(backend.Entity{}, nil) { - return - } - if hasError { - if !yield(backend.Entity{}, errors.New("test collect error")) { - return - } - } - if !yield(backend.Entity{}, nil) { - return - } - if extra { - if !yield(backend.Entity{}, nil) { - return - } - } - } -} - -func TestQuerySeq2Collect(t *testing.T) { - seq := generateTestSeq(true, true) - if _, err := seq.Collect(); err == nil || err.Error() != "test collect error" { - t.Errorf("invalid error: %v", err) - } - seq = generateTestSeq(false, false) - c, err := seq.Collect() - if err != nil || len(c) != 3 { - t.Errorf("invalid collect: %v %v %d", c, err, len(c)) - } - seq = generateTestSeq(false, true) - c, err = seq.Collect() - if err != nil || len(c) != 4 { - t.Errorf("invalid collect: %v %v %d", c, err, len(c)) - } -} - -func TestEntityValue(t *testing.T) { - e := backend.Entity{} - if _, ok := e.Value("key"); ok { - t.Error("values are nil") - } - e.Values = make(map[string]string) - if _, ok := e.Value("key"); ok { - t.Error("values are not set") - } - e.Values["key2"] = "1" - if _, ok := e.Value("key"); ok { - t.Error("values are not matching") - } - if val, ok := e.Value("key2"); !ok || val != "1" { - t.Error("values are not set") - } -} diff --git a/internal/backend/query.go b/internal/backend/query.go @@ -1,241 +0,0 @@ -// Package backend handles querying a store -package backend - -import ( - "crypto/sha512" - "errors" - "fmt" - "regexp" - "slices" - "strings" - - "git.sr.ht/~enckse/lockbox/internal/config" - "git.sr.ht/~enckse/lockbox/internal/output" - "github.com/tobischo/gokeepasslib/v3" -) - -type ( - // QueryOptions indicates how to find entities - QueryOptions struct { - PathFilter string - Criteria string - Mode QueryMode - Values ValueMode - } - // QueryMode indicates HOW an entity will be found - QueryMode int - // ValueMode indicates what to do with the store value of the entity - ValueMode int -) - -const ( - // BlankValue will not decrypt secrets, empty value - BlankValue ValueMode = iota - // SecretValue will have the raw secret onboard - SecretValue - // JSONValue will show entries as a JSON payload - JSONValue -) - -const ( - noneMode QueryMode = iota - // ListMode indicates ALL entities will be listed - ListMode - // FindMode indicates a _contains_ search for an entity - FindMode - // ExactMode means an entity must MATCH the string exactly - ExactMode - // PrefixMode allows for entities starting with a specific value - PrefixMode -) - -// MatchPath will try to match 1 or more elements (more elements when globbing) -func (t *Transaction) MatchPath(path string) ([]Entity, error) { - if !strings.HasSuffix(path, isGlob) { - e, err := t.Get(path, BlankValue) - if err != nil { - return nil, err - } - if e == nil { - return nil, nil - } - return []Entity{*e}, nil - } - prefix := strings.TrimSuffix(path, isGlob) - if strings.HasSuffix(prefix, pathSep) { - return nil, errors.New("invalid match criteria, too many path separators") - } - return t.queryCollect(QueryOptions{Mode: PrefixMode, Criteria: prefix + pathSep, Values: BlankValue}) -} - -// Get will request a singular entity -func (t *Transaction) Get(path string, mode ValueMode) (*Entity, error) { - _, _, err := splitComponents(path) - if err != nil { - return nil, err - } - e, err := t.queryCollect(QueryOptions{Mode: ExactMode, Criteria: path, Values: mode}) - if err != nil { - return nil, err - } - switch len(e) { - case 0: - return nil, nil - case 1: - return &e[0], nil - default: - return nil, errors.New("too many entities matched") - } -} - -func forEach(offset string, groups []gokeepasslib.Group, entries []gokeepasslib.Entry, cb func(string, gokeepasslib.Entry)) { - for _, g := range groups { - o := "" - if offset == "" { - o = g.Name - } else { - o = NewPath(offset, g.Name) - } - forEach(o, g.Groups, g.Entries, cb) - } - for _, e := range entries { - cb(offset, e) - } -} - -func (t *Transaction) queryCollect(args QueryOptions) ([]Entity, error) { - e, err := t.QueryCallback(args) - if err != nil { - return nil, err - } - return e.Collect() -} - -// QueryCallback will retrieve a query based on setting -func (t *Transaction) QueryCallback(args QueryOptions) (QuerySeq2, error) { - if args.Mode == noneMode { - return nil, errors.New("no query mode specified") - } - type entity struct { - path string - backing gokeepasslib.Entry - } - var entities []entity - isSort := args.Mode != ExactMode - decrypt := args.Values != BlankValue - hasPathFilter := args.PathFilter != "" - var pathFilter *regexp.Regexp - if hasPathFilter { - var err error - pathFilter, err = regexp.Compile(args.PathFilter) - if err != nil { - return nil, err - } - } - err := t.act(func(ctx Context) error { - forEach("", ctx.db.Content.Root.Groups[0].Groups, ctx.db.Content.Root.Groups[0].Entries, func(offset string, entry gokeepasslib.Entry) { - path := getPathName(entry) - if offset != "" { - path = NewPath(offset, path) - } - if isSort { - switch args.Mode { - case FindMode: - if !strings.Contains(path, args.Criteria) { - return - } - case PrefixMode: - if !strings.HasPrefix(path, args.Criteria) { - return - } - } - } else { - if args.Mode == ExactMode { - if path != args.Criteria { - return - } - } - } - if hasPathFilter { - if !pathFilter.MatchString(path) { - return - } - } - obj := entity{backing: entry, path: path} - if isSort && len(entities) > 0 { - i, _ := slices.BinarySearchFunc(entities, obj, func(i, j entity) int { - return strings.Compare(i.path, j.path) - }) - entities = slices.Insert(entities, i, obj) - } else { - entities = append(entities, obj) - } - }) - if decrypt { - return ctx.db.UnlockProtectedEntries() - } - return nil - }) - if err != nil { - return nil, err - } - jsonMode := output.JSONModes.Blank - if args.Values == JSONValue { - m, err := output.ParseJSONMode(config.EnvJSONMode.Get()) - if err != nil { - return nil, err - } - jsonMode = m - } - jsonHasher := func(string) string { - return "" - } - switch jsonMode { - case output.JSONModes.Raw: - jsonHasher = func(val string) string { - return val - } - case output.JSONModes.Hash: - hashLength, err := config.EnvJSONHashLength.Get() - if err != nil { - return nil, err - } - l := int(hashLength) - jsonHasher = func(val string) string { - data := fmt.Sprintf("%x", sha512.Sum512([]byte(val))) - if hashLength > 0 && len(data) > l { - data = data[0:hashLength] - } - return data - } - } - return func(yield func(Entity, error) bool) { - for _, item := range entities { - entity := Entity{Path: item.path} - var err error - values := make(EntityValues) - for _, v := range item.backing.Values { - val := "" - key := v.Key - if args.Values != BlankValue { - if args.Values == JSONValue { - values["modtime"] = getValue(item.backing, modTimeKey) - } - val = v.Value.Content - switch args.Values { - case JSONValue: - val = jsonHasher(val) - } - } - if key == modTimeKey || key == titleKey { - continue - } - values[strings.ToLower(key)] = val - } - entity.Values = values - if !yield(entity, err) { - return - } - } - }, nil -} diff --git a/internal/backend/query_test.go b/internal/backend/query_test.go @@ -1,376 +0,0 @@ -package backend_test - -import ( - "errors" - "fmt" - "strings" - "testing" - - "git.sr.ht/~enckse/lockbox/internal/backend" - "git.sr.ht/~enckse/lockbox/internal/config/store" -) - -func compareEntity(actual *backend.Entity, expect backend.Entity) bool { - if err := compareToEntity(actual, expect); err != nil { - return false - } - return true -} - -func compareToEntity(actual *backend.Entity, expect backend.Entity) error { - if actual == nil || actual.Values == nil { - return errors.New("invalid actual") - } - if actual.Path == "" || actual.Path != expect.Path { - return errors.New("invalid actual, no path") - } - for k, v := range actual.Values { - isMod := k == "modtime" - if isMod { - if len(v) < 20 { - return fmt.Errorf("%s invalid mod time", k) - } - } - e, ok := expect.Value(k) - if !ok { - if !isMod { - return fmt.Errorf("%s is missing from expected", k) - } - } - if e != v { - if isMod { - if e == "" { - continue - } - } - return fmt.Errorf("mismatch %s: (%s != %s)", k, e, v) - } - } - return nil -} - -func setupInserts(t *testing.T) { - setup(t) - fullSetup(t, true).Insert("test/test/abc", map[string]string{"password": "tedst", "notes": "xxx"}) - fullSetup(t, true).Insert("test/test/abcx", map[string]string{"password": "tedst"}) - fullSetup(t, true).Insert("test/test/ab11c", map[string]string{"password": "tedst", "notes": "tdest\ntest"}) - fullSetup(t, true).Insert("test/test/abc1ak", map[string]string{"password": "atest", "notes": "atest"}) -} - -func TestMatchPath(t *testing.T) { - store.Clear() - setupInserts(t) - q, err := fullSetup(t, true).MatchPath("test/test/abc") - if err != nil { - t.Errorf("no error: %v", err) - } - if len(q) != 1 { - t.Error("invalid entity result") - } - if q[0].Path != "test/test/abc" { - t.Error("invalid query result") - } - for _, k := range []string{"notes", "password"} { - if val, ok := q[0].Value(k); !ok || val != "" { - t.Errorf("invalid result value: %s", k) - } - } - q, err = fullSetup(t, true).MatchPath("test/test/abcxxx") - if err != nil { - t.Errorf("no error: %v", err) - } - if len(q) != 0 { - t.Error("invalid entity result") - } - q, err = fullSetup(t, true).MatchPath("test/test/*") - if err != nil { - t.Errorf("no error: %v", err) - } - if len(q) != 4 { - t.Error("invalid entity result") - } - if _, err := fullSetup(t, true).MatchPath("test/test//*"); err.Error() != "invalid match criteria, too many path separators" { - t.Errorf("wrong error: %v", err) - } - q, err = fullSetup(t, true).MatchPath("test/test*") - if err != nil { - t.Errorf("no error: %v", err) - } - if len(q) != 0 { - t.Error("invalid entity result") - } -} - -func TestGet(t *testing.T) { - setupInserts(t) - q, err := fullSetup(t, true).Get("test/test/abc", backend.BlankValue) - if err != nil { - t.Errorf("no error: %v", err) - } - if q.Path != "test/test/abc" { - t.Error("invalid query result") - } - for _, k := range []string{"notes", "password"} { - if val, ok := q.Value(k); !ok || val != "" { - t.Errorf("invalid result value: %s", k) - } - } - q, err = fullSetup(t, true).Get("a/b/aaaa", backend.BlankValue) - if err != nil || q != nil { - t.Error("invalid result, should be empty") - } - if _, err := fullSetup(t, true).Get("aaaa", backend.BlankValue); err.Error() != "input paths must contain at LEAST 2 components" { - t.Errorf("invalid error: %v", err) - } -} - -func TestValueModes(t *testing.T) { - store.Clear() - setupInserts(t) - q, err := fullSetup(t, true).Get("test/test/abc", backend.BlankValue) - if err != nil { - t.Errorf("no error: %v", err) - } - for _, k := range []string{"notes", "password"} { - if val, ok := q.Value(k); !ok || val != "" { - t.Errorf("invalid result value: %s", k) - } - } - q, err = fullSetup(t, true).Get("test/test/abc", backend.JSONValue) - if err != nil { - t.Errorf("no error: %v", err) - } - if !compareEntity(q, backend.Entity{ - Path: "test/test/abc", - Values: map[string]string{ - "notes": "9057ff1aa9509b2a0af624d687461d2bbeb07e2f37d953b1ce4a9dc921a7f19c45dc35d7c5363b373792add57d0d7dc41596e1c585d6ef7844cdf8ae87af443f", - "password": "44276ba24db13df5568aa6db81e0190ab9d35d2168dce43dca61e628f5c666b1d8b091f1dda59c2359c86e7d393d59723a421d58496d279031e7f858c11d893e", - }, - }) { - t.Errorf("invalid entity: %v", q) - } - store.SetInt64("LOCKBOX_JSON_HASH_LENGTH", 10) - q, err = fullSetup(t, true).Get("test/test/abc", backend.JSONValue) - if err != nil { - t.Errorf("no error: %v", err) - } - if !compareEntity(q, backend.Entity{ - Path: "test/test/abc", - Values: map[string]string{ - "notes": "9057ff1aa9", - "password": "44276ba24d", - }, - }) { - t.Errorf("invalid entity: %v", q) - } - q, err = fullSetup(t, true).Get("test/test/ab11c", backend.SecretValue) - if err != nil { - t.Errorf("no error: %v", err) - } - if !compareEntity(q, backend.Entity{ - Path: "test/test/ab11c", - Values: map[string]string{ - "notes": "tdest\ntest", - "password": "tedst", - }, - }) { - t.Errorf("invalid entity: %v", q) - } - store.SetString("LOCKBOX_JSON_MODE", "plAINtExt") - q, err = fullSetup(t, true).Get("test/test/abc", backend.JSONValue) - if err != nil { - t.Errorf("no error: %v", err) - } - if !compareEntity(q, backend.Entity{ - Path: "test/test/abc", - Values: map[string]string{ - "notes": "xxx", - "password": "tedst", - }, - }) { - t.Errorf("invalid entity: %v", q) - } - store.SetString("LOCKBOX_JSON_MODE", "emPTY") - q, err = fullSetup(t, true).Get("test/test/abc", backend.JSONValue) - if err != nil { - t.Errorf("no error: %v", err) - } - if !compareEntity(q, backend.Entity{ - Path: "test/test/abc", - Values: map[string]string{ - "notes": "", - "password": "", - }, - }) { - t.Errorf("invalid entity: %v", q) - } -} - -func testCollect(t *testing.T, count int, seq backend.QuerySeq2) []backend.Entity { - collected, err := seq.Collect() - if err != nil { - t.Errorf("invalid collect error: %v", err) - } - if len(collected) != count { - t.Errorf("unexpected entity count: %d", count) - } - return collected -} - -func TestQueryCallback(t *testing.T) { - setupInserts(t) - if _, err := fullSetup(t, true).QueryCallback(backend.QueryOptions{}); err.Error() != "no query mode specified" { - t.Errorf("wrong error: %v", err) - } - seq, err := fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.ListMode}) - if err != nil { - t.Errorf("no error: %v", err) - } - res := testCollect(t, 4, seq) - if res[0].Path != "test/test/ab11c" || res[1].Path != "test/test/abc" || res[2].Path != "test/test/abc1ak" || res[3].Path != "test/test/abcx" { - t.Errorf("invalid results: %v", res) - } - seq, err = fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.FindMode, Criteria: "1"}) - if err != nil { - t.Errorf("no error: %v", err) - } - res = testCollect(t, 2, seq) - if res[0].Path != "test/test/ab11c" || res[1].Path != "test/test/abc1ak" { - t.Errorf("invalid results: %v", res) - } - seq, err = fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.ExactMode, Criteria: "test/test/abc"}) - if err != nil { - t.Errorf("no error: %v", err) - } - res = testCollect(t, 1, seq) - if res[0].Path != "test/test/abc" { - t.Errorf("invalid results: %v", res) - } - seq, err = fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.ExactMode, Criteria: "test/test/abc", PathFilter: "abc"}) - if err != nil { - t.Errorf("no error: %v", err) - } - res = testCollect(t, 1, seq) - if res[0].Path != "test/test/abc" { - t.Errorf("invalid results: %v", res) - } - seq, err = fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.ExactMode, Criteria: "test/test/abc", PathFilter: "abz"}) - if err != nil { - t.Errorf("no error: %v", err) - } - testCollect(t, 0, seq) - seq, err = fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.ExactMode, Criteria: "abczzz"}) - if err != nil { - t.Errorf("no error: %v", err) - } - testCollect(t, 0, seq) -} - -func TestSetModTime(t *testing.T) { - store.Clear() - testDateTime := "2022-12-30T12:34:56-05:00" - tr := fullSetup(t, false) - store.SetString("LOCKBOX_DEFAULTS_MODTIME", testDateTime) - tr.Insert("test/xyz", map[string]string{"password": "test"}) - q, err := fullSetup(t, true).Get("test/xyz", backend.JSONValue) - if err != nil { - t.Errorf("no error: %v", err) - } - if !compareEntity(q, backend.Entity{ - Path: "test/xyz", - Values: map[string]string{ - "password": "ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff", - "modtime": testDateTime, - }, - }) { - t.Errorf("invalid entity: %v", q) - } - - store.Clear() - tr = fullSetup(t, false) - tr.Insert("test/xyz", map[string]string{"password": "test"}) - q, err = fullSetup(t, true).Get("test/xyz", backend.JSONValue) - if err != nil { - t.Errorf("no error: %v", err) - } - - if val, ok := q.Value("modtime"); !ok || len(val) < 20 || val == testDateTime { - t.Errorf("invalid mod: %s", val) - } - - tr = fullSetup(t, false) - store.SetString("LOCKBOX_DEFAULTS_MODTIME", "garbage") - err = tr.Insert("test/xyz", map[string]string{"password": "test"}) - if err == nil || !strings.Contains(err.Error(), "parsing time") { - t.Errorf("invalid error: %v", err) - } -} - -func TestAttributeModes(t *testing.T) { - store.Clear() - setupInserts(t) - fullSetup(t, true).Insert("test/test/totp", map[string]string{"otp": "atest"}) - q, err := fullSetup(t, true).Get("test/test/totp", backend.BlankValue) - if err != nil { - t.Errorf("no error: %v", err) - } - if !compareEntity(q, backend.Entity{ - Path: "test/test/totp", - Values: map[string]string{ - "otp": "", - }, - }) { - t.Errorf("invalid entity: %v", q) - } - q, err = fullSetup(t, true).Get("test/test/totp", backend.JSONValue) - if err != nil { - t.Errorf("no error: %v", err) - } - if !compareEntity(q, backend.Entity{ - Path: "test/test/totp", - Values: map[string]string{ - "otp": "7f8fd0e1a714f63da75206748d0ea1dd601fc8f92498bc87c9579b403c3004a0eefdd7ead976f7dbd6e5143c9aa7a569e24322d870ec7745a4605a154557458e", - }, - }) { - t.Errorf("invalid entity: %v", q) - } - store.SetInt64("LOCKBOX_JSON_HASH_LENGTH", 10) - q, err = fullSetup(t, true).Get("test/test/totp", backend.JSONValue) - if err != nil { - t.Errorf("no error: %v", err) - } - if !compareEntity(q, backend.Entity{ - Path: "test/test/totp", - Values: map[string]string{ - "otp": "7f8fd0e1a7", - }, - }) { - t.Errorf("invalid entity: %v", q) - } - store.SetString("LOCKBOX_JSON_MODE", "PlAINtExt") - q, err = fullSetup(t, true).Get("test/test/totp", backend.JSONValue) - if err != nil { - t.Errorf("no error: %v", err) - } - if !compareEntity(q, backend.Entity{ - Path: "test/test/totp", - Values: map[string]string{ - "otp": "otpauth://totp/lbissuer:lbaccount?algorithm=SHA1&digits=6&issuer=lbissuer&period=30&secret=atest", - }, - }) { - t.Errorf("invalid entity: %v", q) - } - store.SetString("LOCKBOX_JSON_MODE", "emPty") - q, err = fullSetup(t, true).Get("test/test/totp", backend.JSONValue) - if err != nil { - t.Errorf("no error: %v", err) - } - if !compareEntity(q, backend.Entity{ - Path: "test/test/totp", - Values: map[string]string{ - "otp": "", - }, - }) { - t.Errorf("invalid entity: %v", q) - } -} diff --git a/internal/kdbx/actions.go b/internal/kdbx/actions.go @@ -0,0 +1,281 @@ +// Package backend handles kdbx interactions +package kdbx + +import ( + "errors" + "fmt" + "os" + "strings" + "time" + + "git.sr.ht/~enckse/lockbox/internal/config" + "git.sr.ht/~enckse/lockbox/internal/platform" + "github.com/tobischo/gokeepasslib/v3" +) + +type ( + action func(t Context) error +) + +func (t *Transaction) act(cb action) error { + if !t.valid { + return errors.New("invalid transaction") + } + key, err := config.NewKey(config.DefaultKeyMode) + if err != nil { + return err + } + k, err := key.Read(platform.ReadInteractivePassword) + if err != nil { + return err + } + file := config.EnvKeyFile.Get() + if !t.exists { + if err := create(t.file, k, file); err != nil { + return err + } + } + f, err := os.Open(t.file) + if err != nil { + return err + } + defer f.Close() + db := gokeepasslib.NewDatabase() + creds, err := getCredentials(k, file) + if err != nil { + return err + } + db.Credentials = creds + if err := gokeepasslib.NewDecoder(f).Decode(db); err != nil { + return err + } + if len(db.Content.Root.Groups) != 1 { + return errors.New("kdbx must have ONE root group") + } + err = cb(Context{db: db}) + if err != nil { + return err + } + if t.write { + if err := db.LockProtectedEntries(); err != nil { + return err + } + if err := f.Close(); err != nil { + return err + } + f, err = os.Create(t.file) + if err != nil { + return err + } + defer f.Close() + return encode(f, db) + } + return err +} + +// ReKey will change the credentials on a database +func (t *Transaction) ReKey(pass, keyFile string) error { + creds, err := getCredentials(pass, keyFile) + if err != nil { + return err + } + return t.change(func(c Context) error { + c.db.Credentials = creds + return nil + }) +} + +func (t *Transaction) change(cb action) error { + if t.readonly { + return errors.New("unable to alter database in readonly mode") + } + return t.act(func(c Context) error { + if err := c.db.UnlockProtectedEntries(); err != nil { + return err + } + t.write = true + return cb(c) + }) +} + +func (c Context) alterEntities(isAdd bool, offset []string, title string, entity *gokeepasslib.Entry) bool { + g, e, ok := findAndDo(isAdd, title, offset, entity, c.db.Content.Root.Groups[0].Groups, c.db.Content.Root.Groups[0].Entries) + c.db.Content.Root.Groups[0].Groups = g + c.db.Content.Root.Groups[0].Entries = e + return ok +} + +func (c Context) removeEntity(offset []string, title string) bool { + return c.alterEntities(false, offset, title, nil) +} + +func findAndDo(isAdd bool, entityName string, offset []string, opEntity *gokeepasslib.Entry, g []gokeepasslib.Group, e []gokeepasslib.Entry) ([]gokeepasslib.Group, []gokeepasslib.Entry, bool) { + done := false + if len(offset) == 0 { + if isAdd { + e = append(e, *opEntity) + } else { + var entries []gokeepasslib.Entry + for _, entry := range e { + if getPathName(entry) == entityName { + continue + } + entries = append(entries, entry) + } + e = entries + } + done = true + } else { + name := offset[0] + remaining := []string{} + if len(offset) > 1 { + remaining = offset[1:] + } + if isAdd { + match := false + for _, group := range g { + if group.Name == name { + match = true + } + } + if !match { + newGroup := gokeepasslib.NewGroup() + newGroup.Name = name + g = append(g, newGroup) + } + } + var updateGroups []gokeepasslib.Group + for _, group := range g { + if !done && group.Name == name { + groups, entries, ok := findAndDo(isAdd, entityName, remaining, opEntity, group.Groups, group.Entries) + group.Entries = entries + group.Groups = groups + if ok { + done = true + } + } + updateGroups = append(updateGroups, group) + } + g = updateGroups + if !isAdd { + var groups []gokeepasslib.Group + for _, group := range g { + if group.Name == name && len(group.Entries) == 0 && len(group.Groups) == 0 { + continue + } + groups = append(groups, group) + } + g = groups + } + } + return g, e, done +} + +// Move will move a src object to a dst location +func (t *Transaction) Move(src *Entity, dst string) error { + if src == nil { + return errors.New("source entity is not set") + } + if strings.TrimSpace(src.Path) == "" { + return errors.New("empty path not allowed") + } + if len(src.Values) == 0 { + return errors.New("empty secrets not allowed") + } + values := make(map[string]string) + for k, v := range src.Values { + found := false + for _, mapping := range AllowedFields { + if strings.EqualFold(k, mapping) { + values[mapping] = v + found = true + break + } + } + if !found { + return fmt.Errorf("unknown entity field: %s", k) + } + } + mod := config.EnvDefaultModTime.Get() + modTime := time.Now() + if mod != "" { + p, err := time.Parse(config.ModTimeFormat, mod) + if err != nil { + return err + } + modTime = p + } + dOffset, dTitle, err := splitComponents(dst) + if err != nil { + return err + } + sOffset, sTitle, err := splitComponents(src.Path) + if err != nil { + return err + } + isMove := dst != src.Path + return t.change(func(c Context) error { + c.removeEntity(sOffset, sTitle) + if isMove { + c.removeEntity(dOffset, dTitle) + } + e := gokeepasslib.NewEntry() + e.Values = append(e.Values, value(titleKey, dTitle)) + e.Values = append(e.Values, value(modTimeKey, modTime.Format(time.RFC3339))) + for k, v := range values { + val := v + switch k { + case OTPField, PasswordField: + if strings.Contains(val, "\n") { + return fmt.Errorf("%s can NOT be multi-line", strings.ToLower(k)) + } + if k == OTPField { + val = config.EnvTOTPFormat.Get(v) + } + } + e.Values = append(e.Values, protectedValue(k, val)) + } + c.alterEntities(true, dOffset, dTitle, &e) + return nil + }) +} + +// Insert is a move to the same location +func (t *Transaction) Insert(path string, val EntityValues) error { + return t.Move(&Entity{Path: path, Values: val}, path) +} + +// Remove will remove a single entity +func (t *Transaction) Remove(entity *Entity) error { + if entity == nil { + return errors.New("entity is empty/invalid") + } + return t.RemoveAll([]Entity{*entity}) +} + +// RemoveAll handles removing elements +func (t *Transaction) RemoveAll(entities []Entity) error { + if len(entities) == 0 { + return errors.New("no entities given") + } + type removal struct { + title string + parts []string + } + removals := []removal{} + for _, entity := range entities { + offset, title, err := splitComponents(entity.Path) + if err != nil { + return err + } + removals = append(removals, removal{parts: offset, title: title}) + } + return t.change(func(c Context) error { + for _, entity := range removals { + if ok := c.removeEntity(entity.parts, entity.title); !ok { + return errors.New("failed to remove entity") + } + } + return nil + }) +} diff --git a/internal/kdbx/actions_test.go b/internal/kdbx/actions_test.go @@ -0,0 +1,337 @@ +package kdbx_test + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "git.sr.ht/~enckse/lockbox/internal/kdbx" + "git.sr.ht/~enckse/lockbox/internal/config/store" + "git.sr.ht/~enckse/lockbox/internal/platform" +) + +const ( + testDir = "testdata" +) + +func testFile(name string) string { + file := filepath.Join(testDir, name) + if !platform.PathExists(testDir) { + os.Mkdir(testDir, 0o755) + } + return file +} + +func fullSetup(t *testing.T, keep bool) *kdbx.Transaction { + file := testFile("test.kdbx") + if !keep { + os.Remove(file) + } + store.SetBool("LOCKBOX_READONLY", false) + store.SetString("LOCKBOX_STORE", file) + store.SetArray("LOCKBOX_CREDENTIALS_PASSWORD", []string{"test"}) + store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "plaintext") + store.SetString("LOCKBOX_TOTP_ENTRY", "totp") + tr, err := kdbx.NewTransaction() + if err != nil { + t.Errorf("failed: %v", err) + } + return tr +} + +func TestKeyFile(t *testing.T) { + store.Clear() + defer store.Clear() + file := testFile("keyfile_test.kdbx") + keyFile := testFile("file.key") + os.Remove(file) + store.SetString("LOCKBOX_STORE", file) + store.SetArray("LOCKBOX_CREDENTIALS_PASSWORD", []string{"test"}) + store.SetString("LOCKBOX_CREDENTIALS_KEY_FILE", keyFile) + store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "plaintext") + store.SetString("LOCKBOX_TOTP_ENTRY", "totp") + os.WriteFile(keyFile, []byte("test"), 0o644) + tr, err := kdbx.NewTransaction() + if err != nil { + t.Errorf("failed: %v", err) + } + if err := tr.Insert(kdbx.NewPath("a", "b"), map[string]string{"password": "t"}); err != nil { + t.Errorf("no error: %v", err) + } +} + +func setup(t *testing.T) *kdbx.Transaction { + return fullSetup(t, false) +} + +func TestNoWriteOnRO(t *testing.T) { + setup(t) + store.SetBool("LOCKBOX_READONLY", true) + tr, _ := kdbx.NewTransaction() + if err := tr.Insert("a/a/a", map[string]string{"password": "xyz"}); err.Error() != "unable to alter database in readonly mode" { + t.Errorf("wrong error: %v", err) + } +} + +func TestBadAction(t *testing.T) { + tr := &kdbx.Transaction{} + if err := tr.Insert("a/a/a", map[string]string{"notes": "xyz"}); err.Error() != "invalid transaction" { + t.Errorf("wrong error: %v", err) + } +} + +func TestMove(t *testing.T) { + setup(t) + fullSetup(t, true).Insert(kdbx.NewPath("test", "test2", "test1"), map[string]string{"passworD": "pass"}) + fullSetup(t, true).Insert(kdbx.NewPath("test", "test2", "test3"), map[string]string{"NoTES": "pass", "password": "xxx"}) + if err := fullSetup(t, true).Move(nil, ""); err == nil || err.Error() != "source entity is not set" { + t.Errorf("no error: %v", err) + } + if err := fullSetup(t, true).Move(&kdbx.Entity{Path: kdbx.NewPath("test", "test2", "test3"), Values: map[string]string{"Notes": "abc"}}, kdbx.NewPath("test1", "test2", "test3")); err != nil { + t.Errorf("no error: %v", err) + } + q, err := fullSetup(t, true).Get(kdbx.NewPath("test1", "test2", "test3"), kdbx.SecretValue) + if err != nil { + t.Errorf("no error: %v", err) + } + if val, ok := q.Value("notes"); !ok || val != "abc" { + t.Errorf("invalid retrieval") + } + if err := fullSetup(t, true).Move(&kdbx.Entity{Path: kdbx.NewPath("test", "test2", "test1"), Values: map[string]string{"password": "test"}}, kdbx.NewPath("test1", "test2", "test3")); err != nil { + t.Errorf("no error: %v", err) + } + q, err = fullSetup(t, true).Get(kdbx.NewPath("test1", "test2", "test3"), kdbx.SecretValue) + if err != nil { + t.Errorf("no error: %v", err) + } + if val, ok := q.Value("password"); !ok || val != "test" { + t.Errorf("invalid retrieval") + } +} + +func TestInserts(t *testing.T) { + if err := setup(t).Insert("", nil); err.Error() != "empty path not allowed" { + t.Errorf("wrong error: %v", err) + } + if err := setup(t).Insert("a", map[string]string{"randomfield": "1"}); err.Error() != "unknown entity field: randomfield" { + t.Errorf("wrong error: %v", err) + } + if err := setup(t).Insert("tests", map[string]string{"notes": "1"}); err.Error() != "input paths must contain at LEAST 2 components" { + t.Errorf("wrong error: %v", err) + } + if err := setup(t).Insert("tests//l", map[string]string{"notes": "test"}); err.Error() != "unwilling to operate on path with empty segment" { + t.Errorf("wrong error: %v", err) + } + if err := setup(t).Insert("tests/", map[string]string{"password": "test"}); err.Error() != "path can NOT end with separator" { + t.Errorf("wrong error: %v", err) + } + if err := setup(t).Insert("/tests", map[string]string{"password": "test"}); err.Error() != "path can NOT be rooted" { + t.Errorf("wrong error: %v", err) + } + if err := setup(t).Insert("test", map[string]string{"otp": "test"}); err.Error() != "input paths must contain at LEAST 2 components" { + t.Errorf("wrong error: %v", err) + } + if err := setup(t).Insert("a", nil); err.Error() != "empty secrets not allowed" { + t.Errorf("wrong error: %v", err) + } + if err := setup(t).Insert("a", make(map[string]string)); err.Error() != "empty secrets not allowed" { + t.Errorf("wrong error: %v", err) + } + if err := setup(t).Insert(kdbx.NewPath("test", "offset", "value"), map[string]string{"password": "pass"}); err != nil { + t.Errorf("no error: %v", err) + } + if err := fullSetup(t, true).Insert(kdbx.NewPath("test", "offset", "value"), map[string]string{"NoTes": "pass2"}); err != nil { + t.Errorf("wrong error: %v", err) + } + if err := fullSetup(t, true).Insert(kdbx.NewPath("test", "offset", "value2"), map[string]string{"NOTES": "pass\npass", "password": "xxx", "otP": "zzz"}); err != nil { + t.Errorf("no error: %v", err) + } + q, err := fullSetup(t, true).Get(kdbx.NewPath("test", "offset", "value"), kdbx.SecretValue) + if err != nil { + t.Errorf("no error: %v", err) + } + if val, ok := q.Value("notes"); !ok || val != "pass2" { + t.Errorf("invalid retrieval") + } + q, err = fullSetup(t, true).Get(kdbx.NewPath("test", "offset", "value2"), kdbx.SecretValue) + if err != nil { + t.Errorf("no error: %v", err) + } + if val, ok := q.Value("notes"); !ok || val != "pass\npass" { + t.Errorf("invalid retrieval: %s", val) + } + if val, ok := q.Value("password"); !ok || val != "xxx" { + t.Errorf("invalid retrieval: %s", val) + } + if val, ok := q.Value("otp"); !ok || val != "otpauth://totp/lbissuer:lbaccount?algorithm=SHA1&digits=6&issuer=lbissuer&period=30&secret=zzz" { + t.Errorf("invalid retrieval: %s", val) + } + if err := fullSetup(t, true).Insert(kdbx.NewPath("test", "offset"), map[string]string{"otp": "5ae472sabqdekjqykoyxk7hvc2leklq5n"}); err != nil { + t.Errorf("no error: %v", err) + } + if err := fullSetup(t, true).Insert(kdbx.NewPath("test", "offset"), map[string]string{"OTP": "ljaf\n5ae472abqdekjqykoyxk7hvc2leklq5n"}); err == nil || err.Error() != "otp can NOT be multi-line" { + t.Errorf("wrong error: %v", err) + } + if err := fullSetup(t, true).Insert(kdbx.NewPath("test", "offset"), map[string]string{"password": "ljaf\n5ae472abqdekjqykoyxk7hvc2leklq5n"}); err == nil || err.Error() != "password can NOT be multi-line" { + t.Errorf("wrong error: %v", err) + } +} + +func TestRemoves(t *testing.T) { + if err := setup(t).Remove(nil); err.Error() != "entity is empty/invalid" { + t.Errorf("wrong error: %v", err) + } + if err := setup(t).Remove(&kdbx.Entity{}); err.Error() != "input paths must contain at LEAST 2 components" { + t.Errorf("wrong error: %v", err) + } + tx := kdbx.Entity{Path: kdbx.NewPath("test1", "test2", "test3")} + if err := setup(t).Remove(&tx); err.Error() != "failed to remove entity" { + t.Errorf("wrong error: %v", err) + } + setup(t) + for _, i := range []string{"test1", "test2"} { + fullSetup(t, true).Insert(kdbx.NewPath(i, i, i), map[string]string{"PASSWORD": "pass"}) + } + tx = kdbx.Entity{Path: kdbx.NewPath("test1", "test1", "test1")} + if err := fullSetup(t, true).Remove(&tx); err != nil { + t.Errorf("wrong error: %v", err) + } + if err := check(t, kdbx.NewPath("test2", "test2", "test2")); err != nil { + t.Errorf("invalid check: %v", err) + } + tx = kdbx.Entity{Path: kdbx.NewPath("test2", "test2", "test2")} + if err := fullSetup(t, true).Remove(&tx); err != nil { + t.Errorf("wrong error: %v", err) + } + setup(t) + for _, i := range []string{kdbx.NewPath("test", "test", "test1"), kdbx.NewPath("test", "test", "test2"), kdbx.NewPath("test", "test", "test3"), kdbx.NewPath("test", "test1", "test2"), kdbx.NewPath("test", "test1", "test5")} { + fullSetup(t, true).Insert(i, map[string]string{"password": "pass"}) + } + tx = kdbx.Entity{Path: "test/test/test3"} + if err := fullSetup(t, true).Remove(&tx); err != nil { + t.Errorf("wrong error: %v", err) + } + if err := check(t, kdbx.NewPath("test", "test", "test2"), kdbx.NewPath("test", "test", "test1"), kdbx.NewPath("test", "test1", "test2"), kdbx.NewPath("test", "test1", "test5")); err != nil { + t.Errorf("invalid check: %v", err) + } + tx = kdbx.Entity{Path: "test/test/test1"} + if err := fullSetup(t, true).Remove(&tx); err != nil { + t.Errorf("wrong error: %v", err) + } + if err := check(t, kdbx.NewPath("test", "test", "test2"), kdbx.NewPath("test", "test1", "test2"), kdbx.NewPath("test", "test1", "test5")); err != nil { + t.Errorf("invalid check: %v", err) + } + tx = kdbx.Entity{Path: "test/test1/test5"} + if err := fullSetup(t, true).Remove(&tx); err != nil { + t.Errorf("wrong error: %v", err) + } + if err := check(t, kdbx.NewPath("test", "test", "test2"), kdbx.NewPath("test", "test1", "test2")); err != nil { + t.Errorf("invalid check: %v", err) + } + tx = kdbx.Entity{Path: "test/test1/test2"} + if err := fullSetup(t, true).Remove(&tx); err != nil { + t.Errorf("wrong error: %v", err) + } + if err := check(t, kdbx.NewPath("test", "test", "test2")); err != nil { + t.Errorf("invalid check: %v", err) + } + tx = kdbx.Entity{Path: "test/test/test2"} + if err := fullSetup(t, true).Remove(&tx); err != nil { + t.Errorf("wrong error: %v", err) + } +} + +func TestRemoveAlls(t *testing.T) { + if err := setup(t).RemoveAll(nil); err.Error() != "no entities given" { + t.Errorf("wrong error: %v", err) + } + if err := setup(t).RemoveAll([]kdbx.Entity{}); err.Error() != "no entities given" { + t.Errorf("wrong error: %v", err) + } + setup(t) + for _, i := range []string{kdbx.NewPath("test", "test", "test1"), kdbx.NewPath("test", "test", "test2"), kdbx.NewPath("test", "test", "test3"), kdbx.NewPath("test", "test1", "test2"), kdbx.NewPath("test", "test1", "test5")} { + fullSetup(t, true).Insert(i, map[string]string{"PaSsWoRd": "pass"}) + } + if err := fullSetup(t, true).RemoveAll([]kdbx.Entity{{Path: "test/test/test3"}, {Path: "test/test/test1"}}); err != nil { + t.Errorf("wrong error: %v", err) + } + if err := check(t, kdbx.NewPath("test", "test", "test2"), kdbx.NewPath("test", "test1", "test2"), kdbx.NewPath("test", "test1", "test5")); err != nil { + t.Errorf("invalid check: %v", err) + } +} + +func check(t *testing.T, checks ...string) error { + tr := fullSetup(t, true) + for _, c := range checks { + q, err := tr.Get(c, kdbx.BlankValue) + if err != nil { + return err + } + if q == nil { + return fmt.Errorf("failed to find entity: %s", c) + } + } + return nil +} + +func TestKeyAndOrKeyFile(t *testing.T) { + keyAndOrKeyFile(t, true, true) + keyAndOrKeyFile(t, false, true) + keyAndOrKeyFile(t, true, false) + keyAndOrKeyFile(t, false, false) +} + +func keyAndOrKeyFile(t *testing.T, key, keyFile bool) { + store.Clear() + file := testFile("keyorkeyfile.kdbx") + os.Remove(file) + store.SetString("LOCKBOX_STORE", file) + if key { + store.SetArray("LOCKBOX_CREDENTIALS_PASSWORD", []string{"test"}) + store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "plaintext") + } else { + store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "none") + } + if keyFile { + key := testFile("keyfileor.key") + store.SetString("LOCKBOX_CREDENTIALS_KEY_FILE", key) + os.WriteFile(key, []byte("test"), 0o644) + } + tr, err := kdbx.NewTransaction() + if err != nil { + t.Errorf("failed: %v", err) + } + invalid := !key && !keyFile + err = tr.Insert(kdbx.NewPath("a", "b"), map[string]string{"password": "t"}) + if invalid { + if err == nil || err.Error() != "key and/or keyfile must be set" { + t.Errorf("invalid error: %v", err) + } + } else { + if err != nil { + t.Errorf("no error allowed: %v", err) + } + } +} + +func TestReKey(t *testing.T) { + store.Clear() + f := "rekey_test.kdbx" + file := testFile(f) + defer os.Remove(filepath.Join(testDir, f)) + store.SetString("LOCKBOX_STORE", file) + store.SetArray("LOCKBOX_CREDENTIALS_PASSWORD", []string{"test"}) + store.SetString("LOCKBOX_CREDENTIALS_PASSWORD_MODE", "plaintext") + store.SetString("LOCKBOX_TOTP_ENTRY", "totp") + tr, err := kdbx.NewTransaction() + if err != nil { + t.Errorf("failed: %v", err) + } + if err := tr.ReKey("", ""); err == nil || err.Error() != "key and/or keyfile must be set" { + t.Errorf("no error: %v", err) + } + if err := tr.ReKey("abc", ""); err != nil { + t.Errorf("no error: %v", err) + } +} diff --git a/internal/kdbx/core.go b/internal/kdbx/core.go @@ -0,0 +1,228 @@ +// Package backend handles kdbx interactions +package kdbx + +import ( + "errors" + "fmt" + "iter" + "os" + "strings" + + "git.sr.ht/~enckse/lockbox/internal/config" + "git.sr.ht/~enckse/lockbox/internal/platform" + "github.com/tobischo/gokeepasslib/v3" + "github.com/tobischo/gokeepasslib/v3/wrappers" +) + +var ( + errPath = errors.New("input paths must contain at LEAST 2 components") + // AllowedFields are the same of allowed names for storing in a kdbx entry + AllowedFields = []string{NotesField, OTPField, PasswordField} +) + +const ( + titleKey = "Title" + pathSep = "/" + isGlob = pathSep + "*" + modTimeKey = "ModTime" + // OTPField is the totp storage attribute + OTPField = "otp" + // NotesField is the multiline notes key + NotesField = "Notes" + // PasswordField is where the password is stored + PasswordField = "Password" +) + +type ( + // EntityValues are what is stored, from an entity, into kdbx backing store + EntityValues map[string]string + // QuerySeq2 wraps the iteration for query entities + QuerySeq2 iter.Seq2[Entity, error] + // Transaction handles the overall operation of the transaction + Transaction struct { + file string + valid bool + exists bool + write bool + readonly bool + } + // Context handles operating on the underlying database + Context struct { + db *gokeepasslib.Database + } + // Entity are database objects from results and transactional changes + Entity struct { + Values EntityValues + Path string + } +) + +// Load will load a kdbx file for transactions +func Load(file string) (*Transaction, error) { + return loadFile(file, true) +} + +func loadFile(file string, must bool) (*Transaction, error) { + if strings.TrimSpace(file) == "" { + return nil, errors.New("no store set") + } + if !strings.HasSuffix(file, ".kdbx") { + return nil, errors.New("should use a .kdbx extension") + } + exists := platform.PathExists(file) + if must { + if !exists { + return nil, errors.New("invalid file, does not exist") + } + } + ro := config.EnvReadOnly.Get() + return &Transaction{valid: true, file: file, exists: exists, readonly: ro}, nil +} + +// NewTransaction will use the underlying environment data store location +func NewTransaction() (*Transaction, error) { + return loadFile(config.EnvStore.Get(), false) +} + +func splitComponents(path string) ([]string, string, error) { + if len(strings.Split(path, pathSep)) < 2 { + return nil, "", errPath + } + if strings.HasPrefix(path, pathSep) { + return nil, "", errors.New("path can NOT be rooted") + } + if strings.HasSuffix(path, pathSep) { + return nil, "", errors.New("path can NOT end with separator") + } + if strings.Contains(path, pathSep+pathSep) { + return nil, "", errors.New("unwilling to operate on path with empty segment") + } + title := Base(path) + parts := strings.Split(Directory(path), pathSep) + return parts, title, nil +} + +func getCredentials(key, keyFile string) (*gokeepasslib.DBCredentials, error) { + hasKey := len(key) > 0 + hasKeyFile := len(keyFile) > 0 + if !hasKey && !hasKeyFile { + return nil, errors.New("key and/or keyfile must be set") + } + if hasKeyFile { + if !platform.PathExists(keyFile) { + return nil, errors.New("no keyfile found on disk") + } + if !hasKey { + return gokeepasslib.NewKeyCredentials(keyFile) + } + return gokeepasslib.NewPasswordAndKeyCredentials(key, keyFile) + } + return gokeepasslib.NewPasswordCredentials(key), nil +} + +func create(file, key, keyFile string) error { + root := gokeepasslib.NewGroup() + root.Name = "root" + db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4()) + creds, err := getCredentials(key, keyFile) + if err != nil { + return err + } + db.Credentials = creds + db.Content.Root = &gokeepasslib.RootData{ + Groups: []gokeepasslib.Group{root}, + } + if err := db.LockProtectedEntries(); err != nil { + return err + } + + f, err := os.Create(file) + if err != nil { + return err + } + defer f.Close() + return encode(f, db) +} + +func encode(f *os.File, db *gokeepasslib.Database) error { + return gokeepasslib.NewEncoder(f).Encode(db) +} + +func getPathName(entry gokeepasslib.Entry) string { + return entry.GetTitle() +} + +func value(key, value string) gokeepasslib.ValueData { + return gokeepasslib.ValueData{Key: key, Value: gokeepasslib.V{Content: value}} +} + +func protectedValue(key, value string) gokeepasslib.ValueData { + return gokeepasslib.ValueData{ + Key: key, + Value: gokeepasslib.V{Content: value, Protected: wrappers.NewBoolWrapper(true)}, + } +} + +// NewSuffix creates a new user 'name' suffix +func NewSuffix(name string) string { + return fmt.Sprintf("%s%s", pathSep, name) +} + +// NewPath creates a new storage location path. +func NewPath(segments ...string) string { + return strings.Join(segments, pathSep) +} + +// Value will read an entity value +func (e Entity) Value(key string) (string, bool) { + if e.Values == nil { + return "", false + } + val, ok := e.Values[key] + return val, ok +} + +// Base will get the base name of input path +func Base(s string) string { + parts := strings.Split(s, pathSep) + if len(parts) == 0 { + return s + } + return parts[len(parts)-1] +} + +// Directory will get the directory/group for the given path +func Directory(s string) string { + parts := strings.Split(s, pathSep) + return NewPath(parts[0 : len(parts)-1]...) +} + +func getValue(e gokeepasslib.Entry, key string) string { + v := e.Get(key) + if v == nil { + return "" + } + return v.Value.Content +} + +// IsDirectory will indicate if a path looks like a group/directory +func IsDirectory(path string) bool { + return strings.HasSuffix(path, pathSep) +} + +// IsLeafAttribute indicates if a path is leaved with a certain name +func IsLeafAttribute(path, attr string) bool { + return strings.HasSuffix(path, pathSep+attr) +} + +// Collect will create a slice from an iterable set of query sequence results +func (s QuerySeq2) Collect() ([]Entity, error) { + var entities []Entity + for entity, err := range s { + if err != nil { + return nil, err + } + entities = append(entities, entity) + } + return entities, nil +} diff --git a/internal/kdbx/core_test.go b/internal/kdbx/core_test.go @@ -0,0 +1,155 @@ +package kdbx_test + +import ( + "errors" + "testing" + + "git.sr.ht/~enckse/lockbox/internal/kdbx" +) + +func TestLoad(t *testing.T) { + if _, err := kdbx.Load(" "); err.Error() != "no store set" { + t.Errorf("invalid error: %v", err) + } + if _, err := kdbx.Load("garbage"); err.Error() != "should use a .kdbx extension" { + t.Errorf("invalid error: %v", err) + } + if _, err := kdbx.Load("garbage.kdbx"); err.Error() != "invalid file, does not exist" { + t.Errorf("invalid error: %v", err) + } +} + +func TestIsDirectory(t *testing.T) { + if kdbx.IsDirectory("") { + t.Error("invalid directory detection") + } + if !kdbx.IsDirectory("/") { + t.Error("invalid directory detection") + } + if kdbx.IsDirectory("/a") { + t.Error("invalid directory detection") + } +} + +func TestBase(t *testing.T) { + b := kdbx.Base("") + if b != "" { + t.Error("invalid base") + } + b = kdbx.Base("aaa") + if b != "aaa" { + t.Error("invalid base") + } + b = kdbx.Base("aaa/") + if b != "" { + t.Error("invalid base") + } + b = kdbx.Base("aaa/a") + if b != "a" { + t.Error("invalid base") + } +} + +func TestDirectory(t *testing.T) { + b := kdbx.Directory("") + if b != "" { + t.Error("invalid directory") + } + b = kdbx.Directory("/") + if b != "" { + t.Error("invalid directory") + } + b = kdbx.Directory("/a") + if b != "" { + t.Error("invalid directory") + } + b = kdbx.Directory("a") + if b != "" { + t.Error("invalid directory") + } + b = kdbx.Directory("b/a") + if b != "b" { + t.Error("invalid directory") + } +} + +func TestIsLeafAttr(t *testing.T) { + if kdbx.IsLeafAttribute("axyz", "z") { + t.Error("invalid result") + } + if !kdbx.IsLeafAttribute("axy/z", "z") { + t.Error("invalid result") + } +} + +func TestNewPath(t *testing.T) { + p := kdbx.NewPath("abc", "xyz") + if p != kdbx.NewPath("abc", "xyz") { + t.Error("invalid new path") + } +} + +func TestNewSuffix(t *testing.T) { + if kdbx.NewSuffix("test") != "/test" { + t.Error("invalid suffix") + } +} + +func generateTestSeq(hasError, extra bool) kdbx.QuerySeq2 { + return func(yield func(kdbx.Entity, error) bool) { + if !yield(kdbx.Entity{}, nil) { + return + } + if !yield(kdbx.Entity{}, nil) { + return + } + if hasError { + if !yield(kdbx.Entity{}, errors.New("test collect error")) { + return + } + } + if !yield(kdbx.Entity{}, nil) { + return + } + if extra { + if !yield(kdbx.Entity{}, nil) { + return + } + } + } +} + +func TestQuerySeq2Collect(t *testing.T) { + seq := generateTestSeq(true, true) + if _, err := seq.Collect(); err == nil || err.Error() != "test collect error" { + t.Errorf("invalid error: %v", err) + } + seq = generateTestSeq(false, false) + c, err := seq.Collect() + if err != nil || len(c) != 3 { + t.Errorf("invalid collect: %v %v %d", c, err, len(c)) + } + seq = generateTestSeq(false, true) + c, err = seq.Collect() + if err != nil || len(c) != 4 { + t.Errorf("invalid collect: %v %v %d", c, err, len(c)) + } +} + +func TestEntityValue(t *testing.T) { + e := kdbx.Entity{} + if _, ok := e.Value("key"); ok { + t.Error("values are nil") + } + e.Values = make(map[string]string) + if _, ok := e.Value("key"); ok { + t.Error("values are not set") + } + e.Values["key2"] = "1" + if _, ok := e.Value("key"); ok { + t.Error("values are not matching") + } + if val, ok := e.Value("key2"); !ok || val != "1" { + t.Error("values are not set") + } +} diff --git a/internal/kdbx/query.go b/internal/kdbx/query.go @@ -0,0 +1,241 @@ +// Package backend handles querying a store +package kdbx + +import ( + "crypto/sha512" + "errors" + "fmt" + "regexp" + "slices" + "strings" + + "git.sr.ht/~enckse/lockbox/internal/config" + "git.sr.ht/~enckse/lockbox/internal/output" + "github.com/tobischo/gokeepasslib/v3" +) + +type ( + // QueryOptions indicates how to find entities + QueryOptions struct { + PathFilter string + Criteria string + Mode QueryMode + Values ValueMode + } + // QueryMode indicates HOW an entity will be found + QueryMode int + // ValueMode indicates what to do with the store value of the entity + ValueMode int +) + +const ( + // BlankValue will not decrypt secrets, empty value + BlankValue ValueMode = iota + // SecretValue will have the raw secret onboard + SecretValue + // JSONValue will show entries as a JSON payload + JSONValue +) + +const ( + noneMode QueryMode = iota + // ListMode indicates ALL entities will be listed + ListMode + // FindMode indicates a _contains_ search for an entity + FindMode + // ExactMode means an entity must MATCH the string exactly + ExactMode + // PrefixMode allows for entities starting with a specific value + PrefixMode +) + +// MatchPath will try to match 1 or more elements (more elements when globbing) +func (t *Transaction) MatchPath(path string) ([]Entity, error) { + if !strings.HasSuffix(path, isGlob) { + e, err := t.Get(path, BlankValue) + if err != nil { + return nil, err + } + if e == nil { + return nil, nil + } + return []Entity{*e}, nil + } + prefix := strings.TrimSuffix(path, isGlob) + if strings.HasSuffix(prefix, pathSep) { + return nil, errors.New("invalid match criteria, too many path separators") + } + return t.queryCollect(QueryOptions{Mode: PrefixMode, Criteria: prefix + pathSep, Values: BlankValue}) +} + +// Get will request a singular entity +func (t *Transaction) Get(path string, mode ValueMode) (*Entity, error) { + _, _, err := splitComponents(path) + if err != nil { + return nil, err + } + e, err := t.queryCollect(QueryOptions{Mode: ExactMode, Criteria: path, Values: mode}) + if err != nil { + return nil, err + } + switch len(e) { + case 0: + return nil, nil + case 1: + return &e[0], nil + default: + return nil, errors.New("too many entities matched") + } +} + +func forEach(offset string, groups []gokeepasslib.Group, entries []gokeepasslib.Entry, cb func(string, gokeepasslib.Entry)) { + for _, g := range groups { + o := "" + if offset == "" { + o = g.Name + } else { + o = NewPath(offset, g.Name) + } + forEach(o, g.Groups, g.Entries, cb) + } + for _, e := range entries { + cb(offset, e) + } +} + +func (t *Transaction) queryCollect(args QueryOptions) ([]Entity, error) { + e, err := t.QueryCallback(args) + if err != nil { + return nil, err + } + return e.Collect() +} + +// QueryCallback will retrieve a query based on setting +func (t *Transaction) QueryCallback(args QueryOptions) (QuerySeq2, error) { + if args.Mode == noneMode { + return nil, errors.New("no query mode specified") + } + type entity struct { + path string + backing gokeepasslib.Entry + } + var entities []entity + isSort := args.Mode != ExactMode + decrypt := args.Values != BlankValue + hasPathFilter := args.PathFilter != "" + var pathFilter *regexp.Regexp + if hasPathFilter { + var err error + pathFilter, err = regexp.Compile(args.PathFilter) + if err != nil { + return nil, err + } + } + err := t.act(func(ctx Context) error { + forEach("", ctx.db.Content.Root.Groups[0].Groups, ctx.db.Content.Root.Groups[0].Entries, func(offset string, entry gokeepasslib.Entry) { + path := getPathName(entry) + if offset != "" { + path = NewPath(offset, path) + } + if isSort { + switch args.Mode { + case FindMode: + if !strings.Contains(path, args.Criteria) { + return + } + case PrefixMode: + if !strings.HasPrefix(path, args.Criteria) { + return + } + } + } else { + if args.Mode == ExactMode { + if path != args.Criteria { + return + } + } + } + if hasPathFilter { + if !pathFilter.MatchString(path) { + return + } + } + obj := entity{backing: entry, path: path} + if isSort && len(entities) > 0 { + i, _ := slices.BinarySearchFunc(entities, obj, func(i, j entity) int { + return strings.Compare(i.path, j.path) + }) + entities = slices.Insert(entities, i, obj) + } else { + entities = append(entities, obj) + } + }) + if decrypt { + return ctx.db.UnlockProtectedEntries() + } + return nil + }) + if err != nil { + return nil, err + } + jsonMode := output.JSONModes.Blank + if args.Values == JSONValue { + m, err := output.ParseJSONMode(config.EnvJSONMode.Get()) + if err != nil { + return nil, err + } + jsonMode = m + } + jsonHasher := func(string) string { + return "" + } + switch jsonMode { + case output.JSONModes.Raw: + jsonHasher = func(val string) string { + return val + } + case output.JSONModes.Hash: + hashLength, err := config.EnvJSONHashLength.Get() + if err != nil { + return nil, err + } + l := int(hashLength) + jsonHasher = func(val string) string { + data := fmt.Sprintf("%x", sha512.Sum512([]byte(val))) + if hashLength > 0 && len(data) > l { + data = data[0:hashLength] + } + return data + } + } + return func(yield func(Entity, error) bool) { + for _, item := range entities { + entity := Entity{Path: item.path} + var err error + values := make(EntityValues) + for _, v := range item.backing.Values { + val := "" + key := v.Key + if args.Values != BlankValue { + if args.Values == JSONValue { + values["modtime"] = getValue(item.backing, modTimeKey) + } + val = v.Value.Content + switch args.Values { + case JSONValue: + val = jsonHasher(val) + } + } + if key == modTimeKey || key == titleKey { + continue + } + values[strings.ToLower(key)] = val + } + entity.Values = values + if !yield(entity, err) { + return + } + } + }, nil +} diff --git a/internal/kdbx/query_test.go b/internal/kdbx/query_test.go @@ -0,0 +1,376 @@ +package kdbx_test + +import ( + "errors" + "fmt" + "strings" + "testing" + + "git.sr.ht/~enckse/lockbox/internal/kdbx" + "git.sr.ht/~enckse/lockbox/internal/config/store" +) + +func compareEntity(actual *kdbx.Entity, expect kdbx.Entity) bool { + if err := compareToEntity(actual, expect); err != nil { + return false + } + return true +} + +func compareToEntity(actual *kdbx.Entity, expect kdbx.Entity) error { + if actual == nil || actual.Values == nil { + return errors.New("invalid actual") + } + if actual.Path == "" || actual.Path != expect.Path { + return errors.New("invalid actual, no path") + } + for k, v := range actual.Values { + isMod := k == "modtime" + if isMod { + if len(v) < 20 { + return fmt.Errorf("%s invalid mod time", k) + } + } + e, ok := expect.Value(k) + if !ok { + if !isMod { + return fmt.Errorf("%s is missing from expected", k) + } + } + if e != v { + if isMod { + if e == "" { + continue + } + } + return fmt.Errorf("mismatch %s: (%s != %s)", k, e, v) + } + } + return nil +} + +func setupInserts(t *testing.T) { + setup(t) + fullSetup(t, true).Insert("test/test/abc", map[string]string{"password": "tedst", "notes": "xxx"}) + fullSetup(t, true).Insert("test/test/abcx", map[string]string{"password": "tedst"}) + fullSetup(t, true).Insert("test/test/ab11c", map[string]string{"password": "tedst", "notes": "tdest\ntest"}) + fullSetup(t, true).Insert("test/test/abc1ak", map[string]string{"password": "atest", "notes": "atest"}) +} + +func TestMatchPath(t *testing.T) { + store.Clear() + setupInserts(t) + q, err := fullSetup(t, true).MatchPath("test/test/abc") + if err != nil { + t.Errorf("no error: %v", err) + } + if len(q) != 1 { + t.Error("invalid entity result") + } + if q[0].Path != "test/test/abc" { + t.Error("invalid query result") + } + for _, k := range []string{"notes", "password"} { + if val, ok := q[0].Value(k); !ok || val != "" { + t.Errorf("invalid result value: %s", k) + } + } + q, err = fullSetup(t, true).MatchPath("test/test/abcxxx") + if err != nil { + t.Errorf("no error: %v", err) + } + if len(q) != 0 { + t.Error("invalid entity result") + } + q, err = fullSetup(t, true).MatchPath("test/test/*") + if err != nil { + t.Errorf("no error: %v", err) + } + if len(q) != 4 { + t.Error("invalid entity result") + } + if _, err := fullSetup(t, true).MatchPath("test/test//*"); err.Error() != "invalid match criteria, too many path separators" { + t.Errorf("wrong error: %v", err) + } + q, err = fullSetup(t, true).MatchPath("test/test*") + if err != nil { + t.Errorf("no error: %v", err) + } + if len(q) != 0 { + t.Error("invalid entity result") + } +} + +func TestGet(t *testing.T) { + setupInserts(t) + q, err := fullSetup(t, true).Get("test/test/abc", kdbx.BlankValue) + if err != nil { + t.Errorf("no error: %v", err) + } + if q.Path != "test/test/abc" { + t.Error("invalid query result") + } + for _, k := range []string{"notes", "password"} { + if val, ok := q.Value(k); !ok || val != "" { + t.Errorf("invalid result value: %s", k) + } + } + q, err = fullSetup(t, true).Get("a/b/aaaa", kdbx.BlankValue) + if err != nil || q != nil { + t.Error("invalid result, should be empty") + } + if _, err := fullSetup(t, true).Get("aaaa", kdbx.BlankValue); err.Error() != "input paths must contain at LEAST 2 components" { + t.Errorf("invalid error: %v", err) + } +} + +func TestValueModes(t *testing.T) { + store.Clear() + setupInserts(t) + q, err := fullSetup(t, true).Get("test/test/abc", kdbx.BlankValue) + if err != nil { + t.Errorf("no error: %v", err) + } + for _, k := range []string{"notes", "password"} { + if val, ok := q.Value(k); !ok || val != "" { + t.Errorf("invalid result value: %s", k) + } + } + q, err = fullSetup(t, true).Get("test/test/abc", kdbx.JSONValue) + if err != nil { + t.Errorf("no error: %v", err) + } + if !compareEntity(q, kdbx.Entity{ + Path: "test/test/abc", + Values: map[string]string{ + "notes": "9057ff1aa9509b2a0af624d687461d2bbeb07e2f37d953b1ce4a9dc921a7f19c45dc35d7c5363b373792add57d0d7dc41596e1c585d6ef7844cdf8ae87af443f", + "password": "44276ba24db13df5568aa6db81e0190ab9d35d2168dce43dca61e628f5c666b1d8b091f1dda59c2359c86e7d393d59723a421d58496d279031e7f858c11d893e", + }, + }) { + t.Errorf("invalid entity: %v", q) + } + store.SetInt64("LOCKBOX_JSON_HASH_LENGTH", 10) + q, err = fullSetup(t, true).Get("test/test/abc", kdbx.JSONValue) + if err != nil { + t.Errorf("no error: %v", err) + } + if !compareEntity(q, kdbx.Entity{ + Path: "test/test/abc", + Values: map[string]string{ + "notes": "9057ff1aa9", + "password": "44276ba24d", + }, + }) { + t.Errorf("invalid entity: %v", q) + } + q, err = fullSetup(t, true).Get("test/test/ab11c", kdbx.SecretValue) + if err != nil { + t.Errorf("no error: %v", err) + } + if !compareEntity(q, kdbx.Entity{ + Path: "test/test/ab11c", + Values: map[string]string{ + "notes": "tdest\ntest", + "password": "tedst", + }, + }) { + t.Errorf("invalid entity: %v", q) + } + store.SetString("LOCKBOX_JSON_MODE", "plAINtExt") + q, err = fullSetup(t, true).Get("test/test/abc", kdbx.JSONValue) + if err != nil { + t.Errorf("no error: %v", err) + } + if !compareEntity(q, kdbx.Entity{ + Path: "test/test/abc", + Values: map[string]string{ + "notes": "xxx", + "password": "tedst", + }, + }) { + t.Errorf("invalid entity: %v", q) + } + store.SetString("LOCKBOX_JSON_MODE", "emPTY") + q, err = fullSetup(t, true).Get("test/test/abc", kdbx.JSONValue) + if err != nil { + t.Errorf("no error: %v", err) + } + if !compareEntity(q, kdbx.Entity{ + Path: "test/test/abc", + Values: map[string]string{ + "notes": "", + "password": "", + }, + }) { + t.Errorf("invalid entity: %v", q) + } +} + +func testCollect(t *testing.T, count int, seq kdbx.QuerySeq2) []kdbx.Entity { + collected, err := seq.Collect() + if err != nil { + t.Errorf("invalid collect error: %v", err) + } + if len(collected) != count { + t.Errorf("unexpected entity count: %d", count) + } + return collected +} + +func TestQueryCallback(t *testing.T) { + setupInserts(t) + if _, err := fullSetup(t, true).QueryCallback(kdbx.QueryOptions{}); err.Error() != "no query mode specified" { + t.Errorf("wrong error: %v", err) + } + seq, err := fullSetup(t, true).QueryCallback(kdbx.QueryOptions{Mode: kdbx.ListMode}) + if err != nil { + t.Errorf("no error: %v", err) + } + res := testCollect(t, 4, seq) + if res[0].Path != "test/test/ab11c" || res[1].Path != "test/test/abc" || res[2].Path != "test/test/abc1ak" || res[3].Path != "test/test/abcx" { + t.Errorf("invalid results: %v", res) + } + seq, err = fullSetup(t, true).QueryCallback(kdbx.QueryOptions{Mode: kdbx.FindMode, Criteria: "1"}) + if err != nil { + t.Errorf("no error: %v", err) + } + res = testCollect(t, 2, seq) + if res[0].Path != "test/test/ab11c" || res[1].Path != "test/test/abc1ak" { + t.Errorf("invalid results: %v", res) + } + seq, err = fullSetup(t, true).QueryCallback(kdbx.QueryOptions{Mode: kdbx.ExactMode, Criteria: "test/test/abc"}) + if err != nil { + t.Errorf("no error: %v", err) + } + res = testCollect(t, 1, seq) + if res[0].Path != "test/test/abc" { + t.Errorf("invalid results: %v", res) + } + seq, err = fullSetup(t, true).QueryCallback(kdbx.QueryOptions{Mode: kdbx.ExactMode, Criteria: "test/test/abc", PathFilter: "abc"}) + if err != nil { + t.Errorf("no error: %v", err) + } + res = testCollect(t, 1, seq) + if res[0].Path != "test/test/abc" { + t.Errorf("invalid results: %v", res) + } + seq, err = fullSetup(t, true).QueryCallback(kdbx.QueryOptions{Mode: kdbx.ExactMode, Criteria: "test/test/abc", PathFilter: "abz"}) + if err != nil { + t.Errorf("no error: %v", err) + } + testCollect(t, 0, seq) + seq, err = fullSetup(t, true).QueryCallback(kdbx.QueryOptions{Mode: kdbx.ExactMode, Criteria: "abczzz"}) + if err != nil { + t.Errorf("no error: %v", err) + } + testCollect(t, 0, seq) +} + +func TestSetModTime(t *testing.T) { + store.Clear() + testDateTime := "2022-12-30T12:34:56-05:00" + tr := fullSetup(t, false) + store.SetString("LOCKBOX_DEFAULTS_MODTIME", testDateTime) + tr.Insert("test/xyz", map[string]string{"password": "test"}) + q, err := fullSetup(t, true).Get("test/xyz", kdbx.JSONValue) + if err != nil { + t.Errorf("no error: %v", err) + } + if !compareEntity(q, kdbx.Entity{ + Path: "test/xyz", + Values: map[string]string{ + "password": "ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff", + "modtime": testDateTime, + }, + }) { + t.Errorf("invalid entity: %v", q) + } + + store.Clear() + tr = fullSetup(t, false) + tr.Insert("test/xyz", map[string]string{"password": "test"}) + q, err = fullSetup(t, true).Get("test/xyz", kdbx.JSONValue) + if err != nil { + t.Errorf("no error: %v", err) + } + + if val, ok := q.Value("modtime"); !ok || len(val) < 20 || val == testDateTime { + t.Errorf("invalid mod: %s", val) + } + + tr = fullSetup(t, false) + store.SetString("LOCKBOX_DEFAULTS_MODTIME", "garbage") + err = tr.Insert("test/xyz", map[string]string{"password": "test"}) + if err == nil || !strings.Contains(err.Error(), "parsing time") { + t.Errorf("invalid error: %v", err) + } +} + +func TestAttributeModes(t *testing.T) { + store.Clear() + setupInserts(t) + fullSetup(t, true).Insert("test/test/totp", map[string]string{"otp": "atest"}) + q, err := fullSetup(t, true).Get("test/test/totp", kdbx.BlankValue) + if err != nil { + t.Errorf("no error: %v", err) + } + if !compareEntity(q, kdbx.Entity{ + Path: "test/test/totp", + Values: map[string]string{ + "otp": "", + }, + }) { + t.Errorf("invalid entity: %v", q) + } + q, err = fullSetup(t, true).Get("test/test/totp", kdbx.JSONValue) + if err != nil { + t.Errorf("no error: %v", err) + } + if !compareEntity(q, kdbx.Entity{ + Path: "test/test/totp", + Values: map[string]string{ + "otp": "7f8fd0e1a714f63da75206748d0ea1dd601fc8f92498bc87c9579b403c3004a0eefdd7ead976f7dbd6e5143c9aa7a569e24322d870ec7745a4605a154557458e", + }, + }) { + t.Errorf("invalid entity: %v", q) + } + store.SetInt64("LOCKBOX_JSON_HASH_LENGTH", 10) + q, err = fullSetup(t, true).Get("test/test/totp", kdbx.JSONValue) + if err != nil { + t.Errorf("no error: %v", err) + } + if !compareEntity(q, kdbx.Entity{ + Path: "test/test/totp", + Values: map[string]string{ + "otp": "7f8fd0e1a7", + }, + }) { + t.Errorf("invalid entity: %v", q) + } + store.SetString("LOCKBOX_JSON_MODE", "PlAINtExt") + q, err = fullSetup(t, true).Get("test/test/totp", kdbx.JSONValue) + if err != nil { + t.Errorf("no error: %v", err) + } + if !compareEntity(q, kdbx.Entity{ + Path: "test/test/totp", + Values: map[string]string{ + "otp": "otpauth://totp/lbissuer:lbaccount?algorithm=SHA1&digits=6&issuer=lbissuer&period=30&secret=atest", + }, + }) { + t.Errorf("invalid entity: %v", q) + } + store.SetString("LOCKBOX_JSON_MODE", "emPty") + q, err = fullSetup(t, true).Get("test/test/totp", kdbx.JSONValue) + if err != nil { + t.Errorf("no error: %v", err) + } + if !compareEntity(q, kdbx.Entity{ + Path: "test/test/totp", + Values: map[string]string{ + "otp": "", + }, + }) { + t.Errorf("invalid entity: %v", q) + } +}