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