lockbox

password manager
Log | Files | Refs | README | LICENSE

commit 9a936c6507ce7cb8fdfa3f226c800a5ba77d24d1
parent adbed3e99d088861771bdda8489146b5a4dbe852
Author: Sean Enck <sean@ttypty.com>
Date:   Sat,  7 Jun 2025 13:14:57 -0400

lb becomes a better user of multiple fields within a kdbx entry

Diffstat:
Mcmd/lb/main.go | 20+++++++-------------
Mcmd/lb/main_test.go | 108+++++++++++++++++++++++++++++--------------------------------------------------
Mcmd/lb/tests/expected.log | 351+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Minternal/app/commands/core.go | 8++++----
Minternal/app/completions/core.go | 12+++++++-----
Minternal/app/completions/shell/bash.sh | 9+++++++--
Minternal/app/completions/shell/zsh.sh | 11+++++++++--
Minternal/app/conv.go | 2+-
Minternal/app/help/core.go | 4++--
Minternal/app/insert.go | 36+++++++++++++++---------------------
Minternal/app/insert_test.go | 30+++++++++++++++---------------
Minternal/app/list.go | 45+++++++++++++++++++++++++++++++++++++++------
Minternal/app/list_test.go | 32+++++++++++++++++++++++++-------
Minternal/app/move_test.go | 12++++++------
Minternal/app/remove.go | 11++++++++---
Minternal/app/showclip.go | 26++++++++++++++++++++------
Minternal/app/showclip_test.go | 2+-
Minternal/app/totp.go | 40+++++++---------------------------------
Minternal/app/totp_test.go | 66+++++++++++++++++++++++++++++++-----------------------------------
Ainternal/app/unset.go | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/unset_test.go | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/backend/actions.go | 52+++++++++++++++++++++++++++++++---------------------
Minternal/backend/actions_test.go | 79+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Minternal/backend/core.go | 43+++++++++++++++++++++++++------------------
Minternal/backend/core_test.go | 38+++++++++++++++++++++++---------------
Minternal/backend/query.go | 57++++++++++++++++-----------------------------------------
Minternal/backend/query_test.go | 281++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Minternal/config/toml_test.go | 2+-
Minternal/config/vars.go | 12------------
Minternal/config/vars_test.go | 12------------
30 files changed, 858 insertions(+), 650 deletions(-)

diff --git a/cmd/lb/main.go b/cmd/lb/main.go @@ -82,16 +82,14 @@ func run() error { switch command { case commands.ReKey: return app.ReKey(p) - case commands.List, commands.Find: - return app.List(p, command == commands.Find) + case commands.List, commands.Find, commands.Groups: + return app.List(p, command == commands.Find, command == commands.Groups) + case commands.Unset: + return app.Unset(p) case commands.Move: return app.Move(p) - case commands.Insert, commands.MultiLine: - mode := app.SingleLineInsert - if command == commands.MultiLine { - mode = app.MultiLineInsert - } - return app.Insert(p, mode) + case commands.Insert: + return app.Insert(p) case commands.Remove: return app.Remove(p) case commands.JSON: @@ -101,14 +99,10 @@ func run() error { case commands.Conv: return app.Conv(p) case commands.TOTP: - args, err := app.NewTOTPArguments(sub, config.EnvTOTPEntry.Get()) + args, err := app.NewTOTPArguments(sub) if err != nil { return err } - if args.Mode == app.InsertTOTPMode { - p.SetArgs(args.Entry) - return app.Insert(p, app.TOTPInsert) - } return args.Do(app.NewDefaultTOTPOptions(p)) case commands.PasswordGenerate: return app.GeneratePassword(p) diff --git a/cmd/lb/main_test.go b/cmd/lb/main_test.go @@ -185,7 +185,7 @@ func test(profile string) error { } } r.writeConfig(c) - r.run("echo test2 |", "insert keys/k/one2") + r.run("echo testing |", "insert test1/key1/password") if hasPass { delete(c, "credentials.password") c["interactive"] = "true" @@ -208,76 +208,54 @@ func test(profile string) error { } } r.writeConfig(c) - for _, k := range []string{"keys/k/one", "key/a/one", "keys/k/one", "keys/k/one/", "/keys/k/one", "keys/aa/b//s////e"} { - r.run("echo test |", fmt.Sprintf("insert %s", k)) + for _, k := range []string{"test2/key1/password", "test2/key1/notes", "test3", "test3/invalid/", "test3/invalid/still"} { + r.run("echo testing2 |", fmt.Sprintf("insert %s", k)) } - for _, k := range []string{"insert keys2/k/three", "multiline keys2/k/three"} { - r.run(`printf "test3\ntest4\n" |`, k) + for _, k := range []string{"insert test4/multiline/notes", "insert test5/multiline/notes", "insert test5/multiline/otp", "insert test5/multiline/password"} { + r.run(`printf "testing3\ntesting4\n" |`, k) + } + for _, k := range []string{"insert test6/multiline/password", "insert test6/multiline/notes", "insert test7/deeper/rooted/notes", "insert test8/unset/password", "insert test8/unset/notes", "insert test9/key1/sub1/password", "insert test9/key1/sub2/password", "insert test9/key2/sub1/password"} { + r.run(`printf "testing5" |`, k) + r.run("", fmt.Sprintf("show %s", strings.ReplaceAll(k, "insert ", ""))) } r.run("", "ls") - r.run("echo y |", "rm keys/k/one") + r.run("", "groups") + r.run("echo y |", "rm test2/key1") r.logAppend("echo") r.run("", "ls") - r.run("", "ls | grep e") r.run("", "json") + r.run("", "json 'multiline'") r.logAppend("echo") - r.run("", "show keys/k/one2") - r.run("", "show keys2/k/three") - r.run("", "json keys2/k/three") - r.logAppend("echo") - r.run("echo 5ae472abqdekjqykoyxk7hvc2leklq5n |", "totp insert test/k") - r.run("echo 5ae472abqdekjqykoyxk7hvc2leklq5n |", "totp insert test/k/totp") + r.run("echo 5ae472abqdekjqykoyxk7hvc2leklq5n |", "insert test6/multiline/otp") r.run("", "totp ls") - r.run("", "totp find test") - r.run("", "totp show test/k") - r.run("", "totp once test/k") - r.run("", "totp minimal test/k") + r.run("", "totp find multiline") + r.run("", "totp show test6/multiline/otp") + r.run("", "totp once test6/multiline/otp") + r.run("", "totp minimal test6/multiline/otp") r.run("", fmt.Sprintf("conv \"%s\"", r.store)) - r.run("echo y |", "rm keys2/k/three") - r.logAppend("echo") - r.run("echo y |", "rm test/k/totp") - r.logAppend("echo") - r.run("echo y |", "rm test/k/one") + r.run("echo y |", "rm test7/deeper") + r.run("echo y |", "rm test7/deeper/ro") + r.run("echo y |", "rm test1/key1/password") + r.run("echo y |", "rm test1/key1") r.logAppend("echo") + r.run("echo y |", "rm test7/deeper/*") r.logAppend("echo") - r.run("echo test2 |", "insert move/m/ka/abc") - r.run("echo test |", "insert move/m/ka/xyz") - r.run("echo test2 |", "insert move/ma/ka/yyy") - r.run("echo test |", "insert move/ma/ka/zzz") - r.run("echo test |", "insert move/ma/ka2/zzz") - r.run("echo test |", "insert move/ma/ka3/yyy") - r.run("echo test |", "insert move/ma/ka3/zzz") - r.run("", "mv move/m/* move/mac/") - r.run("", "mv move/ma/ka/* move/mac/") - r.run("", "mv move/ma/ka2/* move/mac/") - r.run("", "mv move/ma/ka3/* move/mac/") - r.run("", "mv key/a/one keyx/d/e") r.run("", "ls") - r.run("", "find keyx") - r.run("echo y |", "rm move/*") - r.run("echo y |", "rm keyx/d/e") + r.run("", "groups") + r.run("echo y |", "unset test8/unset/password") r.logAppend("echo") r.run("", "ls") - r.run("echo test2 |", "insert keys/k2/one2") - r.run("echo test |", "insert keys/k2/one") - r.run("echo test2 |", "insert keys/k2/t1/one2") - r.run("echo test |", "insert keys/k2/t1/one") - r.run("echo test2 |", "insert keys/k2/t2/one2") - - // test hooks - c["hooks.directory"] = c.quoteString(filepath.Join(r.testDir, hookDir)) - r.writeConfig(c) - r.run("echo test |", "insert keys/k2/t2/one") + r.run("", "groups") + r.run("echo y |", "unset test8/unset/notes") r.logAppend("echo") r.run("", "ls") - r.run("echo y |", "rm keys/k2/t1/*") - r.logAppend("echo") + r.run("", "groups") + r.run("", "mv test9/key1/* test9/") + r.run("", "mv test9/key2/sub1 test9/sub3") r.run("", "ls") - r.run("echo y |", "rm keys/k2/*") + r.run("", "groups") + r.run("echo y |", "rm test9/*") r.logAppend("echo") - r.run("", "ls") - r.logAppend("echo") - delete(c, "hooks.directory") // test rekeying reKeyArgs := []string{} @@ -299,17 +277,19 @@ func test(profile string) error { r.writeConfig(c) r.logAppend("echo") r.run("", "ls") - r.run("", "show keys/k/one2") + r.run("", "show test6/multiline/password") + + // test json modes c["json.mode"] = c.quoteString("plaintext") r.writeConfig(c) - r.run("", "json k") + r.run("", "json test6") c["json.mode"] = c.quoteString("empty") r.writeConfig(c) - r.run("", "json k") + r.run("", "json test6") c["json.mode"] = c.quoteString("hash") c["json.hash_length"] = "3" r.writeConfig(c) - r.run("", "json k") + r.run("", "json test6") // clipboard copyFile := filepath.Join(r.testDir, "clip.copy") @@ -318,7 +298,7 @@ func test(profile string) error { c["clip.paste_command"] = fmt.Sprintf("[\"touch\", \"%s\"]", pasteFile) c["clip.timeout"] = "3" r.writeConfig(c) - r.run("", "clip keys/k/one2") + r.run("", "clip test6/multiline/password") clipPassed := false tries := 0 for tries < clipTries { @@ -333,6 +313,7 @@ func test(profile string) error { return errors.New("clipboard test failed unexpectedly") } + // invalid configuration invalid := r.newConf() for k, v := range c { invalid[k] = v @@ -353,17 +334,6 @@ func test(profile string) error { setConfig(r.config) r.run("", "ls") - // pwgen - c["pwgen.word_command"] = "[\"/bin/sh\", \"-c\", \"echo abc abc | tr ' ' '\\n'\"]" - c["pwgen.word_count"] = "1" - r.writeConfig(c) - r.run("", "pwgen") - c["pwgen.template"] = "\"{{range $idx, $val := .}}{{if lt $val.Position.End 5}}{{ $val.Text }}{{end}}{{end}}\"" - c["pwgen.characters"] = c.quoteString("b") - c["pwgen.word_count"] = "2" - r.writeConfig(c) - r.run("", "pwgen") - // what is env r.run("", fmt.Sprintf("vars | sed 's#/%s#/datadir#g' | grep -v CREDENTIALS | sort", profile)) diff --git a/cmd/lb/tests/expected.log b/cmd/lb/tests/expected.log @@ -1,186 +1,271 @@ -password: keys/k/one2 -path can NOT end with separator -path can NOT be rooted -unwilling to operate on path with empty segment -key/a/one -keys/k/one -keys/k/one2 -keys2/k/three +password: test1/key1/password +input paths must contain at LEAST 2 components +unknown entity field: +unknown entity field: still +otp can NOT be multi-line +password can NOT be multi-line +testing5 +testing5 +testing5 +testing5 +testing5 +testing5 +testing5 +testing5 +test1/key1/password +test2/key1/notes +test2/key1/password +test4/multiline/notes +test5/multiline/notes +test6/multiline/notes +test6/multiline/password +test7/deeper/rooted/notes +test8/unset/notes +test8/unset/password +test9/key1/sub1/password +test9/key1/sub2/password +test9/key2/sub1/password +test1/key1 +test2/key1 +test4/multiline +test5/multiline +test6/multiline +test7/deeper/rooted +test8/unset +test9/key1/sub1 +test9/key1/sub2 +test9/key2/sub1 delete entry? (y/N) -key/a/one -keys/k/one2 -keys2/k/three -key/a/one -keys/k/one2 -keys2/k/three +test1/key1/password +test4/multiline/notes +test5/multiline/notes +test6/multiline/notes +test6/multiline/password +test7/deeper/rooted/notes +test8/unset/notes +test8/unset/password +test9/key1/sub1/password +test9/key1/sub2/password +test9/key2/sub1/password { - "key/a/one": { + "test1/key1": { "modtime": "XXXX-XX-XX", - "data": "ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff" + "password": "521b9ccefbcd14d179e7a1bb877752870a6d620938b28a66a107eac6e6805b9d0989f45b5730508041aa5e710847d439ea74cd312c9355f1f2dae08d40e41d50" }, - "keys/k/one2": { + "test4/multiline": { "modtime": "XXXX-XX-XX", - "data": "6d201beeefb589b08ef0672dac82353d0cbd9ad99e1642c83a1601f3d647bcca003257b5e8f31bdc1d73fbec84fb085c79d6e2677b7ff927e823a54e789140d9" + "notes": "fdb05182a34667e207ad36cf4688d046951a5877482da0b8dfaf1baa112369ede0fd29786e2cf771e0e00d968837d449ce7e373642a945d0675d888403178b77" }, - "keys2/k/three": { + "test5/multiline": { "modtime": "XXXX-XX-XX", - "data": "132ab0244293c495a027cec12d0050598616daca888449920fc652719be0987830827d069ef78cc613e348de37c9b592d3406e2fb8d99a6961bf0c58da8a334f" + "notes": "fdb05182a34667e207ad36cf4688d046951a5877482da0b8dfaf1baa112369ede0fd29786e2cf771e0e00d968837d449ce7e373642a945d0675d888403178b77" + }, + "test6/multiline": { + "modtime": "XXXX-XX-XX", + "notes": "cbf67e6da88d43f048050e36d7010080536372397b6ed0a0446cd2640340bf47cb616ddbc5d35ec3500e295f875f806fc20120a2d5497b3e729e904091424633", + "password": "cbf67e6da88d43f048050e36d7010080536372397b6ed0a0446cd2640340bf47cb616ddbc5d35ec3500e295f875f806fc20120a2d5497b3e729e904091424633" + }, + "test7/deeper/rooted": { + "modtime": "XXXX-XX-XX", + "notes": "cbf67e6da88d43f048050e36d7010080536372397b6ed0a0446cd2640340bf47cb616ddbc5d35ec3500e295f875f806fc20120a2d5497b3e729e904091424633" + }, + "test8/unset": { + "modtime": "XXXX-XX-XX", + "notes": "cbf67e6da88d43f048050e36d7010080536372397b6ed0a0446cd2640340bf47cb616ddbc5d35ec3500e295f875f806fc20120a2d5497b3e729e904091424633", + "password": "cbf67e6da88d43f048050e36d7010080536372397b6ed0a0446cd2640340bf47cb616ddbc5d35ec3500e295f875f806fc20120a2d5497b3e729e904091424633" + }, + "test9/key1/sub1": { + "modtime": "XXXX-XX-XX", + "password": "cbf67e6da88d43f048050e36d7010080536372397b6ed0a0446cd2640340bf47cb616ddbc5d35ec3500e295f875f806fc20120a2d5497b3e729e904091424633" + }, + "test9/key1/sub2": { + "modtime": "XXXX-XX-XX", + "password": "cbf67e6da88d43f048050e36d7010080536372397b6ed0a0446cd2640340bf47cb616ddbc5d35ec3500e295f875f806fc20120a2d5497b3e729e904091424633" + }, + "test9/key2/sub1": { + "modtime": "XXXX-XX-XX", + "password": "cbf67e6da88d43f048050e36d7010080536372397b6ed0a0446cd2640340bf47cb616ddbc5d35ec3500e295f875f806fc20120a2d5497b3e729e904091424633" } } - -test2 -test3 -test4 { - "keys2/k/three": { + "test4/multiline": { + "modtime": "XXXX-XX-XX", + "notes": "fdb05182a34667e207ad36cf4688d046951a5877482da0b8dfaf1baa112369ede0fd29786e2cf771e0e00d968837d449ce7e373642a945d0675d888403178b77" + }, + "test5/multiline": { "modtime": "XXXX-XX-XX", - "data": "132ab0244293c495a027cec12d0050598616daca888449920fc652719be0987830827d069ef78cc613e348de37c9b592d3406e2fb8d99a6961bf0c58da8a334f" + "notes": "fdb05182a34667e207ad36cf4688d046951a5877482da0b8dfaf1baa112369ede0fd29786e2cf771e0e00d968837d449ce7e373642a945d0675d888403178b77" + }, + "test6/multiline": { + "modtime": "XXXX-XX-XX", + "notes": "cbf67e6da88d43f048050e36d7010080536372397b6ed0a0446cd2640340bf47cb616ddbc5d35ec3500e295f875f806fc20120a2d5497b3e729e904091424633", + "password": "cbf67e6da88d43f048050e36d7010080536372397b6ed0a0446cd2640340bf47cb616ddbc5d35ec3500e295f875f806fc20120a2d5497b3e729e904091424633" } } -test/k -test/k +test6/multiline/otp +test6/multiline/otp XXXXXX XXXXXX XXXXXX -"key/a/one": { +"test1/key1": { "modtime": "XXXX-XX-XX", - "data": "ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff" + "password": "521b9ccefbcd14d179e7a1bb877752870a6d620938b28a66a107eac6e6805b9d0989f45b5730508041aa5e710847d439ea74cd312c9355f1f2dae08d40e41d50" } -"keys/k/one2": { +"test4/multiline": { "modtime": "XXXX-XX-XX", - "data": "6d201beeefb589b08ef0672dac82353d0cbd9ad99e1642c83a1601f3d647bcca003257b5e8f31bdc1d73fbec84fb085c79d6e2677b7ff927e823a54e789140d9" + "notes": "fdb05182a34667e207ad36cf4688d046951a5877482da0b8dfaf1baa112369ede0fd29786e2cf771e0e00d968837d449ce7e373642a945d0675d888403178b77" } -"keys2/k/three": { +"test5/multiline": { "modtime": "XXXX-XX-XX", - "data": "132ab0244293c495a027cec12d0050598616daca888449920fc652719be0987830827d069ef78cc613e348de37c9b592d3406e2fb8d99a6961bf0c58da8a334f" + "notes": "fdb05182a34667e207ad36cf4688d046951a5877482da0b8dfaf1baa112369ede0fd29786e2cf771e0e00d968837d449ce7e373642a945d0675d888403178b77" } -"test/k/totp": { - "attributes": { - "otp": "b6c44d5d8a75071d8e8a39df231b0b98584d1d42982b5cf230e44f94d9c48e2983e78955a54b70c0acb0428d6db7205101e332f950ffb6b6d643aa37287c6aa5" - }, +"test6/multiline": { + "modtime": "XXXX-XX-XX", + "notes": "cbf67e6da88d43f048050e36d7010080536372397b6ed0a0446cd2640340bf47cb616ddbc5d35ec3500e295f875f806fc20120a2d5497b3e729e904091424633", + "otp": "b6c44d5d8a75071d8e8a39df231b0b98584d1d42982b5cf230e44f94d9c48e2983e78955a54b70c0acb0428d6db7205101e332f950ffb6b6d643aa37287c6aa5", + "password": "cbf67e6da88d43f048050e36d7010080536372397b6ed0a0446cd2640340bf47cb616ddbc5d35ec3500e295f875f806fc20120a2d5497b3e729e904091424633" +} +"test7/deeper/rooted": { + "modtime": "XXXX-XX-XX", + "notes": "cbf67e6da88d43f048050e36d7010080536372397b6ed0a0446cd2640340bf47cb616ddbc5d35ec3500e295f875f806fc20120a2d5497b3e729e904091424633" +} +"test8/unset": { + "modtime": "XXXX-XX-XX", + "notes": "cbf67e6da88d43f048050e36d7010080536372397b6ed0a0446cd2640340bf47cb616ddbc5d35ec3500e295f875f806fc20120a2d5497b3e729e904091424633", + "password": "cbf67e6da88d43f048050e36d7010080536372397b6ed0a0446cd2640340bf47cb616ddbc5d35ec3500e295f875f806fc20120a2d5497b3e729e904091424633" +} +"test9/key1/sub1": { "modtime": "XXXX-XX-XX", - "data": "7ef183065ba70aaa417b87ea0a96b7e550a938a52440c640a07537f7794d8a89e50078eca6a7cbcfacabd97a2db06d11e82ddf7556ca909c4df9fc0d006013b1" + "password": "cbf67e6da88d43f048050e36d7010080536372397b6ed0a0446cd2640340bf47cb616ddbc5d35ec3500e295f875f806fc20120a2d5497b3e729e904091424633" } +"test9/key1/sub2": { + "modtime": "XXXX-XX-XX", + "password": "cbf67e6da88d43f048050e36d7010080536372397b6ed0a0446cd2640340bf47cb616ddbc5d35ec3500e295f875f806fc20120a2d5497b3e729e904091424633" +} +"test9/key2/sub1": { + "modtime": "XXXX-XX-XX", + "password": "cbf67e6da88d43f048050e36d7010080536372397b6ed0a0446cd2640340bf47cb616ddbc5d35ec3500e295f875f806fc20120a2d5497b3e729e904091424633" +} +no entities matching: test7/deeper +no entities matching: test7/deeper/ro +no entities matching: test1/key1/password delete entry? (y/N) delete entry? (y/N) -no entities matching: test/k/one - +test4/multiline/notes +test5/multiline/notes +test6/multiline/notes +test6/multiline/otp +test6/multiline/password +test8/unset/notes +test8/unset/password +test9/key1/sub1/password +test9/key1/sub2/password +test9/key2/sub1/password +test4/multiline +test5/multiline +test6/multiline +test8/unset +test9/key1/sub1 +test9/key1/sub2 +test9/key2/sub1 +unset: test8/unset/password? (y/N) clearing value from: test8/unset/password -multiple moves can only be done at a leaf level -unable to get destination object -unable to overwrite entries when moving multiple items -keys/k/one2 -keyx/d/e -move/m/ka/abc -move/m/ka/xyz -move/ma/ka2/zzz -move/ma/ka3/yyy -move/ma/ka3/zzz -move/mac/yyy -move/mac/zzz -keyx/d/e -selected entities: - move/m/ka/abc - move/m/ka/xyz - move/ma/ka2/zzz - move/ma/ka3/yyy - move/ma/ka3/zzz - move/mac/yyy - move/mac/zzz - -delete entries? (y/N) delete entry? (y/N) -keys/k/one2 -pre insert keys/k2/t2/one -CALLED -post insert keys/k2/t2/one -CALLED - -keys/k/one2 -keys/k2/one -keys/k2/one2 -keys/k2/t1/one -keys/k2/t1/one2 -keys/k2/t2/one -keys/k2/t2/one2 -selected entities: - keys/k2/t1/one - keys/k2/t1/one2 - -delete entries? (y/N) pre rm keys/k2/t1/one -CALLED -pre rm keys/k2/t1/one2 -CALLED -post rm keys/k2/t1/one -CALLED -post rm keys/k2/t1/one2 -CALLED - -keys/k/one2 -keys/k2/one -keys/k2/one2 -keys/k2/t2/one -keys/k2/t2/one2 +test4/multiline/notes +test5/multiline/notes +test6/multiline/notes +test6/multiline/otp +test6/multiline/password +test8/unset/notes +test9/key1/sub1/password +test9/key1/sub2/password +test9/key2/sub1/password +test4/multiline +test5/multiline +test6/multiline +test8/unset +test9/key1/sub1 +test9/key1/sub2 +test9/key2/sub1 +removing empty group: test8/unset +delete entry? (y/N) +test4/multiline/notes +test5/multiline/notes +test6/multiline/notes +test6/multiline/otp +test6/multiline/password +test9/key1/sub1/password +test9/key1/sub2/password +test9/key2/sub1/password +test4/multiline +test5/multiline +test6/multiline +test9/key1/sub1 +test9/key1/sub2 +test9/key2/sub1 +test4/multiline/notes +test5/multiline/notes +test6/multiline/notes +test6/multiline/otp +test6/multiline/password +test9/sub1/password +test9/sub2/password +test9/sub3/password +test4/multiline +test5/multiline +test6/multiline +test9/sub1 +test9/sub2 +test9/sub3 selected entities: - keys/k2/one - keys/k2/one2 - keys/k2/t2/one - keys/k2/t2/one2 - -delete entries? (y/N) pre rm keys/k2/one -CALLED -pre rm keys/k2/one2 -CALLED -pre rm keys/k2/t2/one -CALLED -pre rm keys/k2/t2/one2 -CALLED -post rm keys/k2/one -CALLED -post rm keys/k2/one2 -CALLED -post rm keys/k2/t2/one -CALLED -post rm keys/k2/t2/one2 -CALLED - -keys/k/one2 + test9/sub1 + test9/sub2 + test9/sub3 +delete entries? (y/N) -keys/k/one2 -test2 +test4/multiline/notes +test5/multiline/notes +test6/multiline/notes +test6/multiline/otp +test6/multiline/password +testing5 { - "keys/k/one2": { + "test6/multiline": { "modtime": "XXXX-XX-XX", - "data": "test2" + "notes": "testing5", + "otp": "otpauth://totp/lbissuer:lbaccount?algorithm=SHA1\u0026digits=6\u0026issuer=lbissuer\u0026period=30\u0026secret=5ae472abqdekjqykoyxk7hvc2leklq5n", + "password": "testing5" } } { - "keys/k/one2": { + "test6/multiline": { "modtime": "XXXX-XX-XX", + "notes": "", + "otp": "", + "password": "" } } { - "keys/k/one2": { + "test6/multiline": { "modtime": "XXXX-XX-XX", - "data": "6d2" + "notes": "cbf", + "otp": "b6c", + "password": "cbf" } } clipboard will clear in 3 seconds Wrong password? HMAC-SHA256 of header mismatching no store set -keys/k/one2 -abc -bb -'] +test4/multiline/notes +test5/multiline/notes +test6/multiline/notes +test6/multiline/otp +test6/multiline/password LOCKBOX_CLIP_COPY_COMMAND=[touch testdata/datadir/clip.copy] LOCKBOX_CLIP_PASTE_COMMAND=[touch testdata/datadir/clip.paste] LOCKBOX_CLIP_TIMEOUT=3 LOCKBOX_INTERACTIVE=false LOCKBOX_JSON_HASH_LENGTH=3 LOCKBOX_JSON_MODE=hash -LOCKBOX_PWGEN_CHARACTERS=b -LOCKBOX_PWGEN_TEMPLATE={{range $idx, $val := .}}{{if lt $val.Position.End 5}}{{ $val.Text }}{{end}}{{end}} -LOCKBOX_PWGEN_WORD_COMMAND=[/bin/sh -c echo abc abc | tr ' ' ' -LOCKBOX_PWGEN_WORD_COUNT=2 LOCKBOX_STORE=testdata/datadir/pass.kdbx diff --git a/internal/app/commands/core.go b/internal/app/commands/core.go @@ -48,12 +48,8 @@ const ( Completions = "completions" // ReKey will rekey the underlying database ReKey = "rekey" - // MultiLine handles multi-line inserts (when not piped) - MultiLine = "multiline" // TOTPShow is for showing the TOTP token TOTPShow = Show - // TOTPInsert is for inserting totp tokens - TOTPInsert = Insert // JSON handles JSON outputs JSON = "json" // CompletionsZsh is the command to generate zsh completions @@ -64,6 +60,10 @@ const ( PasswordGenerate = "pwgen" // Executable is the name of the executable Executable = "lb" + // Unset indicates a value should be unset (removed) from an entity + Unset = "unset" + // Groups handles getting a list of groups + Groups = "groups" ) var ( diff --git a/internal/app/completions/core.go b/internal/app/completions/core.go @@ -21,6 +21,7 @@ type ( TOTPListCommand string TOTPFindCommand string RemoveCommand string + UnsetCommand string ClipCommand string ShowCommand string MultiLineCommand string @@ -28,6 +29,7 @@ type ( TOTPCommand string DoTOTPList string DoList string + DoGroups string Executable string JSONCommand string HelpCommand string @@ -103,12 +105,12 @@ func Generate(completionType, exe string) ([]string, error) { c := Template{ Executable: exe, InsertCommand: commands.Insert, + UnsetCommand: commands.Unset, RemoveCommand: commands.Remove, TOTPListCommand: commands.TOTPList, TOTPFindCommand: commands.TOTPFind, ClipCommand: commands.Clip, ShowCommand: commands.Show, - MultiLineCommand: commands.MultiLine, JSONCommand: commands.JSON, HelpCommand: commands.Help, HelpAdvancedCommand: commands.HelpAdvanced, @@ -116,25 +118,25 @@ func Generate(completionType, exe string) ([]string, error) { TOTPCommand: commands.TOTP, MoveCommand: commands.Move, DoList: fmt.Sprintf("%s %s", exe, commands.List), + DoGroups: fmt.Sprintf("%s %s", exe, commands.Groups), DoTOTPList: fmt.Sprintf("%s %s %s", exe, commands.TOTP, commands.TOTPList), ExportCommand: fmt.Sprintf("%s %s %s", exe, commands.Env, commands.Completions), } c.Conditionals = NewConditionals() - c.Options = c.newGenOptions([]string{commands.Help, commands.List, commands.Show, commands.Version, commands.JSON, commands.Find}, + c.Options = c.newGenOptions([]string{commands.Help, commands.List, commands.Show, commands.Version, commands.JSON, commands.Find, commands.Groups}, map[string]string{ commands.Clip: c.Conditionals.Not.CanClip, commands.TOTP: c.Conditionals.Not.CanTOTP, commands.Move: c.Conditionals.Not.ReadOnly, commands.Remove: c.Conditionals.Not.ReadOnly, commands.Insert: c.Conditionals.Not.ReadOnly, - commands.MultiLine: c.Conditionals.Not.ReadOnly, + commands.Unset: c.Conditionals.Not.ReadOnly, commands.PasswordGenerate: c.Conditionals.Not.CanPasswordGen, }) c.TOTPSubCommands = c.newGenOptions([]string{commands.TOTPMinimal, commands.TOTPOnce, commands.TOTPShow}, map[string]string{ - commands.TOTPClip: c.Conditionals.Not.CanClip, - commands.TOTPInsert: c.Conditionals.Not.ReadOnly, + commands.TOTPClip: c.Conditionals.Not.CanClip, }) using, err := shell.ReadFile(filepath.Join("shell", fmt.Sprintf("%s.sh", completionType))) diff --git a/internal/app/completions/shell/bash.sh b/internal/app/completions/shell/bash.sh @@ -33,7 +33,12 @@ _{{ $.Executable }}() { "{{ $.HelpCommand }}") opts="{{ $.HelpAdvancedCommand }} {{ $.HelpConfigCommand }}" ;; - "{{ $.InsertCommand }}" | "{{ $.MultiLineCommand }}" | "{{ $.MoveCommand }}" | "{{ $.RemoveCommand }}") + "{{ $.MoveCommand }}" | "{{ $.RemoveCommand }}") + if {{ $.Conditionals.Not.AskMode }}; then + opts="$opts $({{ $.DoGroups }})" + fi + ;; + "{{ $.InsertCommand }}" | "{{ $.UnsetCommand }}") if {{ $.Conditionals.Not.AskMode }}; then opts="$opts $({{ $.DoList }})" fi @@ -58,7 +63,7 @@ _{{ $.Executable }}() { case "$chosen" in "{{ $.MoveCommand }}") if {{ $.Conditionals.Not.AskMode }}; then - opts=$({{ $.DoList }}) + opts=$({{ $.DoGroups }}) fi ;; "{{ $.TOTPCommand }}") diff --git a/internal/app/completions/shell/zsh.sh b/internal/app/completions/shell/zsh.sh @@ -45,7 +45,14 @@ _{{ $.Executable }}() { compadd "$@" "{{ $.HelpConfigCommand }}" fi ;; - "{{ $.InsertCommand }}" | "{{ $.MultiLineCommand }}" | "{{ $.RemoveCommand }}") + "{{ $.RemoveCommand }}") + if [ "$len" -eq 3 ]; then + if {{ $.Conditionals.Not.AskMode }}; then + compadd "$@" $({{ $.DoGroups }}) + fi + fi + ;; + "{{ $.InsertCommand }}" | "{{ $.UnsetCommand }}") if [ "$len" -eq 3 ]; then if {{ $.Conditionals.Not.AskMode }}; then compadd "$@" $({{ $.DoList }}) @@ -56,7 +63,7 @@ _{{ $.Executable }}() { case "$len" in 3 | 4) if {{ $.Conditionals.Not.AskMode }}; then - compadd "$@" $({{ $.DoList }}) + compadd "$@" $({{ $.DoGroups }}) fi ;; esac diff --git a/internal/app/conv.go b/internal/app/conv.go @@ -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]json.RawMessage{item.Path: json.RawMessage([]byte(item.Value))}, "", " ") + b, err := json.MarshalIndent(map[string]backend.EntityValues{item.Path: item.Values}, "", " ") if err != nil { return err } diff --git a/internal/app/help/core.go b/internal/app/help/core.go @@ -91,18 +91,18 @@ func Usage(verbose bool, exe string) ([]string, error) { results = append(results, subCommand(commands.Help, commands.HelpAdvanced, "", "display verbose help information")) results = append(results, subCommand(commands.Help, commands.HelpConfig, "", "display verbose configuration information")) results = append(results, command(commands.Insert, isEntry, "insert a new entry into the store")) + results = append(results, command(commands.Unset, isEntry, "clear an entry value")) results = append(results, command(commands.JSON, isFilter, "display detailed information")) results = append(results, command(commands.List, "", "list entries")) + results = append(results, command(commands.Groups, "", "list groups")) results = append(results, command(commands.Find, isFilter, "find matching entries")) results = append(results, command(commands.Move, "src dst", "move an entry from source to destination")) - results = append(results, command(commands.MultiLine, isEntry, "insert a multiline entry into the store")) results = append(results, command(commands.PasswordGenerate, "", "generate a password")) results = append(results, command(commands.ReKey, "", "rekey/reinitialize the database credentials")) results = append(results, command(commands.Remove, isEntry, "remove an entry from the store")) results = append(results, command(commands.Show, isEntry, "show the entry's value")) results = append(results, command(commands.TOTP, isEntry, "display an updating totp generated code")) results = append(results, subCommand(commands.TOTP, commands.TOTPClip, isEntry, "copy totp code to clipboard")) - results = append(results, subCommand(commands.TOTP, commands.TOTPInsert, isEntry, "insert a new totp entry into the store")) results = append(results, subCommand(commands.TOTP, commands.TOTPList, "", "list entries with totp settings")) results = append(results, subCommand(commands.TOTP, commands.TOTPOnce, isEntry, "display the first generated code")) results = append(results, subCommand(commands.TOTP, commands.TOTPMinimal, isEntry, "display one generated code (no details)")) diff --git a/internal/app/insert.go b/internal/app/insert.go @@ -9,46 +9,40 @@ import ( "git.sr.ht/~enckse/lockbox/internal/backend" ) -type ( - // InsertMode changes how inserts are handled - InsertMode uint -) - -const ( - // SingleLineInsert is a single line entry - SingleLineInsert InsertMode = iota - // MultiLineInsert is a multiline insert - MultiLineInsert - // TOTPInsert is a singleline but from TOTP subcommands - TOTPInsert -) - // Insert will execute an insert -func Insert(cmd UserInputOptions, mode InsertMode) error { +func Insert(cmd UserInputOptions) error { t := cmd.Transaction() args := cmd.Args() if len(args) != 1 { return errors.New("invalid insert, no entry given") } entry := args[0] - existing, err := t.Get(entry, backend.BlankValue) + base := backend.Base(entry) + dir := backend.Directory(entry) + existing, err := t.Get(dir, backend.SecretValue) if err != nil { return err } isPipe := cmd.IsPipe() if existing != nil { if !isPipe { - if !cmd.Confirm("overwrite existing") { - return nil + if _, ok := existing.Value(base); ok { + if !cmd.Confirm("overwrite existing") { + return nil + } } } } - password, err := cmd.Input(!isPipe && mode != MultiLineInsert) + password, err := cmd.Input(!isPipe && !strings.EqualFold(base, backend.Notes)) if err != nil { return fmt.Errorf("invalid input: %w", err) } - p := strings.TrimSpace(string(password)) - if err := t.Insert(entry, p); err != nil { + vals := make(backend.EntityValues) + if existing != nil { + vals = existing.Values + } + vals[base] = strings.TrimSpace(string(password)) + if err := t.Insert(dir, vals); err != nil { return err } if !isPipe { diff --git a/internal/app/insert_test.go b/internal/app/insert_test.go @@ -65,28 +65,28 @@ func TestInsertDo(t *testing.T) { m.pipe = func() bool { return false } - m.command.args = []string{"test/test2"} + m.command.args = []string{"test/test2/test3"} m.command.confirm = false m.input = func() ([]byte, error) { return nil, errors.New("failure") } m.command.buf = bytes.Buffer{} - if err := app.Insert(m, app.SingleLineInsert); err == nil || err.Error() != "invalid input: failure" { + if err := app.Insert(m); err == nil || err.Error() != "invalid input: failure" { t.Errorf("invalid error: %v", err) } m.command.confirm = false m.pipe = func() bool { return true } - if err := app.Insert(m, app.SingleLineInsert); err == nil || err.Error() != "invalid input: failure" { + if err := app.Insert(m); err == nil || err.Error() != "invalid input: failure" { t.Errorf("invalid error: %v", err) } m.input = func() ([]byte, error) { return []byte("TEST"), nil } m.command.confirm = true - m.command.args = []string{"a/b/c"} - if err := app.Insert(m, app.SingleLineInsert); err != nil { + m.command.args = []string{"a/b/password"} + if err := app.Insert(m); err != nil { t.Errorf("invalid error: %v", err) } if m.command.buf.String() != "" { @@ -96,15 +96,15 @@ func TestInsertDo(t *testing.T) { return false } m.command.buf = bytes.Buffer{} - if err := app.Insert(m, app.SingleLineInsert); err != nil { + if err := app.Insert(m); err != nil { t.Errorf("invalid error: %v", err) } if m.command.buf.String() == "" { t.Error("invalid insert") } m.command.buf = bytes.Buffer{} - m.command.args = []string{"test/test2/test1"} - if err := app.Insert(m, app.SingleLineInsert); err != nil { + m.command.args = []string{"test/test2/test1/password"} + if err := app.Insert(m); err != nil { t.Errorf("invalid error: %v", err) } if m.command.buf.String() == "" { @@ -112,8 +112,8 @@ func TestInsertDo(t *testing.T) { } m.command.confirm = false m.command.buf = bytes.Buffer{} - m.command.args = []string{"test/test2/test1"} - if err := app.Insert(m, app.SingleLineInsert); err != nil { + m.command.args = []string{"test/test2/test1/password"} + if err := app.Insert(m); err != nil { t.Errorf("invalid error: %v", err) } if m.command.buf.String() != "" { @@ -122,8 +122,8 @@ func TestInsertDo(t *testing.T) { m.interactive = false m.command.confirm = true m.command.buf = bytes.Buffer{} - m.command.args = []string{"test/test2/test1"} - if err := app.Insert(m, app.SingleLineInsert); err != nil { + m.command.args = []string{"test/test2/test1/password"} + if err := app.Insert(m); err != nil { t.Errorf("invalid error: %v", err) } if m.command.buf.String() == "" || !m.interactive { @@ -131,11 +131,11 @@ func TestInsertDo(t *testing.T) { } m.interactive = false m.command.buf = bytes.Buffer{} - m.command.args = []string{"test/test2/test1"} - if err := app.Insert(m, app.MultiLineInsert); err != nil { + m.command.args = []string{"test/test2/test1/notes"} + if err := app.Insert(m); err != nil { t.Errorf("invalid error: %v", err) } if m.command.buf.String() == "" || m.interactive { - t.Error("invalid insert") + t.Errorf("invalid insert %s %v", m.command.buf.String(), m.interactive) } } diff --git a/internal/app/list.go b/internal/app/list.go @@ -4,35 +4,68 @@ package app import ( "errors" "fmt" + "sort" + "strings" "git.sr.ht/~enckse/lockbox/internal/backend" ) // List will list/find entries -func List(cmd CommandOptions, isFind bool) error { +func List(cmd CommandOptions, isFind, groups bool) error { + if isFind && groups { + return errors.New("groups+find not supported") + } args := cmd.Args() - opts := backend.QueryOptions{} - opts.Mode = backend.ListMode + filter := "" if isFind { if len(args) != 1 { return errors.New("find requires one argument") } - opts.PathFilter = args[0] + filter = args[0] } else { if len(args) != 0 { - return errors.New("list does not support any arguments") + return errors.New("arguments not supported") } } + + return doList("", filter, cmd, groups) +} + +func doList(attr, filter string, cmd CommandOptions, groups bool) error { + opts := backend.QueryOptions{} + opts.Mode = backend.ListMode + opts.PathFilter = filter e, err := cmd.Transaction().QueryCallback(opts) if err != nil { return err } w := cmd.Writer() + attrFilter := attr != "" for f, err := range e { if err != nil { return err } - fmt.Fprintf(w, "%s\n", f.Path) + if groups { + fmt.Fprintf(w, "%s\n", f.Path) + continue + } + if f.Values == nil { + continue + } + var results []string + for k := range f.Values { + if attrFilter { + if k != attr { + continue + } + } + results = append(results, backend.NewPath(f.Path, k)) + } + if len(results) == 0 { + continue + } + sort.Strings(results) + fmt.Fprintf(w, "%s\n", strings.Join(results, "\n")) } return nil } diff --git a/internal/app/list_test.go b/internal/app/list_test.go @@ -43,21 +43,21 @@ func setup(t *testing.T) *backend.Transaction { func TestList(t *testing.T) { m := newMockCommand(t) - if err := app.List(m, false); err != nil { + if err := app.List(m, false, false); err != nil { t.Errorf("invalid error: %v", err) } if m.buf.String() == "" { t.Error("nothing listed") } m.args = []string{"test"} - if err := app.List(m, false); err == nil || err.Error() != "list does not support any arguments" { + if err := app.List(m, false, false); err == nil || err.Error() != "arguments not supported" { t.Errorf("invalid error: %v", err) } } func TestFind(t *testing.T) { m := newMockCommand(t) - if err := app.List(m, true); err == nil || err.Error() != "find requires one argument" { + if err := app.List(m, true, false); err == nil || err.Error() != "find requires one argument" { t.Errorf("invalid error: %v", err) } if m.buf.String() != "" { @@ -65,7 +65,7 @@ func TestFind(t *testing.T) { } m.buf.Reset() m.args = []string{"["} - if err := app.List(m, true); err == nil || !strings.Contains(err.Error(), "missing closing") { + if err := app.List(m, true, false); err == nil || !strings.Contains(err.Error(), "missing closing") { t.Errorf("invalid error: %v", err) } if m.buf.String() != "" { @@ -73,7 +73,7 @@ func TestFind(t *testing.T) { } m.buf.Reset() m.args = []string{"test", "1"} - if err := app.List(m, true); err == nil || err.Error() != "find requires one argument" { + if err := app.List(m, true, false); err == nil || err.Error() != "find requires one argument" { t.Errorf("invalid error: %v", err) } if m.buf.String() != "" { @@ -81,7 +81,7 @@ func TestFind(t *testing.T) { } m.buf.Reset() m.args = []string{"[zzzzzz]"} - if err := app.List(m, true); err != nil { + if err := app.List(m, true, false); err != nil { t.Errorf("invalid error: %v", err) } if m.buf.String() != "" { @@ -89,10 +89,28 @@ func TestFind(t *testing.T) { } m.buf.Reset() m.args = []string{"test"} - if err := app.List(m, true); err != nil { + if err := app.List(m, true, false); err != nil { t.Errorf("invalid error: %v", err) } if m.buf.String() == "" { t.Error("nothing listed") } } + +func TestGroups(t *testing.T) { + m := newMockCommand(t) + if err := app.List(m, false, true); err != nil { + t.Errorf("invalid error: %v", err) + } + if m.buf.String() == "" { + t.Errorf("nothing listed: %s", m.buf.String()) + } + m.args = []string{"test"} + if err := app.List(m, false, true); err == nil || err.Error() != "arguments not supported" { + t.Errorf("invalid error: %v", err) + } + m.args = []string{} + if err := app.List(m, true, true); err == nil || err.Error() != "groups+find not supported" { + t.Errorf("invalid error: %v", err) + } +} diff --git a/internal/app/move_test.go b/internal/app/move_test.go @@ -21,12 +21,12 @@ type ( func newMockCommand(t *testing.T) *mockCommand { setup(t) - fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test1"), "pass") - fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test2"), "pass") - fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test3"), "pass") - fullSetup(t, true).Insert(backend.NewPath("test", "test3", "test1"), "pass") - fullSetup(t, true).Insert(backend.NewPath("test", "test3", "test2"), "pass") - fullSetup(t, true).Insert(backend.NewPath("test", "test4", "test5"), "pass") + 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"}) return &mockCommand{t: t, confirmed: false, confirm: true} } diff --git a/internal/app/remove.go b/internal/app/remove.go @@ -4,6 +4,9 @@ package app import ( "errors" "fmt" + "io" + + "git.sr.ht/~enckse/lockbox/internal/backend" ) // Remove will remove an entry @@ -12,8 +15,11 @@ func Remove(cmd CommandOptions) error { if len(args) != 1 { return errors.New("remove requires an entry") } - t := cmd.Transaction() - deleting := args[0] + return remove(cmd.Transaction(), cmd.Writer(), args[0], cmd) +} + +func remove(t *backend.Transaction, w io.Writer, entry string, cmd CommandOptions) error { + deleting := entry postfixRemove := "y" existings, err := t.MatchPath(deleting) if err != nil { @@ -22,7 +28,6 @@ func Remove(cmd CommandOptions) error { if len(existings) == 0 { return fmt.Errorf("no entities matching: %s", deleting) } - w := cmd.Writer() if len(existings) > 1 { postfixRemove = "ies" fmt.Fprintln(w, "selected entities:") diff --git a/internal/app/showclip.go b/internal/app/showclip.go @@ -24,19 +24,33 @@ func ShowClip(cmd CommandOptions, isShow bool) error { return fmt.Errorf("unable to get clipboard: %w", err) } } - existing, err := cmd.Transaction().Get(entry, backend.SecretValue) + val, err := getEntity(entry, cmd) if err != nil { return err } - if existing == nil { - return errors.New("entry does not exist") - } if isShow { - fmt.Fprintln(cmd.Writer(), existing.Value) + fmt.Fprintln(cmd.Writer(), val) return nil } - if err := clipboard.CopyTo(existing.Value); err != nil { + if err := clipboard.CopyTo(val); err != nil { return fmt.Errorf("clipboard operation failed: %w", err) } return nil } + +func getEntity(entry string, cmd CommandOptions) (string, error) { + base := backend.Base(entry) + dir := backend.Directory(entry) + existing, err := cmd.Transaction().Get(dir, backend.SecretValue) + if err != nil { + return "", err + } + if existing == nil { + return "", errors.New("entry does not exist") + } + val, ok := existing.Value(base) + if !ok { + return "", fmt.Errorf("entity value invalid: %s (%s)", base, entry) + } + return val, nil +} diff --git a/internal/app/showclip_test.go b/internal/app/showclip_test.go @@ -13,7 +13,7 @@ func TestShowClip(t *testing.T) { if err := app.ShowClip(m, true); err.Error() != "only one argument supported" { t.Errorf("invalid error: %v", err) } - m.args = []string{"test/test2/test1"} + m.args = []string{"test/test2/test1/password"} if err := app.ShowClip(m, true); err != nil { t.Errorf("invalid error: %v", err) } diff --git a/internal/app/totp.go b/internal/app/totp.go @@ -31,7 +31,6 @@ type ( // TOTPArguments are the parsed TOTP call arguments TOTPArguments struct { Entry string - token string Mode Mode } totpWrapper struct { @@ -50,8 +49,6 @@ type ( const ( // UnknownTOTPMode is an unknown command UnknownTOTPMode Mode = iota - // InsertTOTPMode is inserting a new totp token - InsertTOTPMode // ShowTOTPMode will show the token ShowTOTPMode // ClipTOTPMode will copy to clipboard @@ -102,15 +99,14 @@ func (args *TOTPArguments) display(opts TOTPOptions) error { if !interactive && clipMode { return errors.New("clipboard not available in non-interactive mode") } - entity, err := opts.app.Transaction().Get(backend.NewPath(args.Entry, args.token), backend.SecretValue) + if !backend.IsLeafAttribute(args.Entry, backend.OTP) { + return fmt.Errorf("'%s' is not a TOTP entry", args.Entry) + } + entity, err := getEntity(args.Entry, opts.app) if err != nil { return err } - if entity == nil { - return errors.New("object does not exist") - } - totpToken := string(entity.Value) - k, err := coreotp.NewKeyFromURL(config.EnvTOTPFormat.Get(totpToken)) + k, err := coreotp.NewKeyFromURL(config.EnvTOTPFormat.Get(entity)) if err != nil { return err } @@ -225,32 +221,17 @@ func (args *TOTPArguments) Do(opts TOTPOptions) error { return ErrNoTOTP } if args.Mode == ListTOTPMode || args.Mode == FindTOTPMode { - e, err := opts.app.Transaction().QueryCallback(backend.QueryOptions{Mode: backend.SuffixMode, Criteria: backend.NewSuffix(args.token), PathFilter: args.Entry}) - if err != nil { - return err - } - writer := opts.app.Writer() - for entry, err := range e { - if err != nil { - return err - } - fmt.Fprintf(writer, "%s\n", entry.Directory()) - } - return nil + return doList(backend.OTP, args.Entry, opts.app, false) } return args.display(opts) } // NewTOTPArguments will parse the input arguments -func NewTOTPArguments(args []string, tokenType string) (*TOTPArguments, error) { +func NewTOTPArguments(args []string) (*TOTPArguments, error) { if len(args) == 0 { return nil, errors.New("not enough arguments for totp") } - if strings.TrimSpace(tokenType) == "" { - return nil, errors.New("invalid token type, not set?") - } opts := &TOTPArguments{Mode: UnknownTOTPMode} - opts.token = tokenType sub := args[0] needs := true switch sub { @@ -262,8 +243,6 @@ func NewTOTPArguments(args []string, tokenType string) (*TOTPArguments, error) { opts.Mode = ListTOTPMode case commands.TOTPFind: opts.Mode = FindTOTPMode - case commands.TOTPInsert: - opts.Mode = InsertTOTPMode case commands.TOTPShow: opts.Mode = ShowTOTPMode case commands.TOTPClip: @@ -280,11 +259,6 @@ func NewTOTPArguments(args []string, tokenType string) (*TOTPArguments, error) { return nil, errors.New("invalid arguments") } opts.Entry = args[1] - if opts.Mode == InsertTOTPMode { - if !strings.HasSuffix(opts.Entry, tokenType) { - opts.Entry = backend.NewPath(opts.Entry, tokenType) - } - } } return opts, nil } diff --git a/internal/app/totp_test.go b/internal/app/totp_test.go @@ -20,9 +20,8 @@ type ( ) func newMock(t *testing.T) (*mockOptions, app.TOTPOptions) { - fullTOTPSetup(t, true).Insert(backend.NewPath("test", "test2", "test1"), "pass") - fullTOTPSetup(t, true).Insert(backend.NewPath("test", "test3", "totp"), "5ae472abqdekjqykoyxk7hvc2leklq5n") - fullTOTPSetup(t, true).Insert(backend.NewPath("test", "test2", "totp"), "5ae472abqdekjqykoyxk7hvc2leklq5n") + 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"}) m := &mockOptions{ buf: bytes.Buffer{}, tx: fullTOTPSetup(t, true), @@ -78,56 +77,45 @@ func setupTOTP(t *testing.T) *backend.Transaction { } func TestNewTOTPArgumentsErrors(t *testing.T) { - if _, err := app.NewTOTPArguments(nil, ""); err == nil || err.Error() != "not enough arguments for totp" { + if _, err := app.NewTOTPArguments(nil); err == nil || err.Error() != "not enough arguments for totp" { t.Errorf("invalid error: %v", err) } - if _, err := app.NewTOTPArguments([]string{"test"}, ""); err == nil || err.Error() != "invalid token type, not set?" { + if _, err := app.NewTOTPArguments([]string{"test"}); err == nil || err.Error() != "unknown totp mode" { t.Errorf("invalid error: %v", err) } - if _, err := app.NewTOTPArguments([]string{"test"}, "a"); err == nil || err.Error() != "unknown totp mode" { + if _, err := app.NewTOTPArguments([]string{"ls", "test"}); err == nil || err.Error() != "list takes no arguments" { t.Errorf("invalid error: %v", err) } - if _, err := app.NewTOTPArguments([]string{"ls", "test"}, "a"); err == nil || err.Error() != "list takes no arguments" { - t.Errorf("invalid error: %v", err) - } - if _, err := app.NewTOTPArguments([]string{"show"}, "a"); err == nil || err.Error() != "invalid arguments" { + if _, err := app.NewTOTPArguments([]string{"show"}); err == nil || err.Error() != "invalid arguments" { t.Errorf("invalid error: %v", err) } } func TestNewTOTPArguments(t *testing.T) { - args, _ := app.NewTOTPArguments([]string{"ls"}, "test") + args, _ := app.NewTOTPArguments([]string{"ls"}) if args.Mode != app.ListTOTPMode || args.Entry != "" { t.Error("invalid args") } - args, _ = app.NewTOTPArguments([]string{"find", "tesst"}, "test") + args, _ = app.NewTOTPArguments([]string{"find", "tesst"}) if args.Mode != app.FindTOTPMode || args.Entry == "" { t.Error("invalid args") } - args, _ = app.NewTOTPArguments([]string{"show", "test"}, "test") + args, _ = app.NewTOTPArguments([]string{"show", "test"}) if args.Mode != app.ShowTOTPMode || args.Entry != "test" { t.Error("invalid args") } - args, _ = app.NewTOTPArguments([]string{"clip", "test"}, "test") + args, _ = app.NewTOTPArguments([]string{"clip", "test"}) if args.Mode != app.ClipTOTPMode || args.Entry != "test" { t.Error("invalid args") } - args, _ = app.NewTOTPArguments([]string{"minimal", "test"}, "test") + args, _ = app.NewTOTPArguments([]string{"minimal", "test"}) if args.Mode != app.MinimalTOTPMode || args.Entry != "test" { t.Error("invalid args") } - args, _ = app.NewTOTPArguments([]string{"once", "test"}, "test") + args, _ = app.NewTOTPArguments([]string{"once", "test"}) if args.Mode != app.OnceTOTPMode || args.Entry != "test" { t.Error("invalid args") } - args, _ = app.NewTOTPArguments([]string{"insert", "test2"}, "test") - if args.Mode != app.InsertTOTPMode || args.Entry != "test2/test" { - t.Errorf("invalid args: %s", args.Entry) - } - args, _ = app.NewTOTPArguments([]string{"insert", "test2/test"}, "test") - if args.Mode != app.InsertTOTPMode || args.Entry != "test2/test" { - t.Errorf("invalid args: %s", args.Entry) - } } func TestDoErrors(t *testing.T) { @@ -161,37 +149,45 @@ func TestDoErrors(t *testing.T) { func TestTOTPList(t *testing.T) { setupTOTP(t) - args, _ := app.NewTOTPArguments([]string{"ls"}, "totp") + args, _ := app.NewTOTPArguments([]string{"ls"}) m, opts := newMock(t) if err := args.Do(opts); err != nil { t.Errorf("invalid error: %v", err) } - if m.buf.String() != "test/test2\ntest/test3\n" { + if m.buf.String() != "test/test2/totp/otp\ntest/test3/totp/otp\n" { t.Errorf("invalid list: %s", m.buf.String()) } } func TestNonListError(t *testing.T) { setupTOTP(t) - args, _ := app.NewTOTPArguments([]string{"clip", "test"}, "totp") + args, _ := app.NewTOTPArguments([]string{"show", "test/test3"}) _, opts := newMock(t) opts.IsInteractive = func() bool { return false } + if err := args.Do(opts); err == nil || err.Error() != "'test/test3' is not a TOTP entry" { + t.Errorf("invalid error: %v", err) + } + args, _ = app.NewTOTPArguments([]string{"clip", "test/test3/otp"}) + _, opts = newMock(t) + opts.IsInteractive = func() bool { + return false + } if err := args.Do(opts); err == nil || err.Error() != "clipboard not available in non-interactive mode" { t.Errorf("invalid error: %v", err) } opts.IsInteractive = func() bool { return true } - if err := args.Do(opts); err == nil || err.Error() != "object does not exist" { + if err := args.Do(opts); err == nil || err.Error() != "entry does not exist" { t.Errorf("invalid error: %v", err) } } func TestMinimal(t *testing.T) { setupTOTP(t) - args, _ := app.NewTOTPArguments([]string{"minimal", "test/test3"}, "totp") + args, _ := app.NewTOTPArguments([]string{"minimal", "test/test3/totp/otp"}) m, opts := newMock(t) if err := args.Do(opts); err != nil { t.Errorf("invalid error: %v", err) @@ -203,7 +199,7 @@ func TestMinimal(t *testing.T) { func TestNonInteractive(t *testing.T) { setupTOTP(t) - args, _ := app.NewTOTPArguments([]string{"show", "test/test3"}, "totp") + args, _ := app.NewTOTPArguments([]string{"show", "test/test3/totp/otp"}) m, opts := newMock(t) opts.IsInteractive = func() bool { return false @@ -218,7 +214,7 @@ func TestNonInteractive(t *testing.T) { func TestOnce(t *testing.T) { setupTOTP(t) - args, _ := app.NewTOTPArguments([]string{"once", "test/test3"}, "totp") + args, _ := app.NewTOTPArguments([]string{"once", "test/test3/totp/otp"}) m, opts := newMock(t) if err := args.Do(opts); err != nil { t.Errorf("invalid error: %v", err) @@ -230,7 +226,7 @@ func TestOnce(t *testing.T) { func TestShow(t *testing.T) { setupTOTP(t) - args, _ := app.NewTOTPArguments([]string{"show", "test/test3"}, "totp") + args, _ := app.NewTOTPArguments([]string{"show", "test/test3/totp/otp"}) m, opts := newMock(t) if err := args.Do(opts); err != nil { t.Errorf("invalid error: %v", err) @@ -275,16 +271,16 @@ func TestParseWindows(t *testing.T) { func TestTOTPFind(t *testing.T) { setupTOTP(t) - args, _ := app.NewTOTPArguments([]string{"find", "test"}, "totp") + args, _ := app.NewTOTPArguments([]string{"find", "test"}) m, opts := newMock(t) if err := args.Do(opts); err != nil { t.Errorf("invalid error: %v", err) } - if m.buf.String() != "test/test2\ntest/test3\n" { + if m.buf.String() != "test/test2/totp/otp\ntest/test3/totp/otp\n" { t.Errorf("invalid list: %s", m.buf.String()) } m.buf.Reset() - args, _ = app.NewTOTPArguments([]string{"find", "[zzzz]"}, "totp") + args, _ = app.NewTOTPArguments([]string{"find", "[zzzz]"}) if err := args.Do(opts); err != nil { t.Errorf("invalid error: %v", err) } diff --git a/internal/app/unset.go b/internal/app/unset.go @@ -0,0 +1,57 @@ +// Package app can unset a field +package app + +import ( + "errors" + "fmt" + + "git.sr.ht/~enckse/lockbox/internal/backend" +) + +// Unset enables clearing an entry +func Unset(cmd CommandOptions) error { + t := cmd.Transaction() + args := cmd.Args() + if len(args) != 1 { + 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) + if err != nil { + return err + } + if existing == nil { + return fmt.Errorf("%s does not exist", entry) + } + w := cmd.Writer() + unsetRemove := func(v backend.EntityValues) (bool, error) { + if len(v) == 0 { + fmt.Fprintf(w, "removing empty group: %s\n", dir) + return true, remove(t, w, dir, cmd) + } + return false, nil + } + ok, err := unsetRemove(existing.Values) + if ok { + return err + } + if _, ok := existing.Value(base); ok { + vals := existing.Values + delete(vals, base) + ok, err = unsetRemove(vals) + if ok { + return err + } + if !cmd.Confirm(fmt.Sprintf("unset: %s", entry)) { + return nil + } + fmt.Fprintf(w, "clearing value from: %s\n", entry) + if err := t.Insert(dir, vals); err != nil { + return err + } + return nil + } + return fmt.Errorf("unable to unset: %s", entry) +} diff --git a/internal/app/unset_test.go b/internal/app/unset_test.go @@ -0,0 +1,50 @@ +package app_test + +import ( + "testing" + + "git.sr.ht/~enckse/lockbox/internal/app" + "git.sr.ht/~enckse/lockbox/internal/backend" +) + +func TestUnset(t *testing.T) { + m := newMockCommand(t) + fullSetup(t, true).Insert(backend.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) + } + m.args = []string{"a/y/z"} + if err := app.Unset(m); err == nil || err.Error() != "a/y/z does not exist" { + t.Errorf("invalid error: %v", err) + } + m.confirm = false + m.args = []string{"test/test2/test1/otp"} + if err := app.Unset(m); err == nil || err.Error() != "unable to unset: test/test2/test1/otp" { + t.Errorf("invalid error: %v", err) + } + m.confirm = false + m.args = []string{"test/test2/test1/password"} + if err := app.Unset(m); err != nil { + t.Errorf("invalid error: %v", err) + } + if m.buf.String() != "" { + t.Errorf("invalid operation") + } + m.buf.Reset() + m.confirm = true + m.args = []string{"test/test2/test1/password"} + if err := app.Unset(m); err != nil { + t.Errorf("invalid error: %v", err) + } + if m.buf.String() == "" { + t.Errorf("invalid operation") + } + m.buf.Reset() + m.args = []string{"test/test2/testz/notes"} + if err := app.Unset(m); err != nil { + t.Errorf("invalid error: %v", err) + } + if m.buf.String() == "" { + t.Errorf("invalid operation") + } +} diff --git a/internal/backend/actions.go b/internal/backend/actions.go @@ -3,6 +3,7 @@ package backend import ( "errors" + "fmt" "os" "strings" "time" @@ -189,8 +190,22 @@ func (t *Transaction) Move(src *Entity, dst string) error { if strings.TrimSpace(src.Path) == "" { return errors.New("empty path not allowed") } - if strings.TrimSpace(src.Value) == "" { - return errors.New("empty secret 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() @@ -220,7 +235,6 @@ func (t *Transaction) Move(src *Entity, dst string) error { if err := hook.Run(HookPre); err != nil { return err } - multi := len(strings.Split(strings.TrimSpace(src.Value), "\n")) > 1 err = t.change(func(c Context) error { c.removeEntity(sOffset, sTitle) if action == MoveAction { @@ -228,24 +242,20 @@ func (t *Transaction) Move(src *Entity, dst string) error { } e := gokeepasslib.NewEntry() e.Values = append(e.Values, value(titleKey, dTitle)) - field := passKey - if multi { - field = notesKey - } - v := src.Value - ok, err := isTOTP(dTitle) - if err != nil { - return err - } - if ok { - if multi { - return errors.New("totp tokens can NOT be multi-line") + e.Values = append(e.Values, value(modTimeKey, modTime.Format(time.RFC3339))) + for k, v := range values { + val := v + switch k { + case otpKey, passKey: + if strings.Contains(val, "\n") { + return fmt.Errorf("%s can NOT be multi-line", strings.ToLower(k)) + } + if k == otpKey { + val = config.EnvTOTPFormat.Get(v) + } } - otp := config.EnvTOTPFormat.Get(v) - e.Values = append(e.Values, protectedValue("otp", otp)) + e.Values = append(e.Values, protectedValue(k, val)) } - e.Values = append(e.Values, protectedValue(field, v)) - e.Values = append(e.Values, value(modTimeKey, modTime.Format(time.RFC3339))) c.alterEntities(true, dOffset, dTitle, &e) return nil }) @@ -256,8 +266,8 @@ func (t *Transaction) Move(src *Entity, dst string) error { } // Insert is a move to the same location -func (t *Transaction) Insert(path, val string) error { - return t.Move(&Entity{Path: path, Value: val}, path) +func (t *Transaction) Insert(path string, val EntityValues) error { + return t.Move(&Entity{Path: path, Values: val}, path) } // Remove will remove a single entity diff --git a/internal/backend/actions_test.go b/internal/backend/actions_test.go @@ -56,7 +56,7 @@ func TestKeyFile(t *testing.T) { if err != nil { t.Errorf("failed: %v", err) } - if err := tr.Insert(backend.NewPath("a", "b"), "t"); err != nil { + if err := tr.Insert(backend.NewPath("a", "b"), map[string]string{"password": "t"}); err != nil { t.Errorf("no error: %v", err) } } @@ -69,104 +69,111 @@ func TestNoWriteOnRO(t *testing.T) { setup(t) store.SetBool("LOCKBOX_READONLY", true) tr, _ := backend.NewTransaction() - if err := tr.Insert("a/a/a", "a"); err.Error() != "unable to alter database in readonly mode" { - t.Errorf("wrong error: %v", err) - } -} - -func TestBadTOTP(t *testing.T) { - tr := setup(t) - store.SetString("LOCKBOX_TOTP_ENTRY", "Title") - if err := tr.Insert("a/a/a", "a"); err.Error() != "invalid totp field, uses restricted name" { + 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", "a"); err.Error() != "invalid 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"), "pass") - fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test3"), "pass") + 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"), Value: "abc"}, backend.NewPath("test1", "test2", "test3")); err != nil { + 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 q.Value != "abc" { + 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"), Value: "test"}, backend.NewPath("test1", "test2", "test3")); err != nil { + 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 q.Value != "test" { + if val, ok := q.Value("password"); !ok || val != "test" { t.Errorf("invalid retrieval") } } func TestInserts(t *testing.T) { - if err := setup(t).Insert("", ""); err.Error() != "empty path not allowed" { + if err := setup(t).Insert("", nil); err.Error() != "empty path not allowed" { t.Errorf("wrong error: %v", err) } - if err := setup(t).Insert("tests", "test"); err.Error() != "input paths must contain at LEAST 2 components" { + 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//l", "test"); err.Error() != "unwilling to operate on path with empty segment" { + 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/", "test"); err.Error() != "path can NOT end with separator" { + 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", "test"); err.Error() != "path can NOT be rooted" { + 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("test", "test"); err.Error() != "input paths must contain at LEAST 2 components" { + 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("a", ""); err.Error() != "empty secret not allowed" { + 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(backend.NewPath("test", "offset", "value"), "pass"); err != nil { + 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"), "pass2"); err != nil { + 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"), "pass\npass"); err != nil { + 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 q.Value != "pass2" { + 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 q.Value != "pass\npass" { - t.Errorf("invalid retrieval") + 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 err := fullSetup(t, true).Insert(backend.NewPath("test", "offset", "totp"), "5ae472abqdekjqykoyxk7hvc2leklq5n"); err != nil { + 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", "totp"), "ljaf\n5ae472abqdekjqykoyxk7hvc2leklq5n"); err.Error() != "totp tokens can NOT be multi-line" { + 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) } } @@ -184,7 +191,7 @@ func TestRemoves(t *testing.T) { } setup(t) for _, i := range []string{"test1", "test2"} { - fullSetup(t, true).Insert(backend.NewPath(i, i, i), "pass") + 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 { @@ -199,7 +206,7 @@ func TestRemoves(t *testing.T) { } 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, "pass") + 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 { @@ -244,7 +251,7 @@ func TestRemoveAlls(t *testing.T) { } 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, "pass") + 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) @@ -296,7 +303,7 @@ func keyAndOrKeyFile(t *testing.T, key, keyFile bool) { t.Errorf("failed: %v", err) } invalid := !key && !keyFile - err = tr.Insert(backend.NewPath("a", "b"), "t") + 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) diff --git a/internal/backend/core.go b/internal/backend/core.go @@ -14,7 +14,10 @@ import ( "github.com/tobischo/gokeepasslib/v3/wrappers" ) -var errPath = errors.New("input paths must contain at LEAST 2 components") +var ( + errPath = errors.New("input paths must contain at LEAST 2 components") + allowedFields = []string{notesKey, passKey, otpKey} +) const ( notesKey = "Notes" @@ -23,9 +26,16 @@ const ( pathSep = "/" isGlob = pathSep + "*" modTimeKey = "ModTime" + otpKey = "otp" + // OTP is the totp storage attribute + OTP = otpKey + // Notes is the multiline notes key + Notes = notesKey ) 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 @@ -42,8 +52,8 @@ type ( } // Entity are database objects from results and transactional changes Entity struct { - Path string - Value string + Values EntityValues + Path string } ) @@ -138,18 +148,6 @@ func encode(f *os.File, db *gokeepasslib.Database) error { return gokeepasslib.NewEncoder(f).Encode(db) } -func isRestrictedField(key string) bool { - return key == notesKey || key == passKey || key == titleKey -} - -func isTOTP(title string) (bool, error) { - t := config.EnvTOTPEntry.Get() - if isRestrictedField(t) { - return false, errors.New("invalid totp field, uses restricted name") - } - return NewSuffix(title) == NewSuffix(t), nil -} - func getPathName(entry gokeepasslib.Entry) string { return entry.GetTitle() } @@ -175,9 +173,13 @@ func NewPath(segments ...string) string { return strings.Join(segments, pathSep) } -// Directory gets the offset location of the entry without the 'name' -func (e Entity) Directory() string { - return Directory(e.Path) +// 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 @@ -208,6 +210,11 @@ 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 diff --git a/internal/backend/core_test.go b/internal/backend/core_test.go @@ -73,22 +73,12 @@ func TestDirectory(t *testing.T) { } } -func TestEntityDir(t *testing.T) { - q := backend.Entity{Path: backend.NewPath("abc", "xyz")} - if q.Directory() != "abc" { - t.Error("invalid query directory") +func TestIsLeafAttr(t *testing.T) { + if backend.IsLeafAttribute("axyz", "z") { + t.Error("invalid result") } - q = backend.Entity{Path: backend.NewPath("abc", "xyz", "111")} - if q.Directory() != "abc/xyz" { - t.Error("invalid query directory") - } - q = backend.Entity{Path: ""} - if q.Directory() != "" { - t.Error("invalid query directory") - } - q = backend.Entity{Path: backend.NewPath("abc")} - if q.Directory() != "" { - t.Error("invalid query directory") + if !backend.IsLeafAttribute("axy/z", "z") { + t.Error("invalid result") } } @@ -145,3 +135,21 @@ func TestQuerySeq2Collect(t *testing.T) { 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 @@ -3,7 +3,6 @@ package backend import ( "crypto/sha512" - "encoding/json" "errors" "fmt" "regexp" @@ -23,12 +22,6 @@ type ( Mode QueryMode Values ValueMode } - // JSON is an entry as a JSON string - JSON struct { - Attributes map[string]string `json:"attributes,omitempty"` - ModTime string `json:"modtime"` - Data string `json:"data,omitempty"` - } // QueryMode indicates HOW an entity will be found QueryMode int // ValueMode indicates what to do with the store value of the entity @@ -52,8 +45,6 @@ const ( FindMode // ExactMode means an entity must MATCH the string exactly ExactMode - // SuffixMode will look for an entity ending in a specific value - SuffixMode // PrefixMode allows for entities starting with a specific value PrefixMode ) @@ -153,10 +144,6 @@ func (t *Transaction) QueryCallback(args QueryOptions) (QuerySeq2, error) { if !strings.Contains(path, args.Criteria) { return } - case SuffixMode: - if !strings.HasSuffix(path, args.Criteria) { - return - } case PrefixMode: if !strings.HasPrefix(path, args.Criteria) { return @@ -226,38 +213,26 @@ func (t *Transaction) QueryCallback(args QueryOptions) (QuerySeq2, error) { for _, item := range entities { entity := Entity{Path: item.path} var err error - if args.Values != BlankValue { - val := getValue(item.backing, notesKey) - if strings.TrimSpace(val) == "" { - val = item.backing.GetPassword() - } - switch args.Values { - case JSONValue: - data := jsonHasher(val) - t := getValue(item.backing, modTimeKey) - var attrs map[string]string - for _, v := range item.backing.Values { - if attrs == nil { - attrs = make(map[string]string) - } - key := v.Key - if isRestrictedField(key) || key == modTimeKey { - continue - } - - attrs[key] = jsonHasher(v.Value.Content) + 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) } - s := JSON{ModTime: t, Data: data, Attributes: attrs} - m, jErr := json.Marshal(s) - if jErr == nil { - entity.Value = string(m) - } else { - err = jErr + val = v.Value.Content + switch args.Values { + case JSONValue: + val = jsonHasher(val) } - case SecretValue: - entity.Value = val } + if key == modTimeKey || key == titleKey { + continue + } + values[strings.ToLower(key)] = val } + entity.Values = values if !yield(entity, err) { return } diff --git a/internal/backend/query_test.go b/internal/backend/query_test.go @@ -1,7 +1,8 @@ package backend_test import ( - "encoding/json" + "errors" + "fmt" "strings" "testing" @@ -9,12 +10,51 @@ import ( "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", "tedst") - fullSetup(t, true).Insert("test/test/abcx", "tedst") - fullSetup(t, true).Insert("test/test/ab11c", "tdest\ntest") - fullSetup(t, true).Insert("test/test/abc1ak", "atest") + 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) { @@ -27,9 +67,14 @@ func TestMatchPath(t *testing.T) { if len(q) != 1 { t.Error("invalid entity result") } - if q[0].Path != "test/test/abc" || q[0].Value != "" { + 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) @@ -62,9 +107,14 @@ func TestGet(t *testing.T) { if err != nil { t.Errorf("no error: %v", err) } - if q.Path != "test/test/abc" || q.Value != "" { + 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") @@ -81,76 +131,78 @@ func TestValueModes(t *testing.T) { if err != nil { t.Errorf("no error: %v", err) } - if q.Value != "" { - t.Errorf("invalid result value: %s", q.Value) + 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) } - m := backend.JSON{} - if err := json.Unmarshal([]byte(q.Value), &m); err != nil { - t.Errorf("no error: %v", err) - } - if m.Data != "44276ba24db13df5568aa6db81e0190ab9d35d2168dce43dca61e628f5c666b1d8b091f1dda59c2359c86e7d393d59723a421d58496d279031e7f858c11d893e" { - t.Errorf("invalid result value: %s", q.Value) - } - if len(m.ModTime) < 20 { - t.Errorf("invalid date/time") + 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) } - m = backend.JSON{} - if err := json.Unmarshal([]byte(q.Value), &m); err != nil { - t.Errorf("no error: %v", err) - } - if m.Data != "44276ba24d" { - t.Errorf("invalid result value: %s", q.Value) + 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 q.Value != "tdest\ntest" { - t.Errorf("invalid result value: %s", q.Value) - } - q, err = fullSetup(t, true).Get("test/test/abc", backend.JSONValue) - if err != nil { - t.Errorf("no error: %v", err) - } - m = backend.JSON{} - if err := json.Unmarshal([]byte(q.Value), &m); err != nil { - t.Errorf("no error: %v", err) - } - if len(m.ModTime) < 20 || m.Data == "" { - t.Errorf("invalid json: %v", m) + 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) } - m = backend.JSON{} - if err := json.Unmarshal([]byte(q.Value), &m); err != nil { - t.Errorf("no error: %v", err) - } - if len(m.ModTime) < 20 || m.Data != "tedst" { - t.Errorf("invalid json: %v", m) + 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) } - m = backend.JSON{} - if err := json.Unmarshal([]byte(q.Value), &m); err != nil { - t.Errorf("no error: %v", err) - } - if len(m.ModTime) < 20 || m.Data != "" { - t.Errorf("invalid json: %v", m) + if !compareEntity(q, backend.Entity{ + Path: "test/test/abc", + Values: map[string]string{ + "notes": "", + "password": "", + }, + }) { + t.Errorf("invalid entity: %v", q) } } @@ -186,14 +238,6 @@ func TestQueryCallback(t *testing.T) { 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.SuffixMode, Criteria: "c"}) - 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/abc" { - 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) @@ -227,37 +271,36 @@ func TestSetModTime(t *testing.T) { testDateTime := "2022-12-30T12:34:56-05:00" tr := fullSetup(t, false) store.SetString("LOCKBOX_DEFAULTS_MODTIME", testDateTime) - tr.Insert("test/xyz", "test") + 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) } - m := backend.JSON{} - if err := json.Unmarshal([]byte(q.Value), &m); err != nil { - t.Errorf("no error: %v", err) - } - if m.ModTime != testDateTime { - t.Errorf("invalid date/time") + 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", "test") + 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) } - m = backend.JSON{} - if err := json.Unmarshal([]byte(q.Value), &m); err != nil { - t.Errorf("no error: %v", err) - } - if m.ModTime == testDateTime { - t.Errorf("invalid date/time") + + 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", "test") + 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) } @@ -266,90 +309,68 @@ func TestSetModTime(t *testing.T) { func TestAttributeModes(t *testing.T) { store.Clear() setupInserts(t) - fullSetup(t, true).Insert("test/test/totp", "atest") + 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 q.Value != "" { - t.Errorf("invalid result value: %s", q.Value) + 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) } - m := backend.JSON{} - if err := json.Unmarshal([]byte(q.Value), &m); err != nil { - t.Errorf("no error: %v", err) - } - if m.Data != "d39e1526f86e69f9ff1f443f5a575b5ce516b66746773997fa11bd4aefb5facd0a6fa79d0b970cad3af342ff5a63f4df1a05ef110573631d84f174b7b1d17c63" { - t.Errorf("invalid result value: %s", q.Value) - } - if m.Attributes == nil || len(m.Attributes) != 1 { - t.Errorf("invalid result value: %v", m.Attributes) - } - val, ok := m.Attributes["otp"] - if !ok || val != "7f8fd0e1a714f63da75206748d0ea1dd601fc8f92498bc87c9579b403c3004a0eefdd7ead976f7dbd6e5143c9aa7a569e24322d870ec7745a4605a154557458e" { - t.Errorf("invalid result value: %v", m.Attributes) - } - if len(m.ModTime) < 20 { - t.Errorf("invalid date/time") + 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) } - m = backend.JSON{} - if err := json.Unmarshal([]byte(q.Value), &m); err != nil { - t.Errorf("no error: %v", err) - } - if m.Data != "d39e1526f8" { - t.Errorf("invalid result value: %s", q.Value) - } - if m.Attributes == nil || len(m.Attributes) != 1 { - t.Errorf("invalid result value: %v", m.Attributes) - } - val, ok = m.Attributes["otp"] - if !ok || val != "7f8fd0e1a7" { - t.Errorf("invalid result value: %v", m.Attributes) + 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) } - m = backend.JSON{} - if err := json.Unmarshal([]byte(q.Value), &m); err != nil { - t.Errorf("no error: %v", err) - } - if len(m.ModTime) < 20 || m.Data != "atest" { - t.Errorf("invalid json: %v", m) - } - if m.Attributes == nil || len(m.Attributes) != 1 { - t.Errorf("invalid result value: %v", m.Attributes) - } - val, ok = m.Attributes["otp"] - if !ok || !strings.Contains(val, "otpauth://") { - t.Errorf("invalid result value: %v", m.Attributes) + 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) } - m = backend.JSON{} - if err := json.Unmarshal([]byte(q.Value), &m); err != nil { - t.Errorf("no error: %v", err) - } - if len(m.ModTime) < 20 || m.Data != "" { - t.Errorf("invalid json: %v", m) - } - if m.Attributes == nil || len(m.Attributes) != 1 { - t.Errorf("invalid result value: %v", m.Attributes) - } - val, ok = m.Attributes["otp"] - if !ok || val != "" { - t.Errorf("invalid result value: %v", m.Attributes) + if !compareEntity(q, backend.Entity{ + Path: "test/test/totp", + Values: map[string]string{ + "otp": "", + }, + }) { + t.Errorf("invalid entity: %v", q) } } diff --git a/internal/config/toml_test.go b/internal/config/toml_test.go @@ -251,7 +251,7 @@ func TestDefaultTOMLToLoadFile(t *testing.T) { if err := config.LoadConfigFile(file); err != nil { t.Errorf("invalid error: %v", err) } - if len(store.List()) != 28 { + if len(store.List()) != 27 { t.Errorf("invalid environment after load") } } diff --git a/internal/config/vars.go b/internal/config/vars.go @@ -94,18 +94,6 @@ var ( }), short: "max totp time", }) - // EnvTOTPEntry is the leaf token to use to store TOTP tokens - EnvTOTPEntry = environmentRegister(EnvironmentString{ - environmentStrings: environmentStrings{ - environmentDefault: newDefaultedEnvironment("totp", - environmentBase{ - key: totpCategory + "ENTRY", - description: "Entry name to store TOTP tokens within the database.", - }), - allowed: []string{"<string>"}, - flags: []stringsFlags{canDefaultFlag}, - }, - }) // EnvPlatform is the platform that the application is running on EnvPlatform = environmentRegister(EnvironmentString{ environmentStrings: environmentStrings{ diff --git a/internal/config/vars_test.go b/internal/config/vars_test.go @@ -56,17 +56,6 @@ func TestIsNoGeneratePassword(t *testing.T) { checkYesNo("LOCKBOX_PWGEN_ENABLED", t, config.EnvPasswordGenEnabled, true) } -func TestTOTP(t *testing.T) { - store.Clear() - if config.EnvTOTPEntry.Get() != "totp" { - t.Error("invalid totp token field") - } - store.SetString("LOCKBOX_TOTP_ENTRY", "abc") - if config.EnvTOTPEntry.Get() != "abc" { - t.Error("invalid totp token field") - } -} - func TestFormatTOTP(t *testing.T) { store.Clear() otp := config.EnvTOTPFormat.Get("otpauth://abc") @@ -171,7 +160,6 @@ func TestUnsetArrays(t *testing.T) { func TestDefaultStrings(t *testing.T) { store.Clear() for k, v := range map[string]config.EnvironmentString{ - "totp": config.EnvTOTPEntry, "hash": config.EnvJSONMode, "command": config.EnvPasswordMode, "{{range $i, $val := .}}{{if $i}}-{{end}}{{$val.Text}}{{end}}": config.EnvPasswordGenTemplate,