commit f096dc9004514cdb57e618c18e8d66afb8ea86a3
parent 5ea5428099e22d0feae96d142c6447572d0ff889
Author: Sean Enck <sean@ttypty.com>
Date: Wed, 2 Jul 2025 13:46:57 -0400
totp tokens should be checkable at insert
Diffstat:
9 files changed, 172 insertions(+), 37 deletions(-)
diff --git a/cmd/lb/tests/expected.log b/cmd/lb/tests/expected.log
@@ -4,7 +4,7 @@ setting up tests
'test3' is not an allowed field name
'' is not an allowed field name
'still' is not an allowed field name
-otp can NOT be multi-line
+Decoding of secret as base32 failed.
password can NOT be multi-line
testing5
testing5
diff --git a/internal/app/insert.go b/internal/app/insert.go
@@ -7,6 +7,8 @@ import (
"slices"
"strings"
+ "git.sr.ht/~enckse/lockbox/internal/app/totp"
+ "git.sr.ht/~enckse/lockbox/internal/config"
"git.sr.ht/~enckse/lockbox/internal/kdbx"
)
@@ -45,18 +47,28 @@ func Insert(cmd UserInputOptions) error {
if err != nil {
return fmt.Errorf("invalid input: %w", err)
}
+ if !isPipe {
+ if isPass {
+ fmt.Fprintln(cmd.Writer())
+ }
+ }
vals := make(kdbx.EntityValues)
if existing != nil {
vals = existing.Values
}
- vals[base] = strings.TrimSpace(string(password))
+ cleaned := strings.TrimSpace(string(password))
+ if config.EnvTOTPCheckOnInsert.Get() && strings.EqualFold(base, kdbx.OTPField) {
+ generator, err := totp.New(cleaned)
+ if err != nil {
+ return err
+ }
+ if _, err := generator.Code(); err != nil {
+ return err
+ }
+ }
+ vals[base] = cleaned
if err := t.Insert(dir, vals); err != nil {
return err
}
- if !isPipe {
- if isPass {
- fmt.Fprintln(cmd.Writer())
- }
- }
return nil
}
diff --git a/internal/app/insert_test.go b/internal/app/insert_test.go
@@ -7,6 +7,7 @@ import (
"testing"
"git.sr.ht/~enckse/lockbox/internal/app"
+ "git.sr.ht/~enckse/lockbox/internal/config/store"
"git.sr.ht/~enckse/lockbox/internal/kdbx"
)
@@ -170,3 +171,25 @@ func TestInsertDo(t *testing.T) {
t.Error("invalid field prompt")
}
}
+
+func TestInsertTOTP(t *testing.T) {
+ defer store.Clear()
+ m := newMockInsert(t)
+ m.pipe = func() bool {
+ return false
+ }
+ m.input = func() ([]byte, error) {
+ return []byte("t"), nil
+ }
+ m.command.buf = bytes.Buffer{}
+ m.command.args = []string{"test/test2/test1/otp"}
+ if err := app.Insert(m); err == nil || err.Error() != "Decoding of secret as base32 failed." {
+ t.Errorf("invalid error: %v", err)
+ }
+ store.SetBool("LOCKBOX_TOTP_CHECK_ON_INSERT", false)
+ m.command.buf = bytes.Buffer{}
+ m.command.args = []string{"test/test2/test1/otp"}
+ if err := app.Insert(m); err != nil {
+ t.Errorf("invalid error: %v", err)
+ }
+}
diff --git a/internal/app/totp.go b/internal/app/totp.go
@@ -9,10 +9,8 @@ import (
"strings"
"time"
- coreotp "github.com/pquerna/otp"
- otp "github.com/pquerna/otp/totp"
-
"git.sr.ht/~enckse/lockbox/internal/app/commands"
+ "git.sr.ht/~enckse/lockbox/internal/app/totp"
"git.sr.ht/~enckse/lockbox/internal/config"
"git.sr.ht/~enckse/lockbox/internal/kdbx"
"git.sr.ht/~enckse/lockbox/internal/platform"
@@ -31,10 +29,6 @@ type (
Entry string
Mode string
}
- totpWrapper struct {
- code string
- opts otp.ValidateOpts
- }
// TOTPOptions are TOTP call options
TOTPOptions struct {
app CommandOptions
@@ -62,10 +56,6 @@ func colorWhenRules() ([]config.TimeWindow, error) {
return ParseTimeWindow(envTime...)
}
-func (w totpWrapper) generateCode() (string, error) {
- return otp.GenerateCodeCustom(w.code, time.Now(), w.opts)
-}
-
func (args *TOTPArguments) display(opts TOTPOptions) error {
interactive := !slices.Contains([]string{commands.TOTPMinimal, commands.TOTPSeed, commands.TOTPURL}, args.Mode)
once := args.Mode == commands.TOTPOnce
@@ -80,31 +70,18 @@ func (args *TOTPArguments) display(opts TOTPOptions) error {
if err != nil {
return err
}
- k, err := coreotp.NewKeyFromURL(config.EnvTOTPFormat.Get(entity))
+ generator, err := totp.New(entity)
if err != nil {
return err
}
- wrapper := totpWrapper{}
- wrapper.code = k.Secret()
- wrapper.opts = otp.ValidateOpts{}
- wrapper.opts.Digits = k.Digits()
- wrapper.opts.Algorithm = k.Algorithm()
- wrapper.opts.Period = uint(k.Period())
writer := opts.app.Writer()
switch args.Mode {
- case commands.TOTPSeed:
- fmt.Fprintln(writer, wrapper.code)
- return nil
- case commands.TOTPURL:
- fmt.Fprintf(writer, "url: %s\n", k.URL())
- fmt.Fprintf(writer, "seed: %s\n", wrapper.code)
- fmt.Fprintf(writer, "digits: %s\n", wrapper.opts.Digits)
- fmt.Fprintf(writer, "algorithm: %s\n", wrapper.opts.Algorithm)
- fmt.Fprintf(writer, "period: %d\n", wrapper.opts.Period)
+ case commands.TOTPSeed, commands.TOTPURL:
+ generator.Print(writer, args.Mode == commands.TOTPURL)
return nil
}
if !interactive {
- code, err := wrapper.generateCode()
+ code, err := generator.Code()
if err != nil {
return err
}
@@ -152,7 +129,7 @@ func (args *TOTPArguments) display(opts TOTPOptions) error {
}
lastSecond = last
left := 60 - last
- code, err := wrapper.generateCode()
+ code, err := generator.Code()
if err != nil {
return err
}
diff --git a/internal/app/totp/core.go b/internal/app/totp/core.go
@@ -0,0 +1,56 @@
+// Package totp handles TOTP operations
+package totp
+
+import (
+ "fmt"
+ "io"
+ "time"
+
+ coreotp "github.com/pquerna/otp"
+ otp "github.com/pquerna/otp/totp"
+
+ "git.sr.ht/~enckse/lockbox/internal/config"
+)
+
+type (
+ // Generator is used to generate TOTP codes
+ Generator struct {
+ key *coreotp.Key
+ secret string
+ opts otp.ValidateOpts
+ }
+)
+
+// Code will generate a new code for the specified TOTP object
+func (g Generator) Code() (string, error) {
+ return otp.GenerateCodeCustom(g.secret, time.Now(), g.opts)
+}
+
+// Print will print information about the generator to the writer
+func (g Generator) Print(w io.Writer, details bool) {
+ if details {
+ fmt.Fprintf(w, "url: %s\n", g.key.URL())
+ fmt.Fprintf(w, "seed: %s\n", g.secret)
+ fmt.Fprintf(w, "digits: %s\n", g.opts.Digits)
+ fmt.Fprintf(w, "algorithm: %s\n", g.opts.Algorithm)
+ fmt.Fprintf(w, "period: %d\n", g.opts.Period)
+ return
+ }
+ fmt.Fprintln(w, g.secret)
+}
+
+// New will create a new generator
+func New(code string) (Generator, error) {
+ k, err := coreotp.NewKeyFromURL(config.EnvTOTPFormat.Get(code))
+ if err != nil {
+ return Generator{}, err
+ }
+ wrapper := Generator{}
+ wrapper.secret = k.Secret()
+ wrapper.opts = otp.ValidateOpts{}
+ wrapper.opts.Digits = k.Digits()
+ wrapper.opts.Algorithm = k.Algorithm()
+ wrapper.opts.Period = uint(k.Period())
+ wrapper.key = k
+ return wrapper, nil
+}
diff --git a/internal/app/totp/core_test.go b/internal/app/totp/core_test.go
@@ -0,0 +1,55 @@
+package totp_test
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "git.sr.ht/~enckse/lockbox/internal/app/totp"
+)
+
+func TestPrint(t *testing.T) {
+ generator, _ := totp.New("5ae472abqdekjqykoyxk7hvc2leklq5n")
+ var buf bytes.Buffer
+ generator.Print(&buf, false)
+ if strings.TrimSpace(buf.String()) != "5ae472abqdekjqykoyxk7hvc2leklq5n" {
+ t.Errorf("invalid buffer: %s", buf.String())
+ }
+ buf = bytes.Buffer{}
+ generator.Print(&buf, true)
+ count := 0
+ hasBlank := false
+ for _, line := range strings.Split(buf.String(), "\n") {
+ if line == "" {
+ if hasBlank {
+ t.Errorf("already have blank line")
+ }
+ hasBlank = true
+ continue
+ }
+ count++
+ if !strings.Contains(line, ":") {
+ t.Errorf("line missing colon: %s", line)
+ }
+ }
+ if count != 5 {
+ t.Errorf("invalid buffer: %s", buf.String())
+ }
+}
+
+func TestCode(t *testing.T) {
+ generator, _ := totp.New("5ae472abqdekjqykoyxk7hvc2leklq5n")
+ code, err := generator.Code()
+ if err != nil {
+ t.Errorf("invalid error: %v", err)
+ }
+ if len(code) != 6 {
+ t.Errorf("invalid code: %s", code)
+ }
+}
+
+func TestNew(t *testing.T) {
+ if _, err := totp.New("5ae472abqdekjqykoyxk7hvc2leklq5n"); err != nil {
+ t.Errorf("invalid error: %v", err)
+ }
+}
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()) != 15 {
+ if len(store.List()) != 16 {
t.Errorf("invalid environment after load: %d", len(store.List()))
}
}
diff --git a/internal/config/vars.go b/internal/config/vars.go
@@ -60,6 +60,14 @@ var (
}),
short: "max totp time",
})
+ // EnvTOTPCheckOnInsert will indicate if TOTP tokens should be check for validity during the insert process
+ EnvTOTPCheckOnInsert = environmentRegister(EnvironmentBool{
+ environmentDefault: newDefaultedEnvironment(true,
+ environmentBase{
+ key: totpCategory + "CHECK_ON_INSERT",
+ description: "Test TOTP code generation on insert.",
+ }),
+ })
// EnvStore is the location of the keepass file/store
EnvStore = environmentRegister(EnvironmentString{
environmentStrings: environmentStrings{
diff --git a/internal/config/vars_test.go b/internal/config/vars_test.go
@@ -40,6 +40,10 @@ func TestColorFeature(t *testing.T) {
checkYesNo("LOCKBOX_FEATURE_COLOR", t, config.EnvFeatureColor, true)
}
+func TestTOTPCheckOnInsert(t *testing.T) {
+ checkYesNo("LOCKBOX_TOTP_CHECK_ON_INSERT", t, config.EnvTOTPCheckOnInsert, true)
+}
+
func TestFormatTOTP(t *testing.T) {
store.Clear()
otp := config.EnvTOTPFormat.Get("otpauth://abc")