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:
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