lockbox

password manager
Log | Files | Refs | README | LICENSE

commit 4a196a6b3c3af29a2bd3ddbe7db71d61cb8bdcc9
parent 1f0dd3efdd3998b75f640fff503d26a4c0112667
Author: Sean Enck <sean@ttypty.com>
Date:   Sat,  2 Sep 2023 08:09:32 -0400

add support for 'interactive' key mode

Diffstat:
Minternal/backend/actions.go | 7++++++-
Minternal/config/core.go | 14++++++++++++++
Minternal/config/core_test.go | 7+++++++
Minternal/config/vars.go | 23+++++++++++++++++++----
Minternal/config/vars_test.go | 26++++++++++++++++++++++----
Minternal/platform/os.go | 36++++++++++++++++++++++++++++++++++++
Minternal/platform/os_test.go | 34++++++++++++++++++++++++++++++++++
Mtests/expected.log | 1+
Mtests/run.sh | 16++++++++++++++++
9 files changed, 155 insertions(+), 9 deletions(-)

diff --git a/internal/backend/actions.go b/internal/backend/actions.go @@ -8,6 +8,7 @@ import ( "time" "github.com/enckse/lockbox/internal/config" + "github.com/enckse/lockbox/internal/platform" "github.com/tobischo/gokeepasslib/v3" ) @@ -34,7 +35,11 @@ func (t *Transaction) act(cb action) error { if err != nil { return err } - k := string(key) + useKey, err := platform.ReadKey(key, platform.ReadInteractivePassword) + if err != nil { + return err + } + k := string(useKey) file := config.EnvKeyFile.Get() if !t.exists { if err := create(t.file, k, file); err != nil { diff --git a/internal/config/core.go b/internal/config/core.go @@ -91,8 +91,22 @@ type ( Start int End int } + // Key is a wrapper to help manage the returned key + Key struct { + key []byte + } ) +// Interactive indicates if the key requires interactive input +func (e *Key) Interactive() bool { + return e.key == nil +} + +// Key returns the key data +func (e *Key) Key() []byte { + return e.key +} + func shlex(in string) ([]string, error) { return shell.Fields(in, os.Getenv) } diff --git a/internal/config/core_test.go b/internal/config/core_test.go @@ -227,3 +227,10 @@ func TestExpandParsed(t *testing.T) { t.Errorf("invalid expand: %v", r) } } + +func TestKey(t *testing.T) { + k := config.Key{} + if !k.Interactive() || k.Key() != nil { + t.Error("should be interactive without data") + } +} diff --git a/internal/config/vars.go b/internal/config/vars.go @@ -16,6 +16,7 @@ const ( prefixKey = "LOCKBOX_" clipBaseEnv = prefixKey + "CLIP_" plainKeyMode = "plaintext" + interactiveKeyMode = "interactive" commandKeyMode = "command" commandArgsExample = "[cmd args...]" fileExample = "<file>" @@ -81,7 +82,7 @@ var ( EnvFormatTOTP = EnvironmentFormatter{environmentBase: environmentBase{key: EnvTOTPToken.key + "_FORMAT", desc: "Override the otpauth url used to store totp tokens. It must have ONE format\nstring ('%s') to insert the totp base code."}, fxn: formatterTOTP, allowed: "otpauth//url/%s/args..."} // EnvConfig is the location of the config file to read environment variables from EnvConfig = EnvironmentString{environmentBase: environmentBase{key: prefixKey + "ENV", desc: fmt.Sprintf("Allows setting a specific file of environment variables for lockbox\nto read and use as configuration values (an '.env' file). The keyword\n'%s' will disable this functionality the keyword '%s' will search\nfor a file in the following paths in user's home directory matching\nthe first file found.\n\ndefault search paths:\n%v\n\nNote that this setting is not output as part of the environment.", noEnvironment, detectEnvironment, detectEnvironmentPaths)}, canDefault: true, defaultValue: detectEnvironment, allowed: []string{detectEnvironment, fileExample, noEnvironment}} - envKeyMode = EnvironmentString{environmentBase: environmentBase{key: prefixKey + "KEYMODE", requirement: "must be set to a valid mode when using a key", desc: "How to retrieve the database store password."}, allowed: []string{commandKeyMode, plainKeyMode}, canDefault: true, defaultValue: commandKeyMode} + envKeyMode = EnvironmentString{environmentBase: environmentBase{key: prefixKey + "KEYMODE", requirement: "must be set to a valid mode when using a key", desc: "How to retrieve the database store password."}, allowed: []string{commandKeyMode, plainKeyMode, interactiveKeyMode}, canDefault: true, defaultValue: commandKeyMode} envKey = EnvironmentString{environmentBase: environmentBase{requirement: requiredKeyOrKeyFile, key: prefixKey + "KEY", desc: fmt.Sprintf("The database key ('%s' mode) or command to run ('%s' mode)\nto retrieve the database password.", plainKeyMode, commandKeyMode)}, allowed: []string{commandArgsExample, "password"}, canDefault: false} envConfigExpands = EnvironmentInt{environmentBase: environmentBase{key: EnvConfig.key + "_EXPANDS", desc: "The maximum number of times to expand the input env to resolve variables,\nset to 0 to disable expansion. This value can NOT be an expansion itself\nif set in the env config file."}, shortDesc: "max expands", allowZero: true, defaultValue: 20} ) @@ -121,13 +122,27 @@ func GetReKey(args []string) ([]string, error) { } // GetKey will get the encryption key setup for lb -func GetKey() ([]byte, error) { +func GetKey() (*Key, error) { useKey := envKey.Get() + keyMode := envKeyMode.Get() + if keyMode == interactiveKeyMode { + isInteractive, err := EnvInteractive.Get() + if err != nil { + return nil, err + } + if !isInteractive { + return nil, errors.New("interactive key mode requested in non-interactive mode") + } + if useKey != "" { + return nil, errors.New("key can NOT be set in interactive mode") + } + return &Key{}, nil + } if useKey == "" { return nil, nil } var data []byte - switch envKeyMode.Get() { + switch keyMode { case commandKeyMode: parts, err := shlex(useKey) if err != nil { @@ -148,7 +163,7 @@ func GetKey() ([]byte, error) { if len(b) == 0 { return nil, errors.New("key is empty") } - return b, nil + return &Key{key: b}, nil } // ListEnvironmentVariables will print information about env variables diff --git a/internal/config/vars_test.go b/internal/config/vars_test.go @@ -79,24 +79,42 @@ func TestTOTP(t *testing.T) { func TestGetKey(t *testing.T) { os.Setenv("LOCKBOX_KEY", "aaa") os.Setenv("LOCKBOX_KEYMODE", "lak;jfea") - if _, err := config.GetKey(); err.Error() != "unknown keymode" { + if k, err := config.GetKey(); err.Error() != "unknown keymode" || k != nil { t.Errorf("invalid error: %v", err) } os.Setenv("LOCKBOX_KEYMODE", "plaintext") os.Setenv("LOCKBOX_KEY", "") - if _, err := config.GetKey(); err != nil { + if k, err := config.GetKey(); err != nil || k != nil { t.Errorf("invalid error: %v", err) } os.Setenv("LOCKBOX_KEY", "key") k, err := config.GetKey() - if err != nil || string(k) != "key" { + if err != nil || k == nil || string(k.Key()) != "key" || k.Interactive() { t.Error("invalid key retrieval") } os.Setenv("LOCKBOX_KEYMODE", "command") os.Setenv("LOCKBOX_KEY", "invalid command text is long and invalid via shlex") - if _, err := config.GetKey(); err == nil { + if k, err := config.GetKey(); err == nil || k != nil { t.Error("should have failed") } + os.Setenv("LOCKBOX_INTERACTIVE", "yes") + os.Setenv("LOCKBOX_KEYMODE", "interactive") + os.Setenv("LOCKBOX_KEY", "") + if k, err := config.GetKey(); err != nil || k == nil || !k.Interactive() { + t.Errorf("invalid error: %v", err) + } + os.Setenv("LOCKBOX_INTERACTIVE", "no") + os.Setenv("LOCKBOX_KEYMODE", "interactive") + os.Setenv("LOCKBOX_KEY", "") + if k, err := config.GetKey(); err == nil || err.Error() != "interactive key mode requested in non-interactive mode" || k != nil { + t.Errorf("invalid error: %v", err) + } + os.Setenv("LOCKBOX_INTERACTIVE", "yes") + os.Setenv("LOCKBOX_KEYMODE", "interactive") + os.Setenv("LOCKBOX_KEY", "aaa") + if k, err := config.GetKey(); err == nil || err.Error() != "key can NOT be set in interactive mode" || k != nil { + t.Errorf("invalid error: %v", err) + } } func TestListVariables(t *testing.T) { diff --git a/internal/platform/os.go b/internal/platform/os.go @@ -9,10 +9,14 @@ import ( "os" "strings" "syscall" + + "github.com/enckse/lockbox/internal/config" ) type ( stdinReaderFunc func(string) (bool, error) + // PasswordReader is for input password handling (stdin) as a db key + PasswordReader func() (string, error) ) func termEcho(on bool) { @@ -67,6 +71,38 @@ func GetUserInputPassword(piping, multiLine bool) ([]byte, error) { return []byte(password), nil } +// ReadKey will attempt to resolve a key (if interactive) for the platform +func ReadKey(key *config.Key, fxn PasswordReader) (string, error) { + if fxn == nil { + return "", errors.New("invalid function given") + } + if key == nil { + return "", nil + } + useKey := string(key.Key()) + if key.Interactive() { + read, err := fxn() + if err != nil { + return "", err + } + if len(read) == 0 { + return "", errors.New("interactive password can NOT be empty") + } + useKey = read + } + return useKey, nil +} + +// ReadInteractivePassword will prompt for a single password for unlocking +func ReadInteractivePassword() (string, error) { + termEcho(false) + defer func() { + termEcho(true) + }() + fmt.Print("password: ") + return Stdin(true) +} + func confirmInputsMatch() (string, error) { termEcho(false) defer func() { diff --git a/internal/platform/os_test.go b/internal/platform/os_test.go @@ -1,10 +1,12 @@ package platform_test import ( + "errors" "os" "path/filepath" "testing" + "github.com/enckse/lockbox/internal/config" "github.com/enckse/lockbox/internal/platform" ) @@ -19,3 +21,35 @@ func TestPathExist(t *testing.T) { t.Error("test dir SHOULD exist") } } + +func TestReadKey(t *testing.T) { + o, err := platform.ReadKey(nil, nil) + if o != "" || err == nil || err.Error() != "invalid function given" { + t.Errorf("invalid error: %v", err) + } + fxn := func() (string, error) { + return "", nil + } + o, err = platform.ReadKey(nil, fxn) + if o != "" || err != nil { + t.Errorf("invalid error: %v", err) + } + o, err = platform.ReadKey(&config.Key{}, fxn) + if o != "" || err == nil || err.Error() != "interactive password can NOT be empty" { + t.Errorf("invalid error: %v", err) + } + fxn = func() (string, error) { + return "abc", errors.New("test error") + } + o, err = platform.ReadKey(&config.Key{}, fxn) + if o != "" || err == nil || err.Error() != "test error" { + t.Errorf("invalid error: %v", err) + } + fxn = func() (string, error) { + return "abc", nil + } + o, err = platform.ReadKey(&config.Key{}, fxn) + if o != "abc" || err != nil { + t.Errorf("invalid error: %v", err) + } +} diff --git a/tests/expected.log b/tests/expected.log @@ -1,3 +1,4 @@ +password: keys/k/one2 path can NOT end with separator path can NOT be rooted unwilling to operate on path with empty segment diff --git a/tests/run.sh b/tests/run.sh @@ -8,6 +8,7 @@ CLIP_COPY="$DATA/clip.copy" CLIP_PASTE="$DATA/clip.paste" _execute() { + local oldmode oldkey export LOCKBOX_HOOKDIR="" export LOCKBOX_STORE="${DATA}/passwords.kdbx" export LOCKBOX_TOTP=totp @@ -16,6 +17,21 @@ _execute() { export LOCKBOX_KEYMODE=plaintext export LOCKBOX_JSON_DATA_HASH_LENGTH=0 echo test2 |${LB_BINARY} insert keys/k/one2 + oldmode="$LOCKBOX_KEYMODE" + oldkey="$LOCKBOX_KEY" + if [ "$oldkey" != "" ]; then + export LOCKBOX_INTERACTIVE=yes + export LOCKBOX_KEYMODE=interactive + export LOCKBOX_KEY="" + else + printf "password: " + fi + echo "$oldkey" | ${LB_BINARY} ls 2>/dev/null + if [ "$oldkey" != "" ]; then + export LOCKBOX_INTERACTIVE=no + export LOCKBOX_KEYMODE="$oldmode" + export LOCKBOX_KEY="$oldkey" + fi echo test |${LB_BINARY} insert keys/k/one echo test |${LB_BINARY} insert key/a/one echo test |${LB_BINARY} insert keys/k/one