lockbox

password manager
Log | Files | Refs | README | LICENSE

commit 437f861fa25cbf1b389bfdfc4f48c6b6f277115d
parent 0c8a8a7be66adccfe1955eb9086d570ebf1b1762
Author: Sean Enck <sean@ttypty.com>
Date:   Tue, 25 Jul 2023 20:13:50 -0400

split env to impl and definition

Diffstat:
Minternal/inputs/env.go | 279+++++++++----------------------------------------------------------------------
Minternal/inputs/env_test.go | 198+++----------------------------------------------------------------------------
Ainternal/inputs/vars.go | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/inputs/vars_test.go | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 470 insertions(+), 442 deletions(-)

diff --git a/internal/inputs/env.go b/internal/inputs/env.go @@ -2,52 +2,17 @@ package inputs import ( - "errors" - "flag" "fmt" "os" - "os/exec" - "sort" "strconv" "strings" - "time" "mvdan.cc/sh/v3/shell" ) const ( - yes = "yes" - no = "no" - prefixKey = "LOCKBOX_" - noClipEnv = prefixKey + "NOCLIP" - noColorEnv = prefixKey + "NOCOLOR" - interactiveEnv = prefixKey + "INTERACTIVE" - readOnlyEnv = prefixKey + "READONLY" - fieldTOTPEnv = prefixKey + "TOTP" - clipBaseEnv = prefixKey + "CLIP_" - formatTOTPEnv = fieldTOTPEnv + "_FORMAT" - keyModeEnv = prefixKey + "KEYMODE" - keyEnv = prefixKey + "KEY" - // KeyFileEnv is an OPTIONAL keyfile for the database - KeyFileEnv = prefixKey + "KEYFILE" - plainKeyMode = "plaintext" - commandKeyMode = "command" - // PlatformEnv is the platform lb is running on. - PlatformEnv = prefixKey + "PLATFORM" - // StoreEnv is the location of the filesystem store that lb is operating on. - StoreEnv = prefixKey + "STORE" - 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 - ClipCopyEnv = clipBaseEnv + "COPY" - clipOSC52Env = clipBaseEnv + "OSC52" - defaultTOTPField = "totp" - commandArgsExample = "[cmd args...]" + yes = "yes" + no = "no" // MacOSPlatform is the macos indicator for platform MacOSPlatform = "macos" // LinuxWaylandPlatform for linux+wayland @@ -56,204 +21,20 @@ const ( LinuxXPlatform = "linux-x" // WindowsLinuxPlatform for WSL subsystems WindowsLinuxPlatform = "wsl" - defaultMaxClipboard = 45 - detectedValue = "(detected)" - noTOTPEnv = prefixKey + "NOTOTP" - // HookDirEnv represents a stored location for user hooks - HookDirEnv = prefixKey + "HOOKDIR" - // ModTimeEnv is modtime override ability for entries - ModTimeEnv = prefixKey + "SET_MODTIME" - // ModTimeFormat is the expected modtime format - ModTimeFormat = time.RFC3339 - // MaxTOTPTimeDefault is the max TOTP time to run (default) - MaxTOTPTimeDefault = "120" - // JSONDataOutputEnv controls how JSON is output - JSONDataOutputEnv = prefixKey + "JSON_DATA_OUTPUT" - defaultHashLength = 0 - hashJSONLengthEnv = JSONDataOutputEnv + "_HASH_LENGTH" ) var isYesNoArgs = []string{yes, no} type ( - environmentOutput struct { - showValues bool - } // SystemPlatform represents the platform lockbox is running on. SystemPlatform string ) -// GetReKey will get the rekey environment settings -func GetReKey(args []string) ([]string, error) { - set := flag.NewFlagSet("rekey", flag.ExitOnError) - store := set.String("store", "", "new store") - key := set.String("key", "", "new key") - keyFile := set.String("keyfile", "", "new keyfile") - keyMode := set.String("keymode", "", "new keymode") - if err := set.Parse(args); err != nil { - return nil, err - } - mapped := map[string]string{ - keyModeEnv: *keyMode, - keyEnv: *key, - KeyFileEnv: *keyFile, - StoreEnv: *store, - } - hasStore := false - hasKey := false - hasKeyFile := false - var out []string - for k, val := range mapped { - if val != "" { - switch k { - case StoreEnv: - hasStore = true - case keyEnv: - hasKey = true - case KeyFileEnv: - hasKeyFile = true - } - } - out = append(out, fmt.Sprintf("%s=%s", k, val)) - } - sort.Strings(out) - if !hasStore || (!hasKey && !hasKeyFile) { - return nil, fmt.Errorf("missing required arguments for rekey: %s", strings.Join(out, " ")) - } - return out, nil -} - -// GetClipboardMax will get max time to keep an entry in the clipboard before clearing -func GetClipboardMax() (int, error) { - return getPositiveIntEnv(defaultMaxClipboard, clipMaxEnv, "clipboard max time", false) -} - -// GetHashLength will get the maximum hash length allowed in JSON output hashing mode -func GetHashLength() (int, error) { - return getPositiveIntEnv(defaultHashLength, hashJSONLengthEnv, "hash length", true) -} - -func getPositiveIntEnv(defaultVal int, key, desc string, canBeZero bool) (int, error) { - val := defaultVal - use := os.Getenv(key) - if use != "" { - i, err := strconv.Atoi(use) - if err != nil { - return -1, err - } - invalid := false - check := "" - if canBeZero { - check = "=" - } - switch i { - case 0: - invalid = !canBeZero - default: - invalid = i < 0 - } - if invalid { - return -1, fmt.Errorf("%s must be >%s 0", desc, check) - } - val = i - } - return val, nil -} - -// GetKey will get the encryption key setup for lb -func GetKey() ([]byte, error) { - useKeyMode := os.Getenv(keyModeEnv) - if useKeyMode == "" { - useKeyMode = commandKeyMode - } - useKey := os.Getenv(keyEnv) - if useKey == "" { - return nil, errors.New("no key given") - } - b, err := getKey(useKeyMode, useKey) - if err != nil { - return nil, err - } - if len(b) == 0 { - return nil, errors.New("key is empty") - } - return b, nil -} - // Shlex will do simple shell command lex-ing func Shlex(in string) ([]string, error) { return shell.Fields(in, os.Getenv) } -func getKey(keyMode, name string) ([]byte, error) { - var data []byte - switch keyMode { - case commandKeyMode: - parts, err := Shlex(name) - if err != nil { - return nil, err - } - cmd := exec.Command(parts[0], parts[1:]...) - b, err := cmd.Output() - if err != nil { - return nil, err - } - data = b - case plainKeyMode: - data = []byte(name) - default: - return nil, errors.New("unknown keymode") - } - return []byte(strings.TrimSpace(string(data))), nil -} - -// IsClipOSC52 indicates if OSC52 mode is enabled -func IsClipOSC52() (bool, error) { - return isYesNoEnv(false, clipOSC52Env) -} - -// IsNoTOTP indicates if TOTP is disabled -func IsNoTOTP() (bool, error) { - return isYesNoEnv(false, noTOTPEnv) -} - -// IsReadOnly indicates to operate in readonly, no writing to file allowed -func IsReadOnly() (bool, error) { - return isYesNoEnv(false, readOnlyEnv) -} - -// IsNoClipEnabled indicates if clipboard mode is enabled. -func IsNoClipEnabled() (bool, error) { - return isYesNoEnv(false, noClipEnv) -} - -// IsNoColorEnabled indicates if the flag is set to disable color. -func IsNoColorEnabled() (bool, error) { - return isYesNoEnv(false, noColorEnv) -} - -// IsInteractive indicates if running as a user UI experience. -func IsInteractive() (bool, error) { - return isYesNoEnv(true, interactiveEnv) -} - -// TOTPToken gets the name of the totp special case tokens -func TOTPToken() string { - return EnvironOrDefault(fieldTOTPEnv, defaultTOTPField) -} - -func (o environmentOutput) formatEnvironmentVariable(required bool, name, val, desc string, allowed []string) string { - value := val - if o.showValues { - value = os.Getenv(name) - } - if len(value) == 0 { - value = "(unset)" - } - description := strings.ReplaceAll(desc, "\n", "\n ") - return fmt.Sprintf("\n%s\n %s\n\n required: %t\n value: %s\n options: %s\n", name, description, required, value, strings.Join(allowed, "|")) -} - // PlatformSet returns the list of possible platforms func PlatformSet() []string { return []string{ @@ -264,35 +45,6 @@ func PlatformSet() []string { } } -// ListEnvironmentVariables will print information about env variables and potential/set values -func ListEnvironmentVariables(showValues bool) []string { - e := environmentOutput{showValues: showValues} - var results []string - results = append(results, e.formatEnvironmentVariable(true, StoreEnv, "", "directory to the database file", []string{"file"})) - results = append(results, e.formatEnvironmentVariable(true, keyModeEnv, commandKeyMode, "how to retrieve the database store password", []string{commandKeyMode, plainKeyMode})) - results = append(results, e.formatEnvironmentVariable(true, keyEnv, "", fmt.Sprintf("the database key ('%s' mode) or command to run ('%s' mode)\nto retrieve the database password", plainKeyMode, commandKeyMode), []string{commandArgsExample, "password"})) - results = append(results, e.formatEnvironmentVariable(false, noClipEnv, no, "disable clipboard operations", isYesNoArgs)) - results = append(results, e.formatEnvironmentVariable(false, noColorEnv, no, "disable terminal colors", isYesNoArgs)) - results = append(results, e.formatEnvironmentVariable(false, interactiveEnv, yes, "enable interactive mode", isYesNoArgs)) - results = append(results, e.formatEnvironmentVariable(false, readOnlyEnv, no, "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})) - results = append(results, e.formatEnvironmentVariable(false, clipMaxEnv, fmt.Sprintf("%d", defaultMaxClipboard), "override the amount of time before totp clears the clipboard (e.g. 10),\nmust be an integer", []string{"integer"})) - results = append(results, e.formatEnvironmentVariable(false, PlatformEnv, detectedValue, "override the detected platform", PlatformSet())) - results = append(results, e.formatEnvironmentVariable(false, noTOTPEnv, no, "disable TOTP integrations", isYesNoArgs)) - results = append(results, e.formatEnvironmentVariable(false, HookDirEnv, "", "the path to hooks to execute on actions against the database", []string{"directory"})) - results = append(results, e.formatEnvironmentVariable(false, clipOSC52Env, no, "enable OSC52 clipboard mode", isYesNoArgs)) - results = append(results, e.formatEnvironmentVariable(false, KeyFileEnv, "", "additional keyfile to access/protect the database", []string{"keyfile"})) - results = append(results, e.formatEnvironmentVariable(false, ModTimeEnv, ModTimeFormat, fmt.Sprintf("input modification time to set for the entry\n(expected format: %s)", ModTimeFormat), []string{"modtime"})) - results = append(results, e.formatEnvironmentVariable(false, JSONDataOutputEnv, string(JSONDataOutputHash), fmt.Sprintf("changes what the data field in JSON outputs will contain\nuse '%s' with CAUTION", JSONDataOutputRaw), []string{string(JSONDataOutputRaw), string(JSONDataOutputHash), string(JSONDataOutputBlank)})) - results = append(results, e.formatEnvironmentVariable(false, hashJSONLengthEnv, fmt.Sprintf("%d", defaultHashLength), fmt.Sprintf("maximum hash length the JSON output should contain\nwhen '%s' mode is set for JSON output", JSONDataOutputHash), []string{"integer"})) - return results -} - // EnvironOrDefault will get the environment value OR default if env is not set. func EnvironOrDefault(envKey, defaultValue string) string { val := os.Getenv(envKey) @@ -315,3 +67,30 @@ func isYesNoEnv(defaultValue bool, envKey string) (bool, error) { return false, fmt.Errorf("invalid yes/no env value for %s", envKey) } + +func getPositiveIntEnv(defaultVal int, key, desc string, canBeZero bool) (int, error) { + val := defaultVal + use := os.Getenv(key) + if use != "" { + i, err := strconv.Atoi(use) + if err != nil { + return -1, err + } + invalid := false + check := "" + if canBeZero { + check = "=" + } + switch i { + case 0: + invalid = !canBeZero + default: + invalid = i < 0 + } + if invalid { + return -1, fmt.Errorf("%s must be >%s 0", desc, check) + } + val = i + } + return val, nil +} diff --git a/internal/inputs/env_test.go b/internal/inputs/env_test.go @@ -1,204 +1,12 @@ package inputs_test import ( - "fmt" "os" - "strings" "testing" "github.com/enckse/lockbox/internal/inputs" ) -func checkYesNo(key string, t *testing.T, cb func() (bool, error), onEmpty bool) { - os.Setenv(key, "yes") - c, err := cb() - if err != nil { - t.Errorf("invalid error: %v", err) - } - if !c { - t.Error("invalid setting") - } - os.Setenv(key, "") - c, err = cb() - if err != nil { - t.Errorf("invalid error: %v", err) - } - if c != onEmpty { - t.Error("invalid setting") - } - os.Setenv(key, "no") - c, err = cb() - if err != nil { - t.Errorf("invalid error: %v", err) - } - if c { - t.Error("invalid setting") - } - os.Setenv(key, "afoieae") - _, err = cb() - if err == nil || err.Error() != fmt.Sprintf("invalid yes/no env value for %s", key) { - t.Errorf("unexpected error: %v", err) - } -} - -func TestColorSetting(t *testing.T) { - checkYesNo("LOCKBOX_NOCOLOR", t, inputs.IsNoColorEnabled, false) -} - -func TestInteractiveSetting(t *testing.T) { - checkYesNo("LOCKBOX_INTERACTIVE", t, inputs.IsInteractive, true) -} - -func TestIsReadOnly(t *testing.T) { - checkYesNo("LOCKBOX_READONLY", t, inputs.IsReadOnly, false) -} - -func TestIsOSC52(t *testing.T) { - checkYesNo("LOCKBOX_CLIP_OSC52", t, inputs.IsClipOSC52, false) -} - -func TestIsNoTOTP(t *testing.T) { - checkYesNo("LOCKBOX_NOTOTP", t, inputs.IsNoTOTP, false) -} - -func TestIsNoClip(t *testing.T) { - checkYesNo("LOCKBOX_NOCLIP", t, inputs.IsNoClipEnabled, false) -} - -func TestTOTP(t *testing.T) { - os.Setenv("LOCKBOX_TOTP", "abc") - if inputs.TOTPToken() != "abc" { - t.Error("invalid totp token field") - } - os.Setenv("LOCKBOX_TOTP", "") - if inputs.TOTPToken() != "totp" { - t.Error("invalid totp token field") - } -} - -func TestGetKey(t *testing.T) { - os.Setenv("LOCKBOX_KEY", "aaa") - os.Setenv("LOCKBOX_KEYMODE", "lak;jfea") - if _, err := inputs.GetKey(); err.Error() != "unknown keymode" { - t.Errorf("invalid error: %v", err) - } - os.Setenv("LOCKBOX_KEYMODE", "plaintext") - os.Setenv("LOCKBOX_KEY", "") - if _, err := inputs.GetKey(); err.Error() != "no key given" { - t.Errorf("invalid error: %v", err) - } - os.Setenv("LOCKBOX_KEY", "key") - k, err := inputs.GetKey() - if err != nil || string(k) != "key" { - 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 := inputs.GetKey(); err == nil { - t.Error("should have failed") - } -} - -func TestListVariables(t *testing.T) { - known := make(map[string]struct{}) - for _, v := range inputs.ListEnvironmentVariables(false) { - trim := strings.Split(strings.TrimSpace(v), " ")[0] - if !strings.HasPrefix(trim, "LOCKBOX_") { - t.Errorf("invalid env: %s", v) - } - if _, ok := known[trim]; ok { - t.Errorf("invalid re-used env: %s", trim) - } - known[trim] = struct{}{} - } - l := len(known) - if l != 22 { - t.Errorf("invalid env count, outdated? %d", l) - } -} - -func TestReKey(t *testing.T) { - _, err := inputs.GetReKey([]string{}) - if err == nil || err.Error() != "missing required arguments for rekey: LOCKBOX_KEY= LOCKBOX_KEYFILE= LOCKBOX_KEYMODE= LOCKBOX_STORE=" { - t.Errorf("failed: %v", err) - } - _, err = inputs.GetReKey([]string{"-store", "abc"}) - if err == nil || err.Error() != "missing required arguments for rekey: LOCKBOX_KEY= LOCKBOX_KEYFILE= LOCKBOX_KEYMODE= LOCKBOX_STORE=abc" { - t.Errorf("failed: %v", err) - } - out, err := inputs.GetReKey([]string{"-store", "abc", "-key", "aaa"}) - if err != nil { - t.Errorf("failed: %v", err) - } - if fmt.Sprintf("%v", out) != "[LOCKBOX_KEY=aaa LOCKBOX_KEYFILE= LOCKBOX_KEYMODE= LOCKBOX_STORE=abc]" { - t.Errorf("invalid env: %v", out) - } - out, err = inputs.GetReKey([]string{"-store", "abc", "-keyfile", "aaa"}) - if err != nil { - t.Errorf("failed: %v", err) - } - if fmt.Sprintf("%v", out) != "[LOCKBOX_KEY= LOCKBOX_KEYFILE=aaa LOCKBOX_KEYMODE= LOCKBOX_STORE=abc]" { - t.Errorf("invalid env: %v", out) - } - os.Setenv("LOCKBOX_KEY_NEW", "") - os.Setenv("LOCKBOX_STORE_NEW", "") - os.Setenv("LOCKBOX_KEY_NEW", "") - os.Setenv("LOCKBOX_KEYFILE_NEW", "") -} - -func TestGetClipboardMax(t *testing.T) { - os.Setenv("LOCKBOX_CLIP_MAX", "") - defer os.Clearenv() - max, err := inputs.GetClipboardMax() - if err != nil || max != 45 { - t.Error("invalid clipboard read") - } - os.Setenv("LOCKBOX_CLIP_MAX", "1") - max, err = inputs.GetClipboardMax() - if err != nil || max != 1 { - t.Error("invalid clipboard read") - } - os.Setenv("LOCKBOX_CLIP_MAX", "-1") - if _, err := inputs.GetClipboardMax(); err == nil || err.Error() != "clipboard max time must be > 0" { - t.Errorf("invalid err: %v", err) - } - os.Setenv("LOCKBOX_CLIP_MAX", "alk;ja") - if _, err := inputs.GetClipboardMax(); err == nil || err.Error() != "strconv.Atoi: parsing \"alk;ja\": invalid syntax" { - t.Errorf("invalid err: %v", err) - } - os.Setenv("LOCKBOX_CLIP_MAX", "0") - if _, err := inputs.GetClipboardMax(); err == nil || err.Error() != "clipboard max time must be > 0" { - t.Errorf("invalid err: %v", err) - } -} - -func TestGetHashLength(t *testing.T) { - os.Setenv("LOCKBOX_JSON_DATA_OUTPUT_HASH_LENGTH", "") - defer os.Clearenv() - val, err := inputs.GetHashLength() - if err != nil || val != 0 { - t.Error("invalid hash read") - } - os.Setenv("LOCKBOX_JSON_DATA_OUTPUT_HASH_LENGTH", "1") - val, err = inputs.GetHashLength() - if err != nil || val != 1 { - t.Error("invalid hash read") - } - os.Setenv("LOCKBOX_JSON_DATA_OUTPUT_HASH_LENGTH", "0") - val, err = inputs.GetHashLength() - if err != nil || val != 0 { - t.Error("invalid hash read") - } - os.Setenv("LOCKBOX_JSON_DATA_OUTPUT_HASH_LENGTH", "-1") - if _, err := inputs.GetHashLength(); err == nil || err.Error() != "hash length must be >= 0" { - t.Errorf("invalid err: %v", err) - } - os.Setenv("LOCKBOX_JSON_DATA_OUTPUT_HASH_LENGTH", "-aoaofaij;p1") - if _, err := inputs.GetHashLength(); err == nil || err.Error() != "strconv.Atoi: parsing \"-aoaofaij;p1\": invalid syntax" { - t.Errorf("invalid err: %v", err) - } -} - func TestEnvDefault(t *testing.T) { os.Clearenv() val := inputs.EnvironOrDefault("TEST", "value") @@ -216,3 +24,9 @@ func TestEnvDefault(t *testing.T) { t.Error("invalid read") } } + +func TestPlatformSet(t *testing.T) { + if len(inputs.PlatformSet()) != 4 { + t.Error("invalid platform set") + } +} diff --git a/internal/inputs/vars.go b/internal/inputs/vars.go @@ -0,0 +1,235 @@ +// Package inputs handles user inputs/UI elements. +package inputs + +import ( + "errors" + "flag" + "fmt" + "os" + "os/exec" + "sort" + "strings" + "time" +) + +const ( + prefixKey = "LOCKBOX_" + noClipEnv = prefixKey + "NOCLIP" + noColorEnv = prefixKey + "NOCOLOR" + interactiveEnv = prefixKey + "INTERACTIVE" + readOnlyEnv = prefixKey + "READONLY" + fieldTOTPEnv = prefixKey + "TOTP" + clipBaseEnv = prefixKey + "CLIP_" + formatTOTPEnv = fieldTOTPEnv + "_FORMAT" + keyModeEnv = prefixKey + "KEYMODE" + keyEnv = prefixKey + "KEY" + // KeyFileEnv is an OPTIONAL keyfile for the database + KeyFileEnv = prefixKey + "KEYFILE" + plainKeyMode = "plaintext" + commandKeyMode = "command" + // PlatformEnv is the platform lb is running on. + PlatformEnv = prefixKey + "PLATFORM" + // StoreEnv is the location of the filesystem store that lb is operating on. + StoreEnv = prefixKey + "STORE" + 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 + ClipCopyEnv = clipBaseEnv + "COPY" + clipOSC52Env = clipBaseEnv + "OSC52" + defaultTOTPField = "totp" + commandArgsExample = "[cmd args...]" + defaultMaxClipboard = 45 + detectedValue = "(detected)" + noTOTPEnv = prefixKey + "NOTOTP" + // HookDirEnv represents a stored location for user hooks + HookDirEnv = prefixKey + "HOOKDIR" + // ModTimeEnv is modtime override ability for entries + ModTimeEnv = prefixKey + "SET_MODTIME" + // ModTimeFormat is the expected modtime format + ModTimeFormat = time.RFC3339 + // MaxTOTPTimeDefault is the max TOTP time to run (default) + MaxTOTPTimeDefault = "120" + // JSONDataOutputEnv controls how JSON is output + JSONDataOutputEnv = prefixKey + "JSON_DATA_OUTPUT" + defaultHashLength = 0 + hashJSONLengthEnv = JSONDataOutputEnv + "_HASH_LENGTH" +) + +type ( + environmentOutput struct { + showValues bool + } +) + +// GetReKey will get the rekey environment settings +func GetReKey(args []string) ([]string, error) { + set := flag.NewFlagSet("rekey", flag.ExitOnError) + store := set.String("store", "", "new store") + key := set.String("key", "", "new key") + keyFile := set.String("keyfile", "", "new keyfile") + keyMode := set.String("keymode", "", "new keymode") + if err := set.Parse(args); err != nil { + return nil, err + } + mapped := map[string]string{ + keyModeEnv: *keyMode, + keyEnv: *key, + KeyFileEnv: *keyFile, + StoreEnv: *store, + } + hasStore := false + hasKey := false + hasKeyFile := false + var out []string + for k, val := range mapped { + if val != "" { + switch k { + case StoreEnv: + hasStore = true + case keyEnv: + hasKey = true + case KeyFileEnv: + hasKeyFile = true + } + } + out = append(out, fmt.Sprintf("%s=%s", k, val)) + } + sort.Strings(out) + if !hasStore || (!hasKey && !hasKeyFile) { + return nil, fmt.Errorf("missing required arguments for rekey: %s", strings.Join(out, " ")) + } + return out, nil +} + +// GetClipboardMax will get max time to keep an entry in the clipboard before clearing +func GetClipboardMax() (int, error) { + return getPositiveIntEnv(defaultMaxClipboard, clipMaxEnv, "clipboard max time", false) +} + +// GetHashLength will get the maximum hash length allowed in JSON output hashing mode +func GetHashLength() (int, error) { + return getPositiveIntEnv(defaultHashLength, hashJSONLengthEnv, "hash length", true) +} + +// GetKey will get the encryption key setup for lb +func GetKey() ([]byte, error) { + useKeyMode := os.Getenv(keyModeEnv) + if useKeyMode == "" { + useKeyMode = commandKeyMode + } + useKey := os.Getenv(keyEnv) + if useKey == "" { + return nil, errors.New("no key given") + } + b, err := getKey(useKeyMode, useKey) + if err != nil { + return nil, err + } + if len(b) == 0 { + return nil, errors.New("key is empty") + } + return b, nil +} + +func getKey(keyMode, name string) ([]byte, error) { + var data []byte + switch keyMode { + case commandKeyMode: + parts, err := Shlex(name) + if err != nil { + return nil, err + } + cmd := exec.Command(parts[0], parts[1:]...) + b, err := cmd.Output() + if err != nil { + return nil, err + } + data = b + case plainKeyMode: + data = []byte(name) + default: + return nil, errors.New("unknown keymode") + } + return []byte(strings.TrimSpace(string(data))), nil +} + +// IsClipOSC52 indicates if OSC52 mode is enabled +func IsClipOSC52() (bool, error) { + return isYesNoEnv(false, clipOSC52Env) +} + +// IsNoTOTP indicates if TOTP is disabled +func IsNoTOTP() (bool, error) { + return isYesNoEnv(false, noTOTPEnv) +} + +// IsReadOnly indicates to operate in readonly, no writing to file allowed +func IsReadOnly() (bool, error) { + return isYesNoEnv(false, readOnlyEnv) +} + +// IsNoClipEnabled indicates if clipboard mode is enabled. +func IsNoClipEnabled() (bool, error) { + return isYesNoEnv(false, noClipEnv) +} + +// IsNoColorEnabled indicates if the flag is set to disable color. +func IsNoColorEnabled() (bool, error) { + return isYesNoEnv(false, noColorEnv) +} + +// IsInteractive indicates if running as a user UI experience. +func IsInteractive() (bool, error) { + return isYesNoEnv(true, interactiveEnv) +} + +// TOTPToken gets the name of the totp special case tokens +func TOTPToken() string { + return EnvironOrDefault(fieldTOTPEnv, defaultTOTPField) +} + +func (o environmentOutput) formatEnvironmentVariable(required bool, name, val, desc string, allowed []string) string { + value := val + if o.showValues { + value = os.Getenv(name) + } + if len(value) == 0 { + value = "(unset)" + } + description := strings.ReplaceAll(desc, "\n", "\n ") + return fmt.Sprintf("\n%s\n %s\n\n required: %t\n value: %s\n options: %s\n", name, description, required, value, strings.Join(allowed, "|")) +} + +// ListEnvironmentVariables will print information about env variables and potential/set values +func ListEnvironmentVariables(showValues bool) []string { + e := environmentOutput{showValues: showValues} + var results []string + results = append(results, e.formatEnvironmentVariable(true, StoreEnv, "", "directory to the database file", []string{"file"})) + results = append(results, e.formatEnvironmentVariable(true, keyModeEnv, commandKeyMode, "how to retrieve the database store password", []string{commandKeyMode, plainKeyMode})) + results = append(results, e.formatEnvironmentVariable(true, keyEnv, "", fmt.Sprintf("the database key ('%s' mode) or command to run ('%s' mode)\nto retrieve the database password", plainKeyMode, commandKeyMode), []string{commandArgsExample, "password"})) + results = append(results, e.formatEnvironmentVariable(false, noClipEnv, no, "disable clipboard operations", isYesNoArgs)) + results = append(results, e.formatEnvironmentVariable(false, noColorEnv, no, "disable terminal colors", isYesNoArgs)) + results = append(results, e.formatEnvironmentVariable(false, interactiveEnv, yes, "enable interactive mode", isYesNoArgs)) + results = append(results, e.formatEnvironmentVariable(false, readOnlyEnv, no, "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})) + results = append(results, e.formatEnvironmentVariable(false, clipMaxEnv, fmt.Sprintf("%d", defaultMaxClipboard), "override the amount of time before totp clears the clipboard (e.g. 10),\nmust be an integer", []string{"integer"})) + results = append(results, e.formatEnvironmentVariable(false, PlatformEnv, detectedValue, "override the detected platform", PlatformSet())) + results = append(results, e.formatEnvironmentVariable(false, noTOTPEnv, no, "disable TOTP integrations", isYesNoArgs)) + results = append(results, e.formatEnvironmentVariable(false, HookDirEnv, "", "the path to hooks to execute on actions against the database", []string{"directory"})) + results = append(results, e.formatEnvironmentVariable(false, clipOSC52Env, no, "enable OSC52 clipboard mode", isYesNoArgs)) + results = append(results, e.formatEnvironmentVariable(false, KeyFileEnv, "", "additional keyfile to access/protect the database", []string{"keyfile"})) + results = append(results, e.formatEnvironmentVariable(false, ModTimeEnv, ModTimeFormat, fmt.Sprintf("input modification time to set for the entry\n(expected format: %s)", ModTimeFormat), []string{"modtime"})) + results = append(results, e.formatEnvironmentVariable(false, JSONDataOutputEnv, string(JSONDataOutputHash), fmt.Sprintf("changes what the data field in JSON outputs will contain\nuse '%s' with CAUTION", JSONDataOutputRaw), []string{string(JSONDataOutputRaw), string(JSONDataOutputHash), string(JSONDataOutputBlank)})) + results = append(results, e.formatEnvironmentVariable(false, hashJSONLengthEnv, fmt.Sprintf("%d", defaultHashLength), fmt.Sprintf("maximum hash length the JSON output should contain\nwhen '%s' mode is set for JSON output", JSONDataOutputHash), []string{"integer"})) + return results +} diff --git a/internal/inputs/vars_test.go b/internal/inputs/vars_test.go @@ -0,0 +1,200 @@ +package inputs_test + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/enckse/lockbox/internal/inputs" +) + +func checkYesNo(key string, t *testing.T, cb func() (bool, error), onEmpty bool) { + os.Setenv(key, "yes") + c, err := cb() + if err != nil { + t.Errorf("invalid error: %v", err) + } + if !c { + t.Error("invalid setting") + } + os.Setenv(key, "") + c, err = cb() + if err != nil { + t.Errorf("invalid error: %v", err) + } + if c != onEmpty { + t.Error("invalid setting") + } + os.Setenv(key, "no") + c, err = cb() + if err != nil { + t.Errorf("invalid error: %v", err) + } + if c { + t.Error("invalid setting") + } + os.Setenv(key, "afoieae") + _, err = cb() + if err == nil || err.Error() != fmt.Sprintf("invalid yes/no env value for %s", key) { + t.Errorf("unexpected error: %v", err) + } +} + +func TestColorSetting(t *testing.T) { + checkYesNo("LOCKBOX_NOCOLOR", t, inputs.IsNoColorEnabled, false) +} + +func TestInteractiveSetting(t *testing.T) { + checkYesNo("LOCKBOX_INTERACTIVE", t, inputs.IsInteractive, true) +} + +func TestIsReadOnly(t *testing.T) { + checkYesNo("LOCKBOX_READONLY", t, inputs.IsReadOnly, false) +} + +func TestIsOSC52(t *testing.T) { + checkYesNo("LOCKBOX_CLIP_OSC52", t, inputs.IsClipOSC52, false) +} + +func TestIsNoTOTP(t *testing.T) { + checkYesNo("LOCKBOX_NOTOTP", t, inputs.IsNoTOTP, false) +} + +func TestIsNoClip(t *testing.T) { + checkYesNo("LOCKBOX_NOCLIP", t, inputs.IsNoClipEnabled, false) +} + +func TestTOTP(t *testing.T) { + os.Setenv("LOCKBOX_TOTP", "abc") + if inputs.TOTPToken() != "abc" { + t.Error("invalid totp token field") + } + os.Setenv("LOCKBOX_TOTP", "") + if inputs.TOTPToken() != "totp" { + t.Error("invalid totp token field") + } +} + +func TestGetKey(t *testing.T) { + os.Setenv("LOCKBOX_KEY", "aaa") + os.Setenv("LOCKBOX_KEYMODE", "lak;jfea") + if _, err := inputs.GetKey(); err.Error() != "unknown keymode" { + t.Errorf("invalid error: %v", err) + } + os.Setenv("LOCKBOX_KEYMODE", "plaintext") + os.Setenv("LOCKBOX_KEY", "") + if _, err := inputs.GetKey(); err.Error() != "no key given" { + t.Errorf("invalid error: %v", err) + } + os.Setenv("LOCKBOX_KEY", "key") + k, err := inputs.GetKey() + if err != nil || string(k) != "key" { + 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 := inputs.GetKey(); err == nil { + t.Error("should have failed") + } +} + +func TestListVariables(t *testing.T) { + known := make(map[string]struct{}) + for _, v := range inputs.ListEnvironmentVariables(false) { + trim := strings.Split(strings.TrimSpace(v), " ")[0] + if !strings.HasPrefix(trim, "LOCKBOX_") { + t.Errorf("invalid env: %s", v) + } + if _, ok := known[trim]; ok { + t.Errorf("invalid re-used env: %s", trim) + } + known[trim] = struct{}{} + } + l := len(known) + if l != 22 { + t.Errorf("invalid env count, outdated? %d", l) + } +} + +func TestReKey(t *testing.T) { + _, err := inputs.GetReKey([]string{}) + if err == nil || err.Error() != "missing required arguments for rekey: LOCKBOX_KEY= LOCKBOX_KEYFILE= LOCKBOX_KEYMODE= LOCKBOX_STORE=" { + t.Errorf("failed: %v", err) + } + _, err = inputs.GetReKey([]string{"-store", "abc"}) + if err == nil || err.Error() != "missing required arguments for rekey: LOCKBOX_KEY= LOCKBOX_KEYFILE= LOCKBOX_KEYMODE= LOCKBOX_STORE=abc" { + t.Errorf("failed: %v", err) + } + out, err := inputs.GetReKey([]string{"-store", "abc", "-key", "aaa"}) + if err != nil { + t.Errorf("failed: %v", err) + } + if fmt.Sprintf("%v", out) != "[LOCKBOX_KEY=aaa LOCKBOX_KEYFILE= LOCKBOX_KEYMODE= LOCKBOX_STORE=abc]" { + t.Errorf("invalid env: %v", out) + } + out, err = inputs.GetReKey([]string{"-store", "abc", "-keyfile", "aaa"}) + if err != nil { + t.Errorf("failed: %v", err) + } + if fmt.Sprintf("%v", out) != "[LOCKBOX_KEY= LOCKBOX_KEYFILE=aaa LOCKBOX_KEYMODE= LOCKBOX_STORE=abc]" { + t.Errorf("invalid env: %v", out) + } + os.Setenv("LOCKBOX_KEY_NEW", "") + os.Setenv("LOCKBOX_STORE_NEW", "") + os.Setenv("LOCKBOX_KEY_NEW", "") + os.Setenv("LOCKBOX_KEYFILE_NEW", "") +} + +func TestGetClipboardMax(t *testing.T) { + os.Setenv("LOCKBOX_CLIP_MAX", "") + defer os.Clearenv() + max, err := inputs.GetClipboardMax() + if err != nil || max != 45 { + t.Error("invalid clipboard read") + } + os.Setenv("LOCKBOX_CLIP_MAX", "1") + max, err = inputs.GetClipboardMax() + if err != nil || max != 1 { + t.Error("invalid clipboard read") + } + os.Setenv("LOCKBOX_CLIP_MAX", "-1") + if _, err := inputs.GetClipboardMax(); err == nil || err.Error() != "clipboard max time must be > 0" { + t.Errorf("invalid err: %v", err) + } + os.Setenv("LOCKBOX_CLIP_MAX", "alk;ja") + if _, err := inputs.GetClipboardMax(); err == nil || err.Error() != "strconv.Atoi: parsing \"alk;ja\": invalid syntax" { + t.Errorf("invalid err: %v", err) + } + os.Setenv("LOCKBOX_CLIP_MAX", "0") + if _, err := inputs.GetClipboardMax(); err == nil || err.Error() != "clipboard max time must be > 0" { + t.Errorf("invalid err: %v", err) + } +} + +func TestGetHashLength(t *testing.T) { + os.Setenv("LOCKBOX_JSON_DATA_OUTPUT_HASH_LENGTH", "") + defer os.Clearenv() + val, err := inputs.GetHashLength() + if err != nil || val != 0 { + t.Error("invalid hash read") + } + os.Setenv("LOCKBOX_JSON_DATA_OUTPUT_HASH_LENGTH", "1") + val, err = inputs.GetHashLength() + if err != nil || val != 1 { + t.Error("invalid hash read") + } + os.Setenv("LOCKBOX_JSON_DATA_OUTPUT_HASH_LENGTH", "0") + val, err = inputs.GetHashLength() + if err != nil || val != 0 { + t.Error("invalid hash read") + } + os.Setenv("LOCKBOX_JSON_DATA_OUTPUT_HASH_LENGTH", "-1") + if _, err := inputs.GetHashLength(); err == nil || err.Error() != "hash length must be >= 0" { + t.Errorf("invalid err: %v", err) + } + os.Setenv("LOCKBOX_JSON_DATA_OUTPUT_HASH_LENGTH", "-aoaofaij;p1") + if _, err := inputs.GetHashLength(); err == nil || err.Error() != "strconv.Atoi: parsing \"-aoaofaij;p1\": invalid syntax" { + t.Errorf("invalid err: %v", err) + } +}