lockbox

password manager
Log | Files | Refs | README | LICENSE

commit 203dcc2fcbca88f70d1b6bf725461fb7f78f1567
parent 259309faacaa0b5b9924c31a8365e43729e84e29
Author: Sean Enck <sean@ttypty.com>
Date:   Sat,  7 Jun 2025 16:38:19 -0400

ultimately pwgen is out of scope

Diffstat:
MREADME.md | 13+++++--------
Mcmd/lb/main.go | 2--
Minternal/app/commands/core.go | 2--
Minternal/app/completions/core.go | 25+++++++++++--------------
Minternal/app/completions/core_test.go | 2+-
Minternal/app/help/core.go | 1-
Minternal/app/help/core_test.go | 4++--
Dinternal/app/pwgen.go | 99-------------------------------------------------------------------------------
Dinternal/app/pwgen_test.go | 106-------------------------------------------------------------------------------
Minternal/config/core.go | 1-
Minternal/config/toml_test.go | 2+-
Minternal/config/vars.go | 52----------------------------------------------------
Minternal/config/vars_test.go | 11-----------
13 files changed, 20 insertions(+), 300 deletions(-)

diff --git a/README.md b/README.md @@ -13,7 +13,7 @@ keyring or command for password input over using a GPG key and uses a keepass da While `lb` uses a `.kdbx` formatted file that can be opened by a variety of tools, it is highly opinionated on how to store data in the database. Any `.kdbx` used with `lb` should be managed by `lb` with a fallback ability to use other tools to alter the/view the file otherwise. Mainly lockbox itself is using a common format so that it doesn't lock a user into a custom file format or dealing with gpg, age, etc. files and instead COULD be recovered -via other tooling if needed. +via other tooling if needed. `lb` does try to place nice with standard fields used within kdbx files, but it may disagree how to manage/store/update them. ## configuration @@ -54,10 +54,7 @@ lb clip my/secret/password Create a new entry ``` -lb insert my/new/key -# or -lb multiline my/new/multi -# for multiline inserts +lb insert my/new/key/password ``` ### list @@ -78,19 +75,19 @@ lb rm my/old/key To see the text of an entry ``` -lb show my/key/value +lb show my/key/notes ``` ### totp To get a totp token ``` -lb totp show token +lb totp show token/path/otp ``` The token can be automatically copied to the clipboard too ``` -lb totp clip token +lb totp clip token/path/otp ``` ### rekey diff --git a/cmd/lb/main.go b/cmd/lb/main.go @@ -104,8 +104,6 @@ func run() error { return err } return args.Do(app.NewDefaultTOTPOptions(p)) - case commands.PasswordGenerate: - return app.GeneratePassword(p) default: return fmt.Errorf("unknown command: %s", command) } diff --git a/internal/app/commands/core.go b/internal/app/commands/core.go @@ -50,8 +50,6 @@ const ( JSON = "json" // CompletionsZsh is the command to generate zsh completions CompletionsZsh = "zsh" - // PasswordGenerate is the command to do password generation - PasswordGenerate = "pwgen" // Executable is the name of the executable Executable = "lb" // Unset indicates a value should be unset (removed) from an entity diff --git a/internal/app/completions/core.go b/internal/app/completions/core.go @@ -42,12 +42,11 @@ type ( // Conditionals help control completion flow Conditionals struct { Not struct { - ReadOnly string - CanClip string - CanTOTP string - AskMode string - Ever string - CanPasswordGen string + ReadOnly string + CanClip string + CanTOTP string + AskMode string + Ever string } Exported []string } @@ -91,7 +90,6 @@ func NewConditionals() Conditionals { c.Not.CanClip = registerIsNotEqual(config.EnvClipEnabled, config.NoValue) c.Not.CanTOTP = registerIsNotEqual(config.EnvTOTPEnabled, config.NoValue) c.Not.AskMode = registerIsNotEqual(config.EnvPasswordMode, string(config.AskKeyMode)) - c.Not.CanPasswordGen = registerIsNotEqual(config.EnvPasswordGenEnabled, config.NoValue) c.Not.Ever = fmt.Sprintf(shellIsNotText, "1", "0") return c } @@ -124,13 +122,12 @@ func Generate(completionType, exe string) ([]string, error) { c.Options = c.newGenOptions([]string{commands.Help, commands.List, commands.Show, commands.Version, commands.JSON, 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.Unset: c.Conditionals.Not.ReadOnly, - commands.PasswordGenerate: c.Conditionals.Not.CanPasswordGen, + 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.Unset: c.Conditionals.Not.ReadOnly, }) c.TOTPSubCommands = c.newGenOptions([]string{commands.TOTPMinimal, commands.TOTPOnce, commands.TOTPShow}, map[string]string{ diff --git a/internal/app/completions/core_test.go b/internal/app/completions/core_test.go @@ -23,7 +23,7 @@ func TestCompletions(t *testing.T) { func TestConditionals(t *testing.T) { c := completions.NewConditionals() sort.Strings(c.Exported) - need := []string{"LOCKBOX_CLIP_ENABLED", "LOCKBOX_CREDENTIALS_PASSWORD_MODE", "LOCKBOX_PWGEN_ENABLED", "LOCKBOX_READONLY", "LOCKBOX_TOTP_ENABLED"} + need := []string{"LOCKBOX_CLIP_ENABLED", "LOCKBOX_CREDENTIALS_PASSWORD_MODE", "LOCKBOX_READONLY", "LOCKBOX_TOTP_ENABLED"} if len(c.Exported) != len(need) || fmt.Sprintf("%v", c.Exported) != fmt.Sprintf("%v", need) { t.Errorf("invalid exports: %v", c.Exported) } diff --git a/internal/app/help/core.go b/internal/app/help/core.go @@ -84,7 +84,6 @@ func Usage(verbose bool, exe string) ([]string, error) { results = append(results, command(commands.List, isFilter, "list entries")) results = append(results, command(commands.Groups, isFilter, "list groups")) results = append(results, command(commands.Move, "src dst", "move a group from source to destination")) - 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, "group", "remove an entry from the store")) results = append(results, command(commands.Show, isEntry, "show the entry's value")) diff --git a/internal/app/help/core_test.go b/internal/app/help/core_test.go @@ -9,11 +9,11 @@ import ( func TestUsage(t *testing.T) { u, _ := help.Usage(false, "lb") - if len(u) != 26 { + if len(u) != 25 { t.Errorf("invalid usage, out of date? %d", len(u)) } u, _ = help.Usage(true, "lb") - if len(u) != 90 { + if len(u) != 89 { t.Errorf("invalid verbose usage, out of date? %d", len(u)) } for _, usage := range u { diff --git a/internal/app/pwgen.go b/internal/app/pwgen.go @@ -1,99 +0,0 @@ -// Package app can generate passwords -package app - -import ( - "bytes" - "errors" - "fmt" - "math/rand" - "os/exec" - "slices" - "strings" - "text/template" - - "git.sr.ht/~enckse/lockbox/internal/config" -) - -// GeneratePassword generates a password -func GeneratePassword(cmd CommandOptions) error { - enabled := config.EnvPasswordGenEnabled.Get() - if !enabled { - return errors.New("password generation is disabled") - } - length, err := config.EnvPasswordGenWordCount.Get() - if err != nil { - return err - } - tmplString := config.EnvPasswordGenTemplate.Get() - wordList := config.EnvPasswordGenWordList.Get() - if len(wordList) == 0 { - return errors.New("word list command must set") - } - exe := wordList[0] - var args []string - if len(wordList) > 1 { - args = wordList[1:] - } - wordResults, err := exec.Command(exe, args...).Output() - if err != nil { - return err - } - chars := config.EnvPasswordGenChars.Get() - hasChars := len(chars) > 0 - var allowedChars []rune - if hasChars { - for _, c := range chars { - allowedChars = append(allowedChars, c) - } - } - var choices []string - for _, line := range strings.Split(string(wordResults), "\n") { - t := strings.TrimSpace(line) - if t == "" { - continue - } - use := line - if hasChars { - res := "" - for _, c := range use { - if slices.Contains(allowedChars, c) { - res = fmt.Sprintf("%s%c", res, c) - } - } - if res == "" { - continue - } - use = res - } - choices = append(choices, use) - } - found := len(choices) - if found == 0 { - return errors.New("no sources given") - } - var selected []config.Word - var cnt int64 - totalLength := 0 - for cnt < length { - choice := choices[rand.Intn(found)] - textLength := len(choice) - selected = append(selected, config.Word{Text: choice, Position: config.Position{Start: totalLength, End: totalLength + textLength}}) - totalLength += textLength - cnt++ - } - tmpl, err := template.New("t").Parse(tmplString) - if err != nil { - return err - } - var buf bytes.Buffer - if err := tmpl.Execute(&buf, selected); err != nil { - return err - } - if _, err := buf.WriteString("\n"); err != nil { - return err - } - if _, err := cmd.Writer().Write(buf.Bytes()); err != nil { - return err - } - return nil -} diff --git a/internal/app/pwgen_test.go b/internal/app/pwgen_test.go @@ -1,106 +0,0 @@ -package app_test - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "testing" - - "git.sr.ht/~enckse/lockbox/internal/app" - "git.sr.ht/~enckse/lockbox/internal/config/store" -) - -func setupGenScript() string { - store.Clear() - os.Clearenv() - const pwgenScript = "pwgen.sh" - pwgenPath := filepath.Join("testdata", pwgenScript) - os.WriteFile(pwgenPath, []byte(`#!/bin/sh -for f in $@; do - echo $f -done -`), 0o755) - return pwgenPath -} - -func TestGenerateError(t *testing.T) { - m := newMockCommand(t) - pwgenPath := setupGenScript() - store.SetInt64("LOCKBOX_PWGEN_WORD_COUNT", 0) - if err := app.GeneratePassword(m); err == nil || err.Error() != "word count must be > 0" { - t.Errorf("invalid error: %v", err) - } - store.SetInt64("LOCKBOX_PWGEN_WORD_COUNT", 1) - if err := app.GeneratePassword(m); err == nil || err.Error() != "word list command must set" { - t.Errorf("invalid error: %v", err) - } - store.SetArray("LOCKBOX_PWGEN_WORD_COMMAND", []string{"1 x"}) - if err := app.GeneratePassword(m); err == nil || !strings.Contains(err.Error(), "exec: \"1 x\":") { - t.Errorf("invalid error: %v", err) - } - store.SetArray("LOCKBOX_PWGEN_WORD_COMMAND", []string{pwgenPath}) - if err := app.GeneratePassword(m); err == nil || err.Error() != "no sources given" { - t.Errorf("invalid error: %v", err) - } - store.SetArray("LOCKBOX_PWGEN_WORD_COMMAND", []string{pwgenPath, "1"}) - if err := app.GeneratePassword(m); err != nil { - t.Errorf("invalid error: %v", err) - } - store.SetArray("LOCKBOX_PWGEN_WORD_COMMAND", []string{pwgenPath, "aloj", "1"}) - if err := app.GeneratePassword(m); err != nil { - t.Errorf("invalid error: %v", err) - } - store.SetBool("LOCKBOX_PWGEN_ENABLED", false) - if err := app.GeneratePassword(m); err == nil || err.Error() != "password generation is disabled" { - t.Errorf("invalid error: %v", err) - } -} - -func testPasswordGen(t *testing.T, expect string) { - m := newMockCommand(t) - if err := app.GeneratePassword(m); err != nil { - t.Errorf("invalid error: %v", err) - } - s := m.buf.String() - if s != fmt.Sprintf("%s\n", expect) { - t.Errorf("invalid generated: %s (expected: %s)", s, expect) - } -} - -func TestGenerate(t *testing.T) { - pwgenPath := setupGenScript() - store.SetInt64("LOCKBOX_PWGEN_WORD_COUNT", 1) - store.SetArray("LOCKBOX_PWGEN_WORD_COMMAND", []string{pwgenPath, "1"}) - testPasswordGen(t, "1") - store.SetInt64("LOCKBOX_PWGEN_WORD_COUNT", 10) - store.SetArray("LOCKBOX_PWGEN_WORD_COMMAND", []string{pwgenPath, "1 1 1 1 1 1 1 1 1 1 1 1"}) - testPasswordGen(t, "1-1-1-1-1-1-1-1-1-1") - store.SetInt64("LOCKBOX_PWGEN_WORD_COUNT", 4) - store.SetArray("LOCKBOX_PWGEN_WORD_COMMAND", []string{pwgenPath, "a a a a a a a a a a a a a a a a a a a a a a"}) - testPasswordGen(t, "a-a-a-a") - store.SetString("LOCKBOX_PWGEN_CHARACTERS", "bc") - store.SetArray("LOCKBOX_PWGEN_WORD_COMMAND", []string{pwgenPath, "abc abc abc abc abc abc aaa aa aaa a"}) - testPasswordGen(t, "bc-bc-bc-bc") - store.SetString("LOCKBOX_PWGEN_CHARACTERS", "") - store.SetArray("LOCKBOX_PWGEN_WORD_COMMAND", []string{pwgenPath, "a a a a a a a a a a a a a a a a a a a a a a"}) - testPasswordGen(t, "a-a-a-a") - // NOTE: this allows templating below in golang - store.SetString("LOCKBOX_PWGEN_TEMPLATE", "{{range $idx, $val := .}}{{if lt $idx 5}}-{{end}}{{ $val.Text }}{{ $val.Position.Start }}{{ $val.Position.End }}{{end}}") - testPasswordGen(t, "-a01-a12-a23-a34") - store.SetString("LOCKBOX_PWGEN_TEMPLATE", "{{range $idx, $val := .}}{{if lt $idx 5}}-{{end}}{{ $val.Text }}{{end}}") - testPasswordGen(t, "-a-a-a-a") - store.Clear() - store.SetArray("LOCKBOX_PWGEN_WORD_COMMAND", []string{pwgenPath, "abc axy axY aZZZ aoijafea aoiajfoea afeafa"}) - m := newMockCommand(t) - if err := app.GeneratePassword(m); err != nil { - t.Errorf("invalid error: %v", err) - } - s := m.buf.String() - if s[0] != 'a' { - t.Errorf("no title: %s", s) - } - if len(s) < 5 { - t.Errorf("bad result: %s", s) - } -} diff --git a/internal/config/core.go b/internal/config/core.go @@ -18,7 +18,6 @@ const ( // sub categories clipCategory = "CLIP_" totpCategory = "TOTP_" - genCategory = "PWGEN_" jsonCategory = "JSON_" credsCategory = "CREDENTIALS_" defaultCategory = "DEFAULTS_" 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()) != 25 { + if len(store.List()) != 20 { t.Errorf("invalid environment after load") } } diff --git a/internal/config/vars.go b/internal/config/vars.go @@ -217,56 +217,4 @@ Set to '%s' to ignore the set key value`, noKeyMode, IgnoreKeyMode), flags: []stringsFlags{canExpandFlag}, }, }) - // EnvPasswordGenWordCount is the number of words that will be selected for password generation - EnvPasswordGenWordCount = environmentRegister(EnvironmentInt{ - environmentDefault: newDefaultedEnvironment(8, - environmentBase{ - key: genCategory + "WORD_COUNT", - description: "Number of words to select and include in the generated password.", - }), - short: "word count", - }) - // EnvPasswordGenTemplate is the output template for controlling how output words are placed together - EnvPasswordGenTemplate = environmentRegister(EnvironmentString{ - environmentStrings: environmentStrings{ - environmentDefault: newDefaultedEnvironment("{{range $i, $val := .}}{{if $i}}-{{end}}{{$val.Text}}{{end}}", - environmentBase{ - key: genCategory + "TEMPLATE", - description: fmt.Sprintf("The go text template to use to format the chosen words into a password. Available fields: %s.", TextPositionFields()), - }), - allowed: []string{"<go template>"}, - flags: []stringsFlags{canDefaultFlag}, - }, - }) - // EnvPasswordGenWordList is the command text to generate the word list - EnvPasswordGenWordList = environmentRegister(EnvironmentArray{ - environmentStrings: environmentStrings{ - environmentDefault: newDefaultedEnvironment("", - environmentBase{ - key: genCategory + "WORD_COMMAND", - description: "Command to retrieve the word list to use for password generation (must be split by newline).", - }), - flags: []stringsFlags{isCommandFlag}, - }, - }) - // EnvPasswordGenEnabled indicates if password generation is enabled - EnvPasswordGenEnabled = environmentRegister(EnvironmentBool{ - environmentDefault: newDefaultedEnvironment(true, - environmentBase{ - key: genCategory + "ENABLED", - description: "Enable password generation.", - }), - }) - // EnvPasswordGenChars allows for restricting which characters can be used - EnvPasswordGenChars = environmentRegister(EnvironmentString{ - environmentStrings: environmentStrings{ - environmentDefault: newDefaultedEnvironment("", - environmentBase{ - key: genCategory + "CHARACTERS", - description: "The set of allowed characters in output words (empty means any character is allowed).", - }), - allowed: []string{"<list of characters>"}, - flags: []stringsFlags{canDefaultFlag}, - }, - }) ) diff --git a/internal/config/vars_test.go b/internal/config/vars_test.go @@ -48,10 +48,6 @@ func TestIsNoClip(t *testing.T) { checkYesNo("LOCKBOX_CLIP_ENABLED", t, config.EnvClipEnabled, true) } -func TestIsNoGeneratePassword(t *testing.T) { - checkYesNo("LOCKBOX_PWGEN_ENABLED", t, config.EnvPasswordGenEnabled, true) -} - func TestFormatTOTP(t *testing.T) { store.Clear() otp := config.EnvTOTPFormat.Get("otpauth://abc") @@ -85,10 +81,6 @@ func TestMaxTOTP(t *testing.T) { checkInt(config.EnvTOTPTimeout, "LOCKBOX_TOTP_TIMEOUT", "max totp time", 120, false, t) } -func TestWordCount(t *testing.T) { - checkInt(config.EnvPasswordGenWordCount, "LOCKBOX_PWGEN_WORD_COUNT", "word count", 8, false, t) -} - func checkInt(e config.EnvironmentInt, key, text string, def int64, allowZero bool, t *testing.T) { store.Clear() val, err := e.Get() @@ -139,7 +131,6 @@ func TestUnsetArrays(t *testing.T) { for _, i := range []config.EnvironmentArray{ config.EnvClipCopy, config.EnvClipPaste, - config.EnvPasswordGenWordList, } { val := i.Get() if len(val) != 0 { @@ -158,7 +149,6 @@ func TestDefaultStrings(t *testing.T) { for k, v := range map[string]config.EnvironmentString{ "hash": config.EnvJSONMode, "command": config.EnvPasswordMode, - "{{range $i, $val := .}}{{if $i}}-{{end}}{{$val.Text}}{{end}}": config.EnvPasswordGenTemplate, } { val := v.Get() if val != k { @@ -179,7 +169,6 @@ func TestEmptyStrings(t *testing.T) { config.EnvStore, config.EnvKeyFile, config.EnvDefaultModTime, - config.EnvPasswordGenChars, } { val := v.Get() if val != "" {