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:
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()