lockbox

password manager
Log | Files | Refs | README | LICENSE

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:
Mcmd/lb/tests/expected.log | 2+-
Minternal/app/insert.go | 24++++++++++++++++++------
Minternal/app/insert_test.go | 23+++++++++++++++++++++++
Minternal/app/totp.go | 35++++++-----------------------------
Ainternal/app/totp/core.go | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/totp/core_test.go | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/config/toml_test.go | 2+-
Minternal/config/vars.go | 8++++++++
Minternal/config/vars_test.go | 4++++
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")