lockbox

password manager
Log | Files | Refs | README | LICENSE

commit 6012fc032ed53ea673f849b75e989a463525f672
parent 499f925a068f741f39d49f8a626885752a32c547
Author: Sean Enck <sean@ttypty.com>
Date:   Sat,  5 Oct 2024 10:42:10 -0400

support a simplistic pwgen command

Diffstat:
Mcmd/main.go | 2++
Mgo.mod | 1+
Mgo.sum | 2++
Minternal/app/core.go | 3+++
Minternal/app/core_test.go | 4++--
Ainternal/app/pwgen.go | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/pwgen_test.go | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/config/core.go | 1+
Minternal/config/vars.go | 37++++++++++++++++++++++++++-----------
Minternal/config/vars_test.go | 10+++++++++-
10 files changed, 239 insertions(+), 14 deletions(-)

diff --git a/cmd/main.go b/cmd/main.go @@ -126,6 +126,8 @@ func run() error { return app.Insert(p, app.TOTPInsert) } return args.Do(app.NewDefaultTOTPOptions(p)) + case app.PasswordGenerateCommand: + return app.GeneratePassword(p) default: return fmt.Errorf("unknown command: %s", command) } diff --git a/go.mod b/go.mod @@ -7,6 +7,7 @@ require ( github.com/hashicorp/go-envparse v0.1.0 github.com/pquerna/otp v1.4.0 github.com/tobischo/gokeepasslib/v3 v3.6.0 + golang.org/x/text v0.19.0 mvdan.cc/sh/v3 v3.9.0 ) diff --git a/go.sum b/go.sum @@ -36,6 +36,8 @@ golang.org/x/exp v0.0.0-20230105202349-8879d0199aa3 h1:fJwx88sMf5RXwDwziL0/Mn9Wq golang.org/x/exp v0.0.0-20230105202349-8879d0199aa3/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= mvdan.cc/sh/v3 v3.9.0 h1:it14fyjCdQUk4jf/aYxLO3FG8jFarR9GzMCtnlvvD7c= diff --git a/internal/app/core.go b/internal/app/core.go @@ -75,6 +75,8 @@ const ( CompletionsFishCommand = "fish" docDir = "doc" textFile = ".txt" + // PasswordGenerateCommand is the command to do password generation + PasswordGenerateCommand = "pwgen" ) var ( @@ -215,6 +217,7 @@ func Usage(verbose bool, exe string) ([]string, error) { results = append(results, command(ListCommand, "", "list entries")) results = append(results, command(MoveCommand, "src dst", "move an entry from source to destination")) results = append(results, command(MultiLineCommand, "entry", "insert a multiline entry into the store")) + results = append(results, command(PasswordGenerateCommand, "", "generate a password")) results = append(results, command(ReKeyCommand, "", "rekey/reinitialize the database credentials")) results = append(results, command(RemoveCommand, "entry", "remove an entry from the store")) results = append(results, command(ShowCommand, "entry", "show the entry's value")) diff --git a/internal/app/core_test.go b/internal/app/core_test.go @@ -9,11 +9,11 @@ import ( func TestUsage(t *testing.T) { u, _ := app.Usage(false, "lb") - if len(u) != 25 { + if len(u) != 26 { t.Errorf("invalid usage, out of date? %d", len(u)) } u, _ = app.Usage(true, "lb") - if len(u) != 107 { + if len(u) != 113 { 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 @@ -0,0 +1,95 @@ +package app + +import ( + "bytes" + "errors" + "fmt" + "math/rand" + "os/exec" + "strings" + "text/template" + + "github.com/seanenck/lockbox/internal/config" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +// GeneratePassword generates a password +func GeneratePassword(cmd CommandOptions) error { + length, err := config.EnvPasswordGenCount.Get() + if err != nil { + return err + } + if length < 1 { + return fmt.Errorf("word count must be >= 1") + } + tmplString := config.EnvPasswordGenTemplate.Get() + wordList, err := config.EnvPasswordGenWordList.Get() + if err != nil { + return err + } + 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:] + } + capitalize, err := config.EnvPasswordGenTitle.Get() + if err != nil { + return err + } + wordResults, err := exec.Command(exe, args...).Output() + if err != nil { + return err + } + lang, err := language.Parse(config.EnvLanguage.Get()) + if err != nil { + return err + } + caser := cases.Title(lang) + var choices []string + for _, line := range strings.Split(string(wordResults), "\n") { + t := strings.TrimSpace(line) + if t == "" { + continue + } + use := line + if capitalize { + use = caser.String(use) + } + choices = append(choices, use) + } + found := len(choices) + if found < length { + return errors.New("choices <= word count requested") + } + if found > 1 { + l := found - 1 + for i := 0; i <= l; i++ { + n := rand.Intn(l) + x := choices[i] + choices[i] = choices[n] + choices[n] = x + } + } + var selected []string + cnt := 0 + for cnt < length { + selected = append(selected, choices[cnt]) + 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 := 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 @@ -0,0 +1,98 @@ +package app_test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/seanenck/lockbox/internal/app" +) + +func setupGenScript() string { + 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) { + defer os.Clearenv() + m := newMockCommand(t) + pwgenPath := setupGenScript() + os.Setenv("LOCKBOX_PWGEN_COUNT", "0") + if err := app.GeneratePassword(m); err == nil || err.Error() != "word count must be > 0" { + t.Errorf("invalid error: %v", err) + } + os.Setenv("LOCKBOX_PWGEN_COUNT", "1") + if err := app.GeneratePassword(m); err == nil || err.Error() != "word list command must set" { + t.Errorf("invalid error: %v", err) + } + os.Setenv("LOCKBOX_PWGEN_WORDLIST", "1 x") + if err := app.GeneratePassword(m); err == nil || !strings.Contains(err.Error(), "exec: \"1\":") { + t.Errorf("invalid error: %v", err) + } + os.Setenv("LOCKBOX_PWGEN_WORDLIST", pwgenPath) + if err := app.GeneratePassword(m); err == nil || err.Error() != "choices <= word count requested" { + t.Errorf("invalid error: %v", err) + } + os.Setenv("LOCKBOX_PWGEN_WORDLIST", fmt.Sprintf("%s 1", pwgenPath)) + if err := app.GeneratePassword(m); err != nil { + t.Errorf("invalid error: %v", err) + } + os.Setenv("LOCKBOX_PWGEN_WORDLIST", fmt.Sprintf("%s aloj 1", pwgenPath)) + if err := app.GeneratePassword(m); err != nil { + 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 != expect { + t.Errorf("invalid generated: %s (expected: %s)", s, expect) + } +} + +func TestGenerate(t *testing.T) { + defer os.Clearenv() + pwgenPath := setupGenScript() + os.Setenv("LOCKBOX_PWGEN_COUNT", "1") + os.Setenv("LOCKBOX_PWGEN_WORDLIST", fmt.Sprintf("%s 1", pwgenPath)) + testPasswordGen(t, "1") + os.Setenv("LOCKBOX_PWGEN_COUNT", "10") + os.Setenv("LOCKBOX_PWGEN_WORDLIST", fmt.Sprintf("%s 1 1 1 1 1 1 1 1 1 1 1 1", pwgenPath)) + testPasswordGen(t, "1-1-1-1-1-1-1-1-1-1") + os.Setenv("LOCKBOX_PWGEN_COUNT", "4") + os.Setenv("LOCKBOX_PWGEN_TITLE", "yes") + os.Setenv("LOCKBOX_PWGEN_WORDLIST", fmt.Sprintf("%s a a a a a a a a a a a a a a a", pwgenPath)) + testPasswordGen(t, "A-A-A-A") + os.Setenv("LOCKBOX_PWGEN_TITLE", "no") + testPasswordGen(t, "a-a-a-a") + // NOTE: this allows templating below in golang + os.Setenv("DOLLAR", "$") + os.Setenv("LOCKBOX_PWGEN_TEMPLATE", "{{range ${DOLLAR}idx, ${DOLLAR}val := .}}{{if lt ${DOLLAR}idx 5}}-{{end}}{{ ${DOLLAR}val }}{{end}}") + testPasswordGen(t, "-a-a-a-a") + os.Unsetenv("LOCKBOX_PWGEN_TEMPLATE") + os.Setenv("LOCKBOX_PWGEN_TITLE", "yes") + os.Setenv("LOCKBOX_PWGEN_WORDLIST", fmt.Sprintf("%s abc axy axY aZZZ aoijafea aoiajfoea afaeoa", pwgenPath)) + 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 @@ -29,6 +29,7 @@ const ( // sub categories clipCategory keyCategory = "CLIP_" totpCategory keyCategory = "TOTP_" + genCategory keyCategory = "PWGEN_" // YesValue are yes (on) values YesValue = yes ) diff --git a/internal/config/vars.go b/internal/config/vars.go @@ -329,42 +329,57 @@ This value can NOT be an expansion itself.`, shortDesc: "max expands", allowZero: true, }) - EnvPasswordGenLength = environmentRegister( + // EnvPasswordGenCount is the number of words that will be selected for password generation + EnvPasswordGenCount = environmentRegister( EnvironmentInt{ - environmentDefault: newDefaultedEnvironment(64, + environmentDefault: newDefaultedEnvironment(8, environmentBase{ - subKey: "LENGTH", + subKey: "COUNT", cat: genCategory, - desc: "Minimum of length of the generated password. Once the number of combined words reaches this amount the password generation process will be compledted.", + desc: "Number of words to select and include in the generated password.", }), - shortDesc: "min password generation length", + shortDesc: "word count", allowZero: false, }) - EnvPasswordGenCapitalize = environmentRegister( + // EnvPasswordGenTitle indicates if titling (e.g. uppercasing) will occur to words + EnvPasswordGenTitle = environmentRegister( EnvironmentBool{ environmentDefault: newDefaultedEnvironment(true, environmentBase{ - subKey: "CAPITALIZE", + subKey: "TITLE", cat: genCategory, - desc: "Capitalize words during password generation.", + desc: "Title words during password generation.", }), }) + // EnvPasswordGenTemplate is the output template for controlling how output words are placed together EnvPasswordGenTemplate = environmentRegister( EnvironmentString{ - environmentDefault: newDefaultedEnvironment("{{range $idx, $val := .}}{{if $idx }}{{end}}{{ $val }}{{end}}", + environmentDefault: newDefaultedEnvironment("{{range $idx, $val := .}}{{if gt $idx 0}}-{{end}}{{ $val }}{{end}}", environmentBase{ subKey: "TEMPLATE", cat: genCategory, - desc: "The path to hooks to execute on actions against the database.", + desc: "The go text template to use to format the chosen words into a password.", }), allowed: []string{"<go template>"}, canDefault: true, }) + // EnvPasswordGenWordList is the command text to generate the word list EnvPasswordGenWordList = environmentRegister(EnvironmentCommand{environmentBase: environmentBase{ subKey: "WORDLIST", cat: genCategory, - desc: "Command to retrieve the word list to use for password generation.", + desc: "Command to retrieve the word list to use for password generation (must be split by newline).", }}) + // EnvLanguage is the language to use for everything + EnvLanguage = environmentRegister( + EnvironmentString{ + environmentDefault: newDefaultedEnvironment("en-US", + environmentBase{ + subKey: "LANGUAGE", + desc: "Language to run under.", + }), + allowed: []string{"<language code>"}, + canDefault: true, + }) ) // GetReKey will get the rekey environment settings diff --git a/internal/config/vars_test.go b/internal/config/vars_test.go @@ -69,6 +69,10 @@ func TestIsNoClip(t *testing.T) { checkYesNo("LOCKBOX_NOCLIP", t, config.EnvNoClip, false) } +func TestIsTitle(t *testing.T) { + checkYesNo("LOCKBOX_PWGEN_TITLE", t, config.EnvPasswordGenTitle, true) +} + func TestDefaultCompletions(t *testing.T) { checkYesNo("LOCKBOX_DEFAULT_COMPLETION", t, config.EnvDefaultCompletion, false) } @@ -97,7 +101,7 @@ func TestListVariables(t *testing.T) { known[trim] = struct{}{} } l := len(known) - if l != 26 { + if l != 31 { t.Errorf("invalid env count, outdated? %d", l) } } @@ -182,6 +186,10 @@ func TestMaxTOTP(t *testing.T) { checkInt(config.EnvMaxTOTP, "LOCKBOX_TOTP_MAX", "max totp time", 120, false, t) } +func TestWordCount(t *testing.T) { + checkInt(config.EnvPasswordGenCount, "LOCKBOX_PWGEN_COUNT", "word count", 8, false, t) +} + func checkInt(e config.EnvironmentInt, key, text string, def int, allowZero bool, t *testing.T) { os.Setenv(key, "") defer os.Clearenv()