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:
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,