lockbox

password manager
Log | Files | Refs | README | LICENSE

commit c8e223d3b535b11622a4580a814de885c2e04909
parent 356828e0b6ebc18edad4e933c71b61d577b3f83c
Author: Sean Enck <sean@ttypty.com>
Date:   Sun,  2 Oct 2022 09:43:43 -0400

support tree structure

Diffstat:
Mcmd/main.go | 2+-
Minternal/backend/actions.go | 149++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Minternal/backend/actions_test.go | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Minternal/backend/query.go | 30+++++++++++++++++++++++++-----
Minternal/backend/query_test.go | 28++++++++++++++--------------
Mtests/expected.log | 22+++++++++++-----------
Mtests/run.sh | 24++++++++++++------------
7 files changed, 227 insertions(+), 108 deletions(-)

diff --git a/cmd/main.go b/cmd/main.go @@ -132,7 +132,7 @@ func run() *programError { return newError("invalid input", err) } p := strings.TrimSpace(string(password)) - if err := t.Insert(entry, p, existing, len(strings.Split(p, "\n")) > 1); err != nil { + if err := t.Insert(entry, p, len(strings.Split(p, "\n")) > 1); err != nil { return newError("failed to insert", err) } fmt.Println("") diff --git a/internal/backend/actions.go b/internal/backend/actions.go @@ -37,7 +37,7 @@ func (t *Transaction) act(cb action) error { return err } if len(db.Content.Root.Groups) != 1 { - return errors.New("kdbx must only have ONE root group") + return errors.New("kdbx must have ONE root group") } err = cb(Context{db: db}) if err != nil { @@ -70,70 +70,139 @@ func (t *Transaction) change(cb action) error { }) } +func (c Context) insertEntity(offset []string, title, name string, entity gokeepasslib.Entry) bool { + return c.alterEntities(true, offset, title, name, &entity) +} + +func (c Context) alterEntities(isAdd bool, offset []string, title, name string, entity *gokeepasslib.Entry) bool { + g, e, ok := findAndDo(isAdd, NewPath(title, name), 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, name string) bool { + return c.alterEntities(false, offset, title, name, 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 { + if len(group.Entries) == 0 { + continue + } + } + groups = append(groups, group) + } + g = groups + } + } + return g, e, done +} + +func splitComponents(path string) ([]string, string, string, error) { + name := filepath.Base(path) + dir := filepath.Dir(path) + parts := strings.Split(dir, string(os.PathSeparator)) + if len(parts) < 2 { + return nil, "", "", errors.New("invalid component path") + } + return parts[:len(parts)-1], parts[len(parts)-1], name, nil +} + // Insert handles inserting a new element -func (t *Transaction) Insert(path, val string, entity *QueryEntity, multi bool) error { +func (t *Transaction) Insert(path, val string, multi bool) error { if strings.TrimSpace(path) == "" { return errors.New("empty path not allowed") } if strings.TrimSpace(val) == "" { return errors.New("empty secret not allowed") } + offset, title, name, err := splitComponents(path) + if err != nil { + return err + } return t.change(func(c Context) error { - if entity != nil { - if _, err := remove(entity, c, false); err != nil { - return err - } - } else { - idx, _ := remove(&QueryEntity{Path: path}, c, true) - if idx >= 0 { - return errors.New("trying to insert over existing entity") - } - } + c.removeEntity(offset, title, name) e := gokeepasslib.NewEntry() - e.Values = append(e.Values, value(titleKey, filepath.Dir(path))) - e.Values = append(e.Values, value(userNameKey, filepath.Base(path))) + e.Values = append(e.Values, value(titleKey, title)) + e.Values = append(e.Values, value(userNameKey, name)) field := passKey if multi { field = notesKey } e.Values = append(e.Values, protectedValue(field, val)) - c.db.Content.Root.Groups[0].Entries = append(c.db.Content.Root.Groups[0].Entries, e) + c.insertEntity(offset, title, name, e) return nil }) } -func remove(entity *QueryEntity, c Context, dryRun bool) (int, error) { - entries := c.db.Content.Root.Groups[0].Entries - idx := -1 - for i, e := range entries { - if entity.Path == getPathName(e) { - idx = i - } - } - if idx < 0 { - return idx, errors.New("unable to select entity for deletion") - } - if dryRun { - return idx, nil - } - switch len(entries) { - case 1: - c.db.Content.Root.Groups[0].Entries = []gokeepasslib.Entry{} - default: - c.db.Content.Root.Groups[0].Entries = append(entries[:idx], entries[idx+1:]...) - } - return idx, nil -} - // Remove handles remove an element func (t *Transaction) Remove(entity *QueryEntity) error { if entity == nil { return errors.New("entity is empty/invalid") } - return t.change(func(c Context) error { - _, err := remove(entity, c, false) + offset, title, name, err := splitComponents(entity.Path) + if err != nil { return err + } + return t.change(func(c Context) error { + if ok := c.removeEntity(offset, title, name); !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,7 +1,9 @@ package backend_test import ( + "fmt" "os" + "path/filepath" "testing" "github.com/enckse/lockbox/internal/backend" @@ -27,38 +29,41 @@ func setup(t *testing.T) *backend.Transaction { func TestBadAction(t *testing.T) { tr := &backend.Transaction{} - if err := tr.Insert("a", "a", nil, false); err.Error() != "invalid transaction" { + if err := tr.Insert("a/a/a", "a", false); err.Error() != "invalid transaction" { t.Errorf("wrong error: %v", err) } } func TestInserts(t *testing.T) { - if err := setup(t).Insert("", "", nil, false); err.Error() != "empty path not allowed" { + if err := setup(t).Insert("", "", false); err.Error() != "empty path not allowed" { t.Errorf("wrong error: %v", err) } - if err := setup(t).Insert("a", "", nil, false); err.Error() != "empty secret not allowed" { + if err := setup(t).Insert(filepath.Join("test", "offset"), "test", false); err.Error() != "invalid component path" { t.Errorf("wrong error: %v", err) } - if err := setup(t).Insert("value", "pass", nil, false); err != nil { - t.Errorf("no error: %v", err) + if err := setup(t).Insert("test", "test", false); err.Error() != "invalid component path" { + t.Errorf("wrong error: %v", err) } - if err := fullSetup(t, true).Insert("value", "pass", nil, false); err.Error() != "trying to insert over existing entity" { + if err := setup(t).Insert("a", "", false); err.Error() != "empty secret not allowed" { t.Errorf("wrong error: %v", err) } - if err := fullSetup(t, true).Insert("value", "pass2", &backend.QueryEntity{Path: "value"}, false); err != nil { + if err := setup(t).Insert(filepath.Join("test", "offset", "value"), "pass", false); err != nil { t.Errorf("no error: %v", err) } - if err := fullSetup(t, true).Insert("value2", "pass", nil, true); err != nil { + if err := fullSetup(t, true).Insert(filepath.Join("test", "offset", "value"), "pass2", false); err != nil { + t.Errorf("wrong error: %v", err) + } + if err := fullSetup(t, true).Insert(filepath.Join("test", "offset", "value2"), "pass", true); err != nil { t.Errorf("no error: %v", err) } - q, err := fullSetup(t, true).Get("value", backend.SecretValue) + q, err := fullSetup(t, true).Get(filepath.Join("test", "offset", "value"), backend.SecretValue) if err != nil { t.Errorf("no error: %v", err) } if q.Value != "pass2" { t.Errorf("invalid retrieval") } - q, err = fullSetup(t, true).Get("value2", backend.SecretValue) + q, err = fullSetup(t, true).Get(filepath.Join("test", "offset", "value2"), backend.SecretValue) if err != nil { t.Errorf("no error: %v", err) } @@ -71,43 +76,68 @@ 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.QueryEntity{}); err.Error() != "unable to select entity for deletion" { + if err := setup(t).Remove(&backend.QueryEntity{}); err.Error() != "invalid component path" { + t.Errorf("wrong error: %v", err) + } + if err := setup(t).Remove(&backend.QueryEntity{Path: filepath.Join("test1", "test2", "test3")}); err.Error() != "failed to remove entity" { t.Errorf("wrong error: %v", err) } setup(t) - for _, i := range []string{"test1", "test2", "test3", "test4", "test5"} { - fullSetup(t, true).Insert(i, "pass", nil, false) + for _, i := range []string{"test1", "test2"} { + fullSetup(t, true).Insert(filepath.Join(i, i, i), "pass", false) } - if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: "test3"}); err != nil { + if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: filepath.Join("test1", "test1", "test1")}); err != nil { t.Errorf("wrong error: %v", err) } - check(t, []string{"test1", "test2", "test4", "test5"}) - if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: "test1"}); err != nil { + if err := check(t, filepath.Join("test2", "test2", "test2")); err != nil { + t.Errorf("invalid check: %v", err) + } + if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: filepath.Join("test2", "test2", "test2")}); err != nil { t.Errorf("wrong error: %v", err) } - check(t, []string{"test2", "test4", "test5"}) - if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: "test5"}); err != nil { + setup(t) + for _, i := range []string{filepath.Join("test", "test", "test1"), filepath.Join("test", "test", "test2"), filepath.Join("test", "test", "test3"), filepath.Join("test", "test1", "test2"), filepath.Join("test", "test1", "test5")} { + fullSetup(t, true).Insert(i, "pass", false) + } + if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: "test/test/test3"}); err != nil { t.Errorf("wrong error: %v", err) } - check(t, []string{"test2", "test4"}) - if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: "test4"}); err != nil { + if err := check(t, filepath.Join("test", "test", "test2"), filepath.Join("test", "test", "test1"), filepath.Join("test", "test1", "test2"), filepath.Join("test", "test1", "test5")); err != nil { + t.Errorf("invalid check: %v", err) + } + if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: "test/test/test1"}); err != nil { + t.Errorf("wrong error: %v", err) + } + if err := check(t, filepath.Join("test", "test", "test2"), filepath.Join("test", "test1", "test2"), filepath.Join("test", "test1", "test5")); err != nil { + t.Errorf("invalid check: %v", err) + } + if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: "test/test1/test5"}); err != nil { t.Errorf("wrong error: %v", err) } - check(t, []string{"test2"}) - if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: "test2"}); err != nil { + if err := check(t, filepath.Join("test", "test", "test2"), filepath.Join("test", "test1", "test2")); err != nil { + t.Errorf("invalid check: %v", err) + } + if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: "test/test1/test2"}); err != nil { + t.Errorf("wrong error: %v", err) + } + if err := check(t, filepath.Join("test", "test", "test2")); err != nil { + t.Errorf("invalid check: %v", err) + } + if err := fullSetup(t, true).Remove(&backend.QueryEntity{Path: "test/test/test2"}); err != nil { t.Errorf("wrong error: %v", err) } } -func check(t *testing.T, checks []string) { +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 { - t.Errorf("unexpected error: %v", err) + return err } if q == nil { - t.Errorf("failed to find entity: %s", c) + return fmt.Errorf("failed to find entity: %s", c) } } + return nil } diff --git a/internal/backend/query.go b/internal/backend/query.go @@ -9,6 +9,8 @@ import ( "path/filepath" "sort" "strings" + + "github.com/tobischo/gokeepasslib/v3" ) // Get will request a singular entity @@ -27,6 +29,21 @@ func (t *Transaction) Get(path string, mode ValueMode) (*QueryEntity, error) { } } +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 = filepath.Join(offset, g.Name) + } + forEach(o, g.Groups, g.Entries, cb) + } + for _, e := range entries { + cb(offset, e) + } +} + // QueryCallback will retrieve a query based on setting func (t *Transaction) QueryCallback(args QueryOptions) ([]QueryEntity, error) { if args.Mode == noneMode { @@ -37,30 +54,33 @@ func (t *Transaction) QueryCallback(args QueryOptions) ([]QueryEntity, error) { isSort := args.Mode != ExactMode decrypt := args.Values != BlankValue err := t.act(func(ctx Context) error { - for _, entry := range ctx.db.Content.Root.Groups[0].Entries { + 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 = filepath.Join(offset, path) + } if isSort { switch args.Mode { case FindMode: if !strings.Contains(path, args.Criteria) { - continue + return } case SuffixMode: if !strings.HasSuffix(path, args.Criteria) { - continue + return } } } else { if args.Mode == ExactMode { if path != args.Criteria { - continue + return } } } keys = append(keys, path) entities[path] = QueryEntity{backing: entry} - } + }) if decrypt { return ctx.db.UnlockProtectedEntries() } diff --git a/internal/backend/query_test.go b/internal/backend/query_test.go @@ -9,19 +9,19 @@ import ( func setupInserts(t *testing.T) { setup(t) - fullSetup(t, true).Insert("abc", "tedst", nil, false) - fullSetup(t, true).Insert("abcx", "tedst", nil, false) - fullSetup(t, true).Insert("ab11c", "tdest", nil, true) - fullSetup(t, true).Insert("abc1ak", "atest", nil, false) + fullSetup(t, true).Insert("test/test/abc", "tedst", false) + fullSetup(t, true).Insert("test/test/abcx", "tedst", false) + fullSetup(t, true).Insert("test/test/ab11c", "tdest", true) + fullSetup(t, true).Insert("test/test/abc1ak", "atest", false) } func TestGet(t *testing.T) { setupInserts(t) - q, err := fullSetup(t, true).Get("abc", backend.BlankValue) + q, err := fullSetup(t, true).Get("test/test/abc", backend.BlankValue) if err != nil { t.Errorf("no error: %v", err) } - if q.Path != "abc" || q.Value != "" { + if q.Path != "test/test/abc" || q.Value != "" { t.Error("invalid query result") } q, err = fullSetup(t, true).Get("aaaa", backend.BlankValue) @@ -32,21 +32,21 @@ func TestGet(t *testing.T) { func TestValueModes(t *testing.T) { setupInserts(t) - q, err := fullSetup(t, true).Get("abc", backend.BlankValue) + q, err := fullSetup(t, true).Get("test/test/abc", backend.BlankValue) if err != nil { t.Errorf("no error: %v", err) } if q.Value != "" { t.Errorf("invalid result value: %s", q.Value) } - q, err = fullSetup(t, true).Get("abc", backend.HashedValue) + q, err = fullSetup(t, true).Get("test/test/abc", backend.HashedValue) if err != nil { t.Errorf("no error: %v", err) } if q.Value != "44276ba24db13df5568aa6db81e0190ab9d35d2168dce43dca61e628f5c666b1d8b091f1dda59c2359c86e7d393d59723a421d58496d279031e7f858c11d893e" { t.Errorf("invalid result value: %s", q.Value) } - q, err = fullSetup(t, true).Get("ab11c", backend.SecretValue) + q, err = fullSetup(t, true).Get("test/test/ab11c", backend.SecretValue) if err != nil { t.Errorf("no error: %v", err) } @@ -67,7 +67,7 @@ func TestQueryCallback(t *testing.T) { if len(res) != 4 { t.Error("invalid results: not enough") } - if res[0].Path != "ab11c" || res[1].Path != "abc" || res[2].Path != "abc1ak" || res[3].Path != "abcx" { + 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) } res, err = fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.FindMode, Criteria: "1"}) @@ -77,7 +77,7 @@ func TestQueryCallback(t *testing.T) { if len(res) != 2 { t.Error("invalid results: not enough") } - if res[0].Path != "ab11c" || res[1].Path != "abc1ak" { + if res[0].Path != "test/test/ab11c" || res[1].Path != "test/test/abc1ak" { t.Errorf("invalid results: %v", res) } res, err = fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.SuffixMode, Criteria: "c"}) @@ -87,17 +87,17 @@ func TestQueryCallback(t *testing.T) { if len(res) != 2 { t.Error("invalid results: not enough") } - if res[0].Path != "ab11c" || res[1].Path != "abc" { + if res[0].Path != "test/test/ab11c" || res[1].Path != "test/test/abc" { t.Errorf("invalid results: %v", res) } - res, err = fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.ExactMode, Criteria: "abc"}) + res, err = fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.ExactMode, Criteria: "test/test/abc"}) if err != nil { t.Errorf("no error: %v", err) } if len(res) != 1 { t.Error("invalid results: not enough") } - if res[0].Path != "abc" { + if res[0].Path != "test/test/abc" { t.Errorf("invalid results: %v", res) } res, err = fullSetup(t, true).QueryCallback(backend.QueryOptions{Mode: backend.ExactMode, Criteria: "abczzz"}) diff --git a/tests/expected.log b/tests/expected.log @@ -2,25 +2,25 @@ -keys/one -keys/one2 -keys2/three +keys/k/one +keys/k/one2 +keys2/k/three delete entry? (y/N) -keys/one2 -keys2/three -keys/one2 -keys2/three +keys/k/one2 +keys2/k/three +keys/k/one2 +keys2/k/three test2 test3 test4 -test +test/k XXXXXX -keys/one2: +keys/k/one2: hash:6d201beeefb589b08ef0672dac82353d0cbd9ad99e1642c83a1601f3d647bcca003257b5e8f31bdc1d73fbec84fb085c79d6e2677b7ff927e823a54e789140d9 -keys2/three: +keys2/k/three: hash:132ab0244293c495a027cec12d0050598616daca888449920fc652719be0987830827d069ef78cc613e348de37c9b592d3406e2fb8d99a6961bf0c58da8a334f -test/totp: +test/k/totp: hash:7ef183065ba70aaa417b87ea0a96b7e550a938a52440c640a07537f7794d8a89e50078eca6a7cbcfacabd97a2db06d11e82ddf7556ca909c4df9fc0d006013b1 delete entry? (y/N) delete entry? (y/N) delete entry? (y/N) unable to remove entry (entity is empty/invalid) diff --git a/tests/run.sh b/tests/run.sh @@ -12,25 +12,25 @@ rm -rf $TESTS mkdir -p $TESTS _run() { - echo "test" | "$BIN/lb" insert keys/one - echo "test2" | "$BIN/lb" insert keys/one2 - echo "test" | "$BIN/lb" insert keys/one - echo -e "test3\ntest4" | "$BIN/lb" insert keys2/three + echo "test" | "$BIN/lb" insert keys/k/one + echo "test2" | "$BIN/lb" insert keys/k/one2 + echo "test" | "$BIN/lb" insert keys/k/one + echo -e "test3\ntest4" | "$BIN/lb" insert keys2/k/three "$BIN/lb" ls - yes 2>/dev/null | "$BIN/lb" rm keys/one + yes 2>/dev/null | "$BIN/lb" rm keys/k/one echo "$BIN/lb" ls "$BIN/lb" find e - "$BIN/lb" show keys/one2 - "$BIN/lb" show keys2/three - echo "5ae472abqdekjqykoyxk7hvc2leklq5n" | "$BIN/lb" insert test/totp + "$BIN/lb" show keys/k/one2 + "$BIN/lb" show keys2/k/three + echo "5ae472abqdekjqykoyxk7hvc2leklq5n" | "$BIN/lb" insert test/k/totp "$BIN/lb" "totp" -list - "$BIN/lb" "totp" test | tr '[:digit:]' 'X' + "$BIN/lb" "totp" test/k | tr '[:digit:]' 'X' "$BIN/lb" "hash" $LOCKBOX_STORE - yes 2>/dev/null | "$BIN/lb" rm keys2/three + yes 2>/dev/null | "$BIN/lb" rm keys2/k/three echo - yes 2>/dev/null | "$BIN/lb" rm test/totp - yes 2>/dev/null | "$BIN/lb" rm test/one + yes 2>/dev/null | "$BIN/lb" rm test/k/totp + yes 2>/dev/null | "$BIN/lb" rm test/k/one } _run 2>&1 | sed "s#$LOCKBOX_STORE##g" > $TESTS/actual.log