lockbox

password manager
Log | Files | Refs | README | LICENSE

commit 47df6990207e344efbf5971c79a5900dc1be24fd
parent 148519703b7c95fa4de47289d701d57fa581fec0
Author: Sean Enck <sean@ttypty.com>
Date:   Mon, 27 Mar 2023 21:16:45 -0400

totp tests

Diffstat:
Mcmd/main.go | 14+++++++++++++-
Minternal/cli/core_test.go | 2+-
Minternal/inputs/env.go | 8++++++--
Minternal/inputs/env_test.go | 2+-
Minternal/totp/core.go | 75+++++++++++++++++++++++++++++++++++++++++++++------------------------------
Minternal/totp/core_test.go | 199++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
6 files changed, 264 insertions(+), 36 deletions(-)

diff --git a/cmd/main.go b/cmd/main.go @@ -95,7 +95,11 @@ func run() error { p.SetArgs(args.Entry) return app.Insert(p, app.TOTPInsert) } - return args.Do(p.Transaction()) + opts := totp.Options{App: p} + opts.Clear = clear + opts.IsNoTOTP = inputs.IsNoTOTP + opts.IsInteractive = inputs.IsInteractive + return args.Do(opts) default: return fmt.Errorf("unknown command: %s", command) } @@ -130,3 +134,11 @@ func clearClipboard() error { } return clipboard.CopyTo("") } + +func clear() { + cmd := exec.Command("clear") + cmd.Stdout = os.Stdout + if err := cmd.Run(); err != nil { + fmt.Printf("unable to clear screen: %v\n", err) + } +} diff --git a/internal/cli/core_test.go b/internal/cli/core_test.go @@ -14,7 +14,7 @@ func TestUsage(t *testing.T) { t.Errorf("invalid usage, out of date? %d", len(u)) } u, _ = cli.Usage(true) - if len(u) != 81 { + if len(u) != 82 { t.Errorf("invalid verbose usage, out of date? %d", len(u)) } for _, usage := range u { diff --git a/internal/inputs/env.go b/internal/inputs/env.go @@ -38,6 +38,8 @@ const ( clipMaxEnv = clipBaseEnv + "MAX" // ColorBetweenEnv is a comma-delimited list of times to color totp outputs (e.g. 0:5,30:35 which is the default). ColorBetweenEnv = fieldTOTPEnv + "_BETWEEN" + // MaxTOTPTime indicate how long TOTP tokens will be shown + MaxTOTPTime = fieldTOTPEnv + "_MAX" // ClipPasteEnv allows overriding the clipboard paste command ClipPasteEnv = clipBaseEnv + "PASTE" // ClipCopyEnv allows overriding the clipboard copy command @@ -65,8 +67,9 @@ const ( // ModTimeEnv is modtime override ability for entries ModTimeEnv = prefixKey + "SET_MODTIME" // ModTimeFormat is the expected modtime format - ModTimeFormat = time.RFC3339 - reKeySuffix = "_NEW" + ModTimeFormat = time.RFC3339 + reKeySuffix = "_NEW" + MaxTOTPTimeDefault = "120" ) var ( @@ -337,6 +340,7 @@ func ListEnvironmentVariables(showValues bool) []string { results = append(results, e.formatEnvironmentVariable(false, readOnlyEnv, isNo, "operate in readonly mode", isYesNoArgs)) results = append(results, e.formatEnvironmentVariable(false, fieldTOTPEnv, defaultTOTPField, "attribute name to store TOTP tokens within the database", []string{"string"})) results = append(results, e.formatEnvironmentVariable(false, formatTOTPEnv, strings.ReplaceAll(strings.ReplaceAll(FormatTOTP("%s"), "%25s", "%s"), "&", " \\\n &"), "override the otpauth url used to store totp tokens. It must have ONE format\nstring ('%s') to insert the totp base code", []string{"otpauth//url/%s/args..."})) + results = append(results, e.formatEnvironmentVariable(false, MaxTOTPTime, MaxTOTPTimeDefault, "time, in seconds, in which to show a TOTP token before automatically exiting", []string{"integer"})) results = append(results, e.formatEnvironmentVariable(false, ColorBetweenEnv, TOTPDefaultBetween, "override when to set totp generated outputs to different colors, must be a\nlist of one (or more) rules where a semicolon delimits the start and end\nsecond (0-60 for each)", []string{"start:end,start:end,start:end..."})) results = append(results, e.formatEnvironmentVariable(false, ClipPasteEnv, detectedValue, "override the detected platform paste command", []string{commandArgsExample})) results = append(results, e.formatEnvironmentVariable(false, ClipCopyEnv, detectedValue, "override the detected platform copy command", []string{commandArgsExample})) diff --git a/internal/inputs/env_test.go b/internal/inputs/env_test.go @@ -166,7 +166,7 @@ func TestListVariables(t *testing.T) { known[trim] = struct{}{} } l := len(known) - if l != 19 { + if l != 20 { t.Errorf("invalid env count, outdated? %d", l) } } diff --git a/internal/totp/core.go b/internal/totp/core.go @@ -4,11 +4,11 @@ package totp import ( "errors" "fmt" - "os" - "os/exec" + "strconv" "strings" "time" + "github.com/enckse/lockbox/internal/app" "github.com/enckse/lockbox/internal/backend" "github.com/enckse/lockbox/internal/cli" "github.com/enckse/lockbox/internal/colors" @@ -18,8 +18,12 @@ import ( otp "github.com/pquerna/otp/totp" ) -// ErrNoTOTP is used when TOTP is requested BUT is disabled -var ErrNoTOTP = errors.New("TOTP is disabled") +var ( + // ErrNoTOTP is used when TOTP is requested BUT is disabled + ErrNoTOTP = errors.New("totp is disabled") + // ErrUnknownTOTPMode indicates an unknown totp argument type + ErrUnknownTOTPMode = errors.New("unknown totp mode") +) type ( // Mode is the operating mode for TOTP operations @@ -28,11 +32,19 @@ type ( Arguments struct { Mode Mode Entry string + token string } totpWrapper struct { opts otp.ValidateOpts code string } + // Options are TOTP call options + Options struct { + App app.CommandOptions + Clear func() + IsNoTOTP func() (bool, error) + IsInteractive func() (bool, error) + } ) const ( @@ -52,14 +64,6 @@ const ( OnceMode ) -func clear() { - cmd := exec.Command("clear") - cmd.Stdout = os.Stdout - if err := cmd.Run(); err != nil { - fmt.Printf("unable to clear screen: %v\n", err) - } -} - func colorWhenRules() ([]inputs.ColorWindow, error) { envTime := inputs.EnvOrDefault(inputs.ColorBetweenEnv, inputs.TOTPDefaultBetween) if envTime == inputs.TOTPDefaultBetween { @@ -72,8 +76,8 @@ func (w totpWrapper) generateCode() (string, error) { return otp.GenerateCodeCustom(w.code, time.Now(), w.opts) } -func (args *Arguments) display(tx *backend.Transaction) error { - interactive, err := inputs.IsInteractive() +func (args *Arguments) display(opts Options) error { + interactive, err := opts.IsInteractive() if err != nil { return err } @@ -89,7 +93,7 @@ func (args *Arguments) display(tx *backend.Transaction) error { if err != nil { return err } - entity, err := tx.Get(backend.NewPath(args.Entry, inputs.TOTPToken()), backend.SecretValue) + entity, err := opts.App.Transaction().Get(backend.NewPath(args.Entry, args.token), backend.SecretValue) if err != nil { return err } @@ -107,12 +111,13 @@ func (args *Arguments) display(tx *backend.Transaction) error { wrapper.opts.Digits = k.Digits() wrapper.opts.Algorithm = k.Algorithm() wrapper.opts.Period = uint(k.Period()) + writer := opts.App.Writer() if !interactive { code, err := wrapper.generateCode() if err != nil { return err } - fmt.Println(code) + fmt.Fprintf(writer, "%s\n", code) return nil } first := true @@ -120,7 +125,7 @@ func (args *Arguments) display(tx *backend.Transaction) error { lastSecond := -1 if !clip { if !once { - clear() + opts.Clear() } } clipboard := platform.Clipboard{} @@ -134,14 +139,19 @@ func (args *Arguments) display(tx *backend.Transaction) error { if err != nil { return err } + runString := inputs.EnvOrDefault(inputs.MaxTOTPTime, inputs.MaxTOTPTimeDefault) + runFor, err := strconv.Atoi(runString) + if err != nil { + return err + } for { if !first { time.Sleep(500 * time.Millisecond) } first = false running++ - if running > 120 { - fmt.Println("exiting (timeout)") + if running > runFor { + fmt.Fprint(writer, "exiting (timeout)\n") return nil } now := time.Now() @@ -175,13 +185,13 @@ func (args *Arguments) display(tx *backend.Transaction) error { outputs = append(outputs, "-> CTRL+C to exit") } } else { - fmt.Printf("-> %s\n", expires) + fmt.Fprintf(writer, "-> %s\n", expires) return clipboard.CopyTo(code) } if !once { - clear() + opts.Clear() } - fmt.Printf("%s\n", strings.Join(outputs, "\n\n")) + fmt.Fprintf(writer, "%s\n", strings.Join(outputs, "\n\n")) if once { return nil } @@ -189,11 +199,14 @@ func (args *Arguments) display(tx *backend.Transaction) error { } // Do will perform the TOTP operation -func (args *Arguments) Do(tx *backend.Transaction) error { - if args == nil || args.Mode == UnknownMode { - return errors.New("unknown totp mode") +func (args *Arguments) Do(opts Options) error { + if args.Mode == UnknownMode { + return ErrUnknownTOTPMode + } + if opts.Clear == nil || opts.IsNoTOTP == nil || opts.IsInteractive == nil { + return errors.New("invalid option functions") } - off, err := inputs.IsNoTOTP() + off, err := opts.IsNoTOTP() if err != nil { return err } @@ -201,16 +214,17 @@ func (args *Arguments) Do(tx *backend.Transaction) error { return ErrNoTOTP } if args.Mode == ListMode { - e, err := tx.QueryCallback(backend.QueryOptions{Mode: backend.SuffixMode, Criteria: backend.NewSuffix(inputs.TOTPToken())}) + e, err := opts.App.Transaction().QueryCallback(backend.QueryOptions{Mode: backend.SuffixMode, Criteria: backend.NewSuffix(args.token)}) if err != nil { return err } + writer := opts.App.Writer() for _, entry := range e { - fmt.Println(entry.Directory()) + fmt.Fprintf(writer, "%s\n", entry.Directory()) } return nil } - return args.display(tx) + return args.display(opts) } // NewArguments will parse the input arguments @@ -222,6 +236,7 @@ func NewArguments(args []string, tokenType string) (*Arguments, error) { return nil, errors.New("invalid token type, not set?") } opts := &Arguments{Mode: UnknownMode} + opts.token = tokenType sub := args[0] needs := true switch sub { @@ -242,7 +257,7 @@ func NewArguments(args []string, tokenType string) (*Arguments, error) { case cli.TOTPOnceCommand: opts.Mode = OnceMode default: - return nil, errors.New("unknown totp command") + return nil, ErrUnknownTOTPMode } if needs { if len(args) != 2 { diff --git a/internal/totp/core_test.go b/internal/totp/core_test.go @@ -1,11 +1,73 @@ package totp_test import ( + "bytes" + "io" + "os" + "strings" "testing" + "github.com/enckse/lockbox/internal/backend" "github.com/enckse/lockbox/internal/totp" ) +type ( + mockOptions struct { + buf bytes.Buffer + tx *backend.Transaction + } +) + +func newMock(t *testing.T) *mockOptions { + fullSetup(t, true).Insert(backend.NewPath("test", "test2", "test1"), "pass") + fullSetup(t, true).Insert(backend.NewPath("test", "test3", "totp"), "5ae472abqdekjqykoyxk7hvc2leklq5n") + fullSetup(t, true).Insert(backend.NewPath("test", "test2", "totp"), "5ae472abqdekjqykoyxk7hvc2leklq5n") + return &mockOptions{ + buf: bytes.Buffer{}, + tx: fullSetup(t, true), + } +} + +func fullSetup(t *testing.T, keep bool) *backend.Transaction { + if !keep { + os.Remove("test.kdbx") + } + os.Setenv("LOCKBOX_READONLY", "no") + os.Setenv("LOCKBOX_STORE", "test.kdbx") + os.Setenv("LOCKBOX_KEY", "test") + os.Setenv("LOCKBOX_KEYFILE", "") + os.Setenv("LOCKBOX_KEYMODE", "plaintext") + os.Setenv("LOCKBOX_TOTP", "totp") + os.Setenv("LOCKBOX_HOOKDIR", "") + os.Setenv("LOCKBOX_SET_MODTIME", "") + os.Setenv("LOCKBOX_TOTP_MAX", "1") + tr, err := backend.NewTransaction() + if err != nil { + t.Errorf("failed: %v", err) + } + return tr +} + +func (m *mockOptions) Confirm(string) bool { + return true +} + +func (m *mockOptions) Args() []string { + return nil +} + +func (m *mockOptions) Transaction() *backend.Transaction { + return m.tx +} + +func (m *mockOptions) Writer() io.Writer { + return &m.buf +} + +func setup(t *testing.T) *backend.Transaction { + return fullSetup(t, false) +} + func TestNewArgumentsErrors(t *testing.T) { if _, err := totp.NewArguments(nil, ""); err == nil || err.Error() != "not enough arguments for totp" { t.Errorf("invalid error: %v", err) @@ -13,7 +75,7 @@ func TestNewArgumentsErrors(t *testing.T) { if _, err := totp.NewArguments([]string{"test"}, ""); err == nil || err.Error() != "invalid token type, not set?" { t.Errorf("invalid error: %v", err) } - if _, err := totp.NewArguments([]string{"test"}, "a"); err == nil || err.Error() != "unknown totp command" { + if _, err := totp.NewArguments([]string{"test"}, "a"); err == nil || err.Error() != "unknown totp mode" { t.Errorf("invalid error: %v", err) } if _, err := totp.NewArguments([]string{"ls", "test"}, "a"); err == nil || err.Error() != "list takes no arguments" { @@ -54,3 +116,138 @@ func TestNewArguments(t *testing.T) { t.Errorf("invalid args: %s", args.Entry) } } + +func TestDoErrors(t *testing.T) { + args := totp.Arguments{} + if err := args.Do(totp.Options{}); err == nil || err.Error() != "unknown totp mode" { + t.Errorf("invalid error: %v", err) + } + args.Mode = totp.ShowMode + if err := args.Do(totp.Options{}); err == nil || err.Error() != "invalid option functions" { + t.Errorf("invalid error: %v", err) + } + opts := totp.Options{} + opts.Clear = func() { + } + if err := args.Do(opts); err == nil || err.Error() != "invalid option functions" { + t.Errorf("invalid error: %v", err) + } + opts.IsNoTOTP = func() (bool, error) { + return true, nil + } + if err := args.Do(opts); err == nil || err.Error() != "invalid option functions" { + t.Errorf("invalid error: %v", err) + } + opts.IsInteractive = func() (bool, error) { + return false, nil + } + if err := args.Do(opts); err == nil || err.Error() != "totp is disabled" { + t.Errorf("invalid error: %v", err) + } +} + +func TestList(t *testing.T) { + setup(t) + args, _ := totp.NewArguments([]string{"ls"}, "totp") + opts := testOptions() + m := newMock(t) + opts.App = m + if err := args.Do(opts); err != nil { + t.Errorf("invalid error: %v", err) + } + if m.buf.String() != "test/test2\ntest/test3\n" { + t.Errorf("invalid list: %s", m.buf.String()) + } +} + +func TestNonListError(t *testing.T) { + setup(t) + args, _ := totp.NewArguments([]string{"clip", "test"}, "totp") + opts := testOptions() + m := newMock(t) + opts.App = m + opts.IsInteractive = func() (bool, error) { + return false, nil + } + if err := args.Do(opts); err == nil || err.Error() != "clipboard not available in non-interactive mode" { + t.Errorf("invalid error: %v", err) + } + opts.IsInteractive = func() (bool, error) { + return true, nil + } + if err := args.Do(opts); err == nil || err.Error() != "object does not exist" { + t.Errorf("invalid error: %v", err) + } +} + +func TestShort(t *testing.T) { + setup(t) + args, _ := totp.NewArguments([]string{"short", "test/test3"}, "totp") + opts := testOptions() + m := newMock(t) + opts.App = m + if err := args.Do(opts); err != nil { + t.Errorf("invalid error: %v", err) + } + if len(m.buf.String()) != 7 { + t.Errorf("invalid short: %s", m.buf.String()) + } +} + +func TestNonInteractive(t *testing.T) { + setup(t) + args, _ := totp.NewArguments([]string{"show", "test/test3"}, "totp") + opts := testOptions() + m := newMock(t) + opts.App = m + opts.IsInteractive = func() (bool, error) { + return false, nil + } + if err := args.Do(opts); err != nil { + t.Errorf("invalid error: %v", err) + } + if len(m.buf.String()) != 7 { + t.Errorf("invalid short: %s", m.buf.String()) + } +} + +func TestOnce(t *testing.T) { + setup(t) + args, _ := totp.NewArguments([]string{"once", "test/test3"}, "totp") + opts := testOptions() + m := newMock(t) + opts.App = m + if err := args.Do(opts); err != nil { + t.Errorf("invalid error: %v", err) + } + if len(strings.Split(m.buf.String(), "\n")) != 5 { + t.Errorf("invalid short: %s", m.buf.String()) + } +} + +func TestShow(t *testing.T) { + setup(t) + args, _ := totp.NewArguments([]string{"show", "test/test3"}, "totp") + m := newMock(t) + opts := testOptions() + opts.App = m + if err := args.Do(opts); err != nil { + t.Errorf("invalid error: %v", err) + } + if len(strings.Split(m.buf.String(), "\n")) < 6 || !strings.Contains(m.buf.String(), "exiting (timeout)") { + t.Errorf("invalid short: %s", m.buf.String()) + } +} + +func testOptions() totp.Options { + opts := totp.Options{} + opts.Clear = func() { + } + opts.IsNoTOTP = func() (bool, error) { + return false, nil + } + opts.IsInteractive = func() (bool, error) { + return true, nil + } + return opts +}