lockbox

password manager
Log | Files | Refs | README | LICENSE

commit de1ccc0c62fde3bd45ec42cf2d4e5d4932a715d2
parent 7b51021d42ca1555e8508d10392f21c88fb5781b
Author: Sean Enck <sean@ttypty.com>
Date:   Tue, 25 Jul 2023 20:28:23 -0400

move totp to app area

Diffstat:
Mcmd/main.go | 7+++----
Ainternal/app/colors.go | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/colors_test.go | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/totp.go | 291++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/app/totp_test.go | 238+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dinternal/totp/colors.go | 54------------------------------------------------------
Dinternal/totp/colors_test.go | 66------------------------------------------------------------------
Dinternal/totp/core.go | 292-------------------------------------------------------------------------------
Dinternal/totp/core_test.go | 238-------------------------------------------------------------------------------
9 files changed, 652 insertions(+), 654 deletions(-)

diff --git a/cmd/main.go b/cmd/main.go @@ -12,7 +12,6 @@ import ( "github.com/enckse/lockbox/internal/app" "github.com/enckse/lockbox/internal/inputs" "github.com/enckse/lockbox/internal/platform" - "github.com/enckse/lockbox/internal/totp" ) var version string @@ -85,15 +84,15 @@ func run() error { case app.ConvCommand: return app.Conv(p) case app.TOTPCommand: - args, err := totp.NewArguments(sub, inputs.TOTPToken()) + args, err := app.NewTOTPArguments(sub, inputs.TOTPToken()) if err != nil { return err } - if args.Mode == totp.InsertMode { + if args.Mode == app.InsertTOTPMode { p.SetArgs(args.Entry) return app.Insert(p, app.TOTPInsert) } - return args.Do(totp.NewDefaultOptions(p)) + return args.Do(app.NewDefaultTOTPOptions(p)) default: return fmt.Errorf("unknown command: %s", command) } diff --git a/internal/app/colors.go b/internal/app/colors.go @@ -0,0 +1,54 @@ +// Package app manages definitions within lockbox for colorization. +package app + +import ( + "errors" + + "github.com/enckse/lockbox/internal/inputs" +) + +const ( + // Red will get red terminal coloring. + Red = iota +) + +type ( + // Color are terminal colors for dumb terminal coloring. + Color int +) + +const ( + termBeginRed = "\033[1;31m" + termEndRed = "\033[0m" +) + +type ( + // Terminal represents terminal coloring information. + Terminal struct { + Start string + End string + } +) + +// NewTerminal will retrieve start/end terminal coloration indicators. +func NewTerminal(color Color) (Terminal, error) { + if color != Red { + return Terminal{}, errors.New("bad color") + } + interactive, err := inputs.IsInteractive() + if err != nil { + return Terminal{}, err + } + colors := interactive + if colors { + isColored, err := inputs.IsNoColorEnabled() + if err != nil { + return Terminal{}, err + } + colors = !isColored + } + if colors { + return Terminal{Start: termBeginRed, End: termEndRed}, nil + } + return Terminal{}, nil +} diff --git a/internal/app/colors_test.go b/internal/app/colors_test.go @@ -0,0 +1,66 @@ +package app_test + +import ( + "os" + "testing" + + "github.com/enckse/lockbox/internal/app" +) + +func TestHasColoring(t *testing.T) { + os.Setenv("LOCKBOX_INTERACTIVE", "yes") + os.Setenv("LOCKBOX_NOCOLOR", "no") + term, err := app.NewTerminal(app.Red) + if err != nil { + t.Errorf("color was valid: %v", err) + } + if term.Start != "\033[1;31m" || term.End != "\033[0m" { + t.Error("bad resulting color") + } +} + +func TestBadColor(t *testing.T) { + _, err := app.NewTerminal(app.Color(5)) + if err == nil || err.Error() != "bad color" { + t.Errorf("invalid color error: %v", err) + } +} + +func TestNoColoring(t *testing.T) { + os.Setenv("LOCKBOX_INTERACTIVE", "no") + os.Setenv("LOCKBOX_NOCOLOR", "yes") + term, err := app.NewTerminal(app.Red) + if err != nil { + t.Errorf("color was valid: %v", err) + } + if term.Start != "" || term.End != "" { + t.Error("should have no color") + } + os.Setenv("LOCKBOX_INTERACTIVE", "yes") + os.Setenv("LOCKBOX_NOCOLOR", "yes") + term, err = app.NewTerminal(app.Red) + if err != nil { + t.Errorf("color was valid: %v", err) + } + if term.Start != "" || term.End != "" { + t.Error("should have no color") + } + os.Setenv("LOCKBOX_INTERACTIVE", "no") + os.Setenv("LOCKBOX_NOCOLOR", "no") + term, err = app.NewTerminal(app.Red) + if err != nil { + t.Errorf("color was valid: %v", err) + } + if term.Start != "" || term.End != "" { + t.Error("should have no color") + } + os.Setenv("LOCKBOX_INTERACTIVE", "yes") + os.Setenv("LOCKBOX_NOCOLOR", "no") + term, err = app.NewTerminal(app.Red) + if err != nil { + t.Errorf("color was valid: %v", err) + } + if term.Start == "" || term.End == "" { + t.Error("should have color") + } +} diff --git a/internal/app/totp.go b/internal/app/totp.go @@ -0,0 +1,291 @@ +// Package app handles TOTP tokens. +package app + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/enckse/lockbox/internal/backend" + "github.com/enckse/lockbox/internal/inputs" + "github.com/enckse/lockbox/internal/platform" + coreotp "github.com/pquerna/otp" + otp "github.com/pquerna/otp/totp" +) + +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 + Mode int + // TOTPArguments are the parsed TOTP call arguments + TOTPArguments struct { + Mode Mode + Entry string + token string + } + totpWrapper struct { + opts otp.ValidateOpts + code string + } + // TOTPOptions are TOTP call options + TOTPOptions struct { + app CommandOptions + Clear func() + IsNoTOTP func() (bool, error) + IsInteractive func() (bool, error) + } +) + +const ( + // UnknownTOTPMode is an unknown command + UnknownTOTPMode Mode = iota + // InsertTOTPMode is inserting a new totp token + InsertTOTPMode + // ShowTOTPMode will show the token + ShowTOTPMode + // ClipTOTPMode will copy to clipboard + ClipTOTPMode + // MinimalTOTPMode will display minimal information to display the token + MinimalTOTPMode + // ListTOTPMode lists the available tokens + ListTOTPMode + // OnceTOTPMode will only show the token once and exit + OnceTOTPMode +) + +// NewDefaultTOTPOptions gets the default option set +func NewDefaultTOTPOptions(app CommandOptions) TOTPOptions { + return TOTPOptions{ + app: app, + Clear: clear, + IsInteractive: inputs.IsInteractive, + IsNoTOTP: inputs.IsNoTOTP, + } +} + +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.EnvironOrDefault(inputs.ColorBetweenEnv, inputs.TOTPDefaultBetween) + if envTime == inputs.TOTPDefaultBetween { + return inputs.TOTPDefaultColorWindow, nil + } + return inputs.ParseColorWindow(envTime) +} + +func (w totpWrapper) generateCode() (string, error) { + return otp.GenerateCodeCustom(w.code, time.Now(), w.opts) +} + +func (args *TOTPArguments) display(opts TOTPOptions) error { + interactive, err := opts.IsInteractive() + if err != nil { + return err + } + if args.Mode == MinimalTOTPMode { + interactive = false + } + once := args.Mode == OnceTOTPMode + clip := args.Mode == ClipTOTPMode + if !interactive && clip { + return errors.New("clipboard not available in non-interactive mode") + } + coloring, err := NewTerminal(Red) + if err != nil { + return err + } + entity, err := opts.app.Transaction().Get(backend.NewPath(args.Entry, args.token), backend.SecretValue) + if err != nil { + return err + } + if entity == nil { + return errors.New("object does not exist") + } + totpToken := string(entity.Value) + k, err := coreotp.NewKeyFromURL(inputs.FormatTOTP(totpToken)) + 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() + if !interactive { + code, err := wrapper.generateCode() + if err != nil { + return err + } + fmt.Fprintf(writer, "%s\n", code) + return nil + } + first := true + running := 0 + lastSecond := -1 + if !clip { + if !once { + opts.Clear() + } + } + clipboard := platform.Clipboard{} + if clip { + clipboard, err = platform.NewClipboard() + if err != nil { + return err + } + } + colorRules, err := colorWhenRules() + if err != nil { + return err + } + runString := inputs.EnvironOrDefault(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 > runFor { + fmt.Fprint(writer, "exiting (timeout)\n") + return nil + } + now := time.Now() + last := now.Second() + if last == lastSecond { + continue + } + lastSecond = last + left := 60 - last + code, err := wrapper.generateCode() + if err != nil { + return err + } + startColor := "" + endColor := "" + for _, when := range colorRules { + if left < when.End && left >= when.Start { + startColor = coloring.Start + endColor = coloring.End + } + } + leftString := fmt.Sprintf("%d", left) + if len(leftString) < 2 { + leftString = "0" + leftString + } + expires := fmt.Sprintf("%s%s (%s)%s", startColor, now.Format("15:04:05"), leftString, endColor) + outputs := []string{expires} + if !clip { + outputs = append(outputs, fmt.Sprintf("%s\n %s", args.Entry, code)) + if !once { + outputs = append(outputs, "-> CTRL+C to exit") + } + } else { + fmt.Fprintf(writer, "-> %s\n", expires) + return clipboard.CopyTo(code) + } + if !once { + opts.Clear() + } + fmt.Fprintf(writer, "%s\n", strings.Join(outputs, "\n\n")) + if once { + return nil + } + } +} + +// Do will perform the TOTP operation +func (args *TOTPArguments) Do(opts TOTPOptions) error { + if args.Mode == UnknownTOTPMode { + return ErrUnknownTOTPMode + } + if opts.Clear == nil || opts.IsNoTOTP == nil || opts.IsInteractive == nil { + return errors.New("invalid option functions") + } + off, err := opts.IsNoTOTP() + if err != nil { + return err + } + if off { + return ErrNoTOTP + } + if args.Mode == ListTOTPMode { + 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.Fprintf(writer, "%s\n", entry.Directory()) + } + return nil + } + return args.display(opts) +} + +// NewTOTPArguments will parse the input arguments +func NewTOTPArguments(args []string, tokenType string) (*TOTPArguments, error) { + if len(args) == 0 { + return nil, errors.New("not enough arguments for totp") + } + if strings.TrimSpace(tokenType) == "" { + return nil, errors.New("invalid token type, not set?") + } + opts := &TOTPArguments{Mode: UnknownTOTPMode} + opts.token = tokenType + sub := args[0] + needs := true + switch sub { + case TOTPListCommand: + needs = false + if len(args) != 1 { + return nil, errors.New("list takes no arguments") + } + opts.Mode = ListTOTPMode + case TOTPInsertCommand: + opts.Mode = InsertTOTPMode + case TOTPShowCommand: + opts.Mode = ShowTOTPMode + case TOTPClipCommand: + opts.Mode = ClipTOTPMode + case TOTPMinimalCommand: + opts.Mode = MinimalTOTPMode + case TOTPOnceCommand: + opts.Mode = OnceTOTPMode + default: + return nil, ErrUnknownTOTPMode + } + if needs { + if len(args) != 2 { + return nil, errors.New("invalid arguments") + } + opts.Entry = args[1] + if opts.Mode == InsertTOTPMode { + if !strings.HasSuffix(opts.Entry, tokenType) { + opts.Entry = backend.NewPath(opts.Entry, tokenType) + } + } + } + return opts, nil +} diff --git a/internal/app/totp_test.go b/internal/app/totp_test.go @@ -0,0 +1,238 @@ +package app_test + +import ( + "bytes" + "io" + "os" + "strings" + "testing" + + "github.com/enckse/lockbox/internal/app" + "github.com/enckse/lockbox/internal/backend" +) + +type ( + mockOptions struct { + buf bytes.Buffer + tx *backend.Transaction + } +) + +func newMock(t *testing.T) (*mockOptions, app.TOTPOptions) { + fullTOTPSetup(t, true).Insert(backend.NewPath("test", "test2", "test1"), "pass") + fullTOTPSetup(t, true).Insert(backend.NewPath("test", "test3", "totp"), "5ae472abqdekjqykoyxk7hvc2leklq5n") + fullTOTPSetup(t, true).Insert(backend.NewPath("test", "test2", "totp"), "5ae472abqdekjqykoyxk7hvc2leklq5n") + m := &mockOptions{ + buf: bytes.Buffer{}, + tx: fullTOTPSetup(t, true), + } + opts := app.NewDefaultTOTPOptions(m) + opts.Clear = func() { + } + opts.IsNoTOTP = func() (bool, error) { + return false, nil + } + opts.IsInteractive = func() (bool, error) { + return true, nil + } + return m, opts +} + +func fullTOTPSetup(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 setupTOTP(t *testing.T) *backend.Transaction { + return fullTOTPSetup(t, false) +} + +func TestNewTOTPArgumentsErrors(t *testing.T) { + if _, err := app.NewTOTPArguments(nil, ""); err == nil || err.Error() != "not enough arguments for totp" { + t.Errorf("invalid error: %v", err) + } + if _, err := app.NewTOTPArguments([]string{"test"}, ""); err == nil || err.Error() != "invalid token type, not set?" { + t.Errorf("invalid error: %v", err) + } + if _, err := app.NewTOTPArguments([]string{"test"}, "a"); err == nil || err.Error() != "unknown totp mode" { + t.Errorf("invalid error: %v", err) + } + if _, err := app.NewTOTPArguments([]string{"ls", "test"}, "a"); err == nil || err.Error() != "list takes no arguments" { + t.Errorf("invalid error: %v", err) + } + if _, err := app.NewTOTPArguments([]string{"show"}, "a"); err == nil || err.Error() != "invalid arguments" { + t.Errorf("invalid error: %v", err) + } +} + +func TestNewTOTPArguments(t *testing.T) { + args, _ := app.NewTOTPArguments([]string{"ls"}, "test") + if args.Mode != app.ListTOTPMode || args.Entry != "" { + t.Error("invalid args") + } + args, _ = app.NewTOTPArguments([]string{"show", "test"}, "test") + if args.Mode != app.ShowTOTPMode || args.Entry != "test" { + t.Error("invalid args") + } + args, _ = app.NewTOTPArguments([]string{"clip", "test"}, "test") + if args.Mode != app.ClipTOTPMode || args.Entry != "test" { + t.Error("invalid args") + } + args, _ = app.NewTOTPArguments([]string{"minimal", "test"}, "test") + if args.Mode != app.MinimalTOTPMode || args.Entry != "test" { + t.Error("invalid args") + } + args, _ = app.NewTOTPArguments([]string{"once", "test"}, "test") + if args.Mode != app.OnceTOTPMode || args.Entry != "test" { + t.Error("invalid args") + } + args, _ = app.NewTOTPArguments([]string{"insert", "test2"}, "test") + if args.Mode != app.InsertTOTPMode || args.Entry != "test2/test" { + t.Errorf("invalid args: %s", args.Entry) + } + args, _ = app.NewTOTPArguments([]string{"insert", "test2/test"}, "test") + if args.Mode != app.InsertTOTPMode || args.Entry != "test2/test" { + t.Errorf("invalid args: %s", args.Entry) + } +} + +func TestDoErrors(t *testing.T) { + args := app.TOTPArguments{} + if err := args.Do(app.TOTPOptions{}); err == nil || err.Error() != "unknown totp mode" { + t.Errorf("invalid error: %v", err) + } + args.Mode = app.ShowTOTPMode + if err := args.Do(app.TOTPOptions{}); err == nil || err.Error() != "invalid option functions" { + t.Errorf("invalid error: %v", err) + } + opts := app.TOTPOptions{} + 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 TestTOTPList(t *testing.T) { + setupTOTP(t) + args, _ := app.NewTOTPArguments([]string{"ls"}, "totp") + m, opts := newMock(t) + 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) { + setupTOTP(t) + args, _ := app.NewTOTPArguments([]string{"clip", "test"}, "totp") + _, opts := newMock(t) + 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 TestMinimal(t *testing.T) { + setupTOTP(t) + args, _ := app.NewTOTPArguments([]string{"minimal", "test/test3"}, "totp") + m, opts := newMock(t) + 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) { + setupTOTP(t) + args, _ := app.NewTOTPArguments([]string{"show", "test/test3"}, "totp") + m, opts := newMock(t) + 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) { + setupTOTP(t) + args, _ := app.NewTOTPArguments([]string{"once", "test/test3"}, "totp") + m, opts := newMock(t) + 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) { + setupTOTP(t) + args, _ := app.NewTOTPArguments([]string{"show", "test/test3"}, "totp") + m, opts := newMock(t) + 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()) + } +} diff --git a/internal/totp/colors.go b/internal/totp/colors.go @@ -1,54 +0,0 @@ -// Package totp manages definitions within lockbox for colorization. -package totp - -import ( - "errors" - - "github.com/enckse/lockbox/internal/inputs" -) - -const ( - // Red will get red terminal coloring. - Red = iota -) - -type ( - // Color are terminal colors for dumb terminal coloring. - Color int -) - -const ( - termBeginRed = "\033[1;31m" - termEndRed = "\033[0m" -) - -type ( - // Terminal represents terminal coloring information. - Terminal struct { - Start string - End string - } -) - -// NewTerminal will retrieve start/end terminal coloration indicators. -func NewTerminal(color Color) (Terminal, error) { - if color != Red { - return Terminal{}, errors.New("bad color") - } - interactive, err := inputs.IsInteractive() - if err != nil { - return Terminal{}, err - } - colors := interactive - if colors { - isColored, err := inputs.IsNoColorEnabled() - if err != nil { - return Terminal{}, err - } - colors = !isColored - } - if colors { - return Terminal{Start: termBeginRed, End: termEndRed}, nil - } - return Terminal{}, nil -} diff --git a/internal/totp/colors_test.go b/internal/totp/colors_test.go @@ -1,66 +0,0 @@ -package totp_test - -import ( - "os" - "testing" - - "github.com/enckse/lockbox/internal/totp" -) - -func TestHasColoring(t *testing.T) { - os.Setenv("LOCKBOX_INTERACTIVE", "yes") - os.Setenv("LOCKBOX_NOCOLOR", "no") - term, err := totp.NewTerminal(totp.Red) - if err != nil { - t.Errorf("color was valid: %v", err) - } - if term.Start != "\033[1;31m" || term.End != "\033[0m" { - t.Error("bad resulting color") - } -} - -func TestBadColor(t *testing.T) { - _, err := totp.NewTerminal(totp.Color(5)) - if err == nil || err.Error() != "bad color" { - t.Errorf("invalid color error: %v", err) - } -} - -func TestNoColoring(t *testing.T) { - os.Setenv("LOCKBOX_INTERACTIVE", "no") - os.Setenv("LOCKBOX_NOCOLOR", "yes") - term, err := totp.NewTerminal(totp.Red) - if err != nil { - t.Errorf("color was valid: %v", err) - } - if term.Start != "" || term.End != "" { - t.Error("should have no color") - } - os.Setenv("LOCKBOX_INTERACTIVE", "yes") - os.Setenv("LOCKBOX_NOCOLOR", "yes") - term, err = totp.NewTerminal(totp.Red) - if err != nil { - t.Errorf("color was valid: %v", err) - } - if term.Start != "" || term.End != "" { - t.Error("should have no color") - } - os.Setenv("LOCKBOX_INTERACTIVE", "no") - os.Setenv("LOCKBOX_NOCOLOR", "no") - term, err = totp.NewTerminal(totp.Red) - if err != nil { - t.Errorf("color was valid: %v", err) - } - if term.Start != "" || term.End != "" { - t.Error("should have no color") - } - os.Setenv("LOCKBOX_INTERACTIVE", "yes") - os.Setenv("LOCKBOX_NOCOLOR", "no") - term, err = totp.NewTerminal(totp.Red) - if err != nil { - t.Errorf("color was valid: %v", err) - } - if term.Start == "" || term.End == "" { - t.Error("should have color") - } -} diff --git a/internal/totp/core.go b/internal/totp/core.go @@ -1,292 +0,0 @@ -// Package totp handles TOTP tokens. -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/inputs" - "github.com/enckse/lockbox/internal/platform" - coreotp "github.com/pquerna/otp" - otp "github.com/pquerna/otp/totp" -) - -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 - Mode int - // Arguments are the parsed TOTP call arguments - 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 ( - // UnknownMode is an unknown command - UnknownMode Mode = iota - // InsertMode is inserting a new totp token - InsertMode - // ShowMode will show the token - ShowMode - // ClipMode will copy to clipboard - ClipMode - // MinimalMode will display minimal information to display the token - MinimalMode - // ListMode lists the available tokens - ListMode - // OnceMode will only show the token once and exit - OnceMode -) - -// NewDefaultOptions gets the default option set -func NewDefaultOptions(app app.CommandOptions) Options { - return Options{ - app: app, - Clear: clear, - IsInteractive: inputs.IsInteractive, - IsNoTOTP: inputs.IsNoTOTP, - } -} - -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.EnvironOrDefault(inputs.ColorBetweenEnv, inputs.TOTPDefaultBetween) - if envTime == inputs.TOTPDefaultBetween { - return inputs.TOTPDefaultColorWindow, nil - } - return inputs.ParseColorWindow(envTime) -} - -func (w totpWrapper) generateCode() (string, error) { - return otp.GenerateCodeCustom(w.code, time.Now(), w.opts) -} - -func (args *Arguments) display(opts Options) error { - interactive, err := opts.IsInteractive() - if err != nil { - return err - } - if args.Mode == MinimalMode { - interactive = false - } - once := args.Mode == OnceMode - clip := args.Mode == ClipMode - if !interactive && clip { - return errors.New("clipboard not available in non-interactive mode") - } - coloring, err := NewTerminal(Red) - if err != nil { - return err - } - entity, err := opts.app.Transaction().Get(backend.NewPath(args.Entry, args.token), backend.SecretValue) - if err != nil { - return err - } - if entity == nil { - return errors.New("object does not exist") - } - totpToken := string(entity.Value) - k, err := coreotp.NewKeyFromURL(inputs.FormatTOTP(totpToken)) - 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() - if !interactive { - code, err := wrapper.generateCode() - if err != nil { - return err - } - fmt.Fprintf(writer, "%s\n", code) - return nil - } - first := true - running := 0 - lastSecond := -1 - if !clip { - if !once { - opts.Clear() - } - } - clipboard := platform.Clipboard{} - if clip { - clipboard, err = platform.NewClipboard() - if err != nil { - return err - } - } - colorRules, err := colorWhenRules() - if err != nil { - return err - } - runString := inputs.EnvironOrDefault(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 > runFor { - fmt.Fprint(writer, "exiting (timeout)\n") - return nil - } - now := time.Now() - last := now.Second() - if last == lastSecond { - continue - } - lastSecond = last - left := 60 - last - code, err := wrapper.generateCode() - if err != nil { - return err - } - startColor := "" - endColor := "" - for _, when := range colorRules { - if left < when.End && left >= when.Start { - startColor = coloring.Start - endColor = coloring.End - } - } - leftString := fmt.Sprintf("%d", left) - if len(leftString) < 2 { - leftString = "0" + leftString - } - expires := fmt.Sprintf("%s%s (%s)%s", startColor, now.Format("15:04:05"), leftString, endColor) - outputs := []string{expires} - if !clip { - outputs = append(outputs, fmt.Sprintf("%s\n %s", args.Entry, code)) - if !once { - outputs = append(outputs, "-> CTRL+C to exit") - } - } else { - fmt.Fprintf(writer, "-> %s\n", expires) - return clipboard.CopyTo(code) - } - if !once { - opts.Clear() - } - fmt.Fprintf(writer, "%s\n", strings.Join(outputs, "\n\n")) - if once { - return nil - } - } -} - -// Do will perform the TOTP operation -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 := opts.IsNoTOTP() - if err != nil { - return err - } - if off { - return ErrNoTOTP - } - if args.Mode == ListMode { - 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.Fprintf(writer, "%s\n", entry.Directory()) - } - return nil - } - return args.display(opts) -} - -// NewArguments will parse the input arguments -func NewArguments(args []string, tokenType string) (*Arguments, error) { - if len(args) == 0 { - return nil, errors.New("not enough arguments for totp") - } - if strings.TrimSpace(tokenType) == "" { - return nil, errors.New("invalid token type, not set?") - } - opts := &Arguments{Mode: UnknownMode} - opts.token = tokenType - sub := args[0] - needs := true - switch sub { - case app.TOTPListCommand: - needs = false - if len(args) != 1 { - return nil, errors.New("list takes no arguments") - } - opts.Mode = ListMode - case app.TOTPInsertCommand: - opts.Mode = InsertMode - case app.TOTPShowCommand: - opts.Mode = ShowMode - case app.TOTPClipCommand: - opts.Mode = ClipMode - case app.TOTPMinimalCommand: - opts.Mode = MinimalMode - case app.TOTPOnceCommand: - opts.Mode = OnceMode - default: - return nil, ErrUnknownTOTPMode - } - if needs { - if len(args) != 2 { - return nil, errors.New("invalid arguments") - } - opts.Entry = args[1] - if opts.Mode == InsertMode { - if !strings.HasSuffix(opts.Entry, tokenType) { - opts.Entry = backend.NewPath(opts.Entry, tokenType) - } - } - } - return opts, nil -} diff --git a/internal/totp/core_test.go b/internal/totp/core_test.go @@ -1,238 +0,0 @@ -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, totp.Options) { - 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") - m := &mockOptions{ - buf: bytes.Buffer{}, - tx: fullSetup(t, true), - } - opts := totp.NewDefaultOptions(m) - opts.Clear = func() { - } - opts.IsNoTOTP = func() (bool, error) { - return false, nil - } - opts.IsInteractive = func() (bool, error) { - return true, nil - } - return m, opts -} - -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) - } - 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 mode" { - t.Errorf("invalid error: %v", err) - } - if _, err := totp.NewArguments([]string{"ls", "test"}, "a"); err == nil || err.Error() != "list takes no arguments" { - t.Errorf("invalid error: %v", err) - } - if _, err := totp.NewArguments([]string{"show"}, "a"); err == nil || err.Error() != "invalid arguments" { - t.Errorf("invalid error: %v", err) - } -} - -func TestNewArguments(t *testing.T) { - args, _ := totp.NewArguments([]string{"ls"}, "test") - if args.Mode != totp.ListMode || args.Entry != "" { - t.Error("invalid args") - } - args, _ = totp.NewArguments([]string{"show", "test"}, "test") - if args.Mode != totp.ShowMode || args.Entry != "test" { - t.Error("invalid args") - } - args, _ = totp.NewArguments([]string{"clip", "test"}, "test") - if args.Mode != totp.ClipMode || args.Entry != "test" { - t.Error("invalid args") - } - args, _ = totp.NewArguments([]string{"minimal", "test"}, "test") - if args.Mode != totp.MinimalMode || args.Entry != "test" { - t.Error("invalid args") - } - args, _ = totp.NewArguments([]string{"once", "test"}, "test") - if args.Mode != totp.OnceMode || args.Entry != "test" { - t.Error("invalid args") - } - args, _ = totp.NewArguments([]string{"insert", "test2"}, "test") - if args.Mode != totp.InsertMode || args.Entry != "test2/test" { - t.Errorf("invalid args: %s", args.Entry) - } - args, _ = totp.NewArguments([]string{"insert", "test2/test"}, "test") - if args.Mode != totp.InsertMode || args.Entry != "test2/test" { - 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") - m, opts := newMock(t) - 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 := newMock(t) - 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 TestMinimal(t *testing.T) { - setup(t) - args, _ := totp.NewArguments([]string{"minimal", "test/test3"}, "totp") - m, opts := newMock(t) - 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") - m, opts := newMock(t) - 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") - m, opts := newMock(t) - 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, opts := newMock(t) - 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()) - } -}