commit 47df6990207e344efbf5971c79a5900dc1be24fd
parent 148519703b7c95fa4de47289d701d57fa581fec0
Author: Sean Enck <sean@ttypty.com>
Date: Mon, 27 Mar 2023 21:16:45 -0400
totp tests
Diffstat:
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
+}