lockbox

password manager
Log | Files | Refs | README | LICENSE

commit d6c74ab155c60d431d0658edabe199f475c61ccf
parent 03df3eea7f46c8a3f81d624a684f01e321b05027
Author: Sean Enck <sean@ttypty.com>
Date:   Sat,  7 Dec 2024 10:14:37 -0500

refactor config -> core to contain less common code needs

Diffstat:
Minternal/app/core.go | 4++--
Minternal/app/rekey.go | 31+++++++++++++++++++++++++++++--
Minternal/app/rekey_test.go | 16++++++++++++++++
Minternal/app/totp.go | 5+++--
Minternal/backend/query.go | 11++++++-----
Minternal/config/core.go | 364+++++++++++++++++--------------------------------------------------------------
Minternal/config/core_test.go | 92+++++++++++++++++++++++--------------------------------------------------------
Ainternal/config/env.go | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/config/vars.go | 127+++++--------------------------------------------------------------------------
Minternal/config/vars_test.go | 74--------------------------------------------------------------------------
Ainternal/core/colors.go | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/core/colors_test.go | 40++++++++++++++++++++++++++++++++++++++++
Ainternal/core/core.go | 18++++++++++++++++++
Ainternal/core/json.go | 41+++++++++++++++++++++++++++++++++++++++++
Ainternal/core/json_test.go | 31+++++++++++++++++++++++++++++++
Ainternal/core/platforms.go | 28++++++++++++++++++++++++++++
Ainternal/core/platforms_test.go | 13+++++++++++++
Minternal/platform/clipboard.go | 11++++++-----
Minternal/platform/clipboard_test.go | 8++++----
Ainternal/platform/detect.go | 48++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/platform/detect_test.go | 29+++++++++++++++++++++++++++++
21 files changed, 645 insertions(+), 565 deletions(-)

diff --git a/internal/app/core.go b/internal/app/core.go @@ -254,8 +254,8 @@ func Usage(verbose bool, exe string) ([]string, error) { CompletionsEnv: config.EnvDefaultCompletionKey, ExampleTOML: config.ExampleTOML, } - document.ReKey.KeyFile = setDocFlag(config.ReKeyFlags.KeyFile) - document.ReKey.NoKey = config.ReKeyFlags.NoKey + document.ReKey.KeyFile = setDocFlag(reKeyFlags.KeyFile) + document.ReKey.NoKey = reKeyFlags.NoKey document.Hooks.Mode.Pre = string(backend.HookPre) document.Hooks.Mode.Post = string(backend.HookPost) document.Hooks.Action.Insert = string(backend.InsertAction) diff --git a/internal/app/rekey.go b/internal/app/rekey.go @@ -2,13 +2,25 @@ package app import ( - "github.com/seanenck/lockbox/internal/config" + "errors" + "flag" + "strings" ) +var reKeyFlags = struct { + KeyFile string + NoKey string +}{"keyfile", "nokey"} + +type reKeyArgs struct { + NoKey bool + KeyFile string +} + // ReKey handles entry rekeying func ReKey(cmd UserInputOptions) error { args := cmd.Args() - vars, err := config.GetReKey(args) + vars, err := readArgs(args) if err != nil { return err } @@ -28,3 +40,18 @@ func ReKey(cmd UserInputOptions) error { } return cmd.Transaction().ReKey(pass, vars.KeyFile) } + +func readArgs(args []string) (reKeyArgs, error) { + set := flag.NewFlagSet("rekey", flag.ExitOnError) + keyFile := set.String(reKeyFlags.KeyFile, "", "new keyfile") + noKey := set.Bool(reKeyFlags.NoKey, false, "disable password/key credential") + if err := set.Parse(args); err != nil { + return reKeyArgs{}, err + } + noPass := *noKey + file := *keyFile + if strings.TrimSpace(file) == "" && noPass { + return reKeyArgs{}, errors.New("a key or keyfile must be passed for rekey") + } + return reKeyArgs{KeyFile: file, NoKey: noPass}, nil +} diff --git a/internal/app/rekey_test.go b/internal/app/rekey_test.go @@ -75,3 +75,19 @@ func TestReKeyPipe(t *testing.T) { t.Errorf("invalid error: %v", err) } } + +func TestReKeyFlags(t *testing.T) { + newMockCommand(t) + mock := &mockKeyer{} + mock.t = t + mock.args = []string{"-nokey"} + if err := app.ReKey(mock); err == nil || err.Error() != "a key or keyfile must be passed for rekey" { + t.Errorf("invalid error: %v", err) + } + mock.args = []string{"-nokey", "-keyfile", "blla"} + mock.confirm = true + mock.pipe = false + if err := app.ReKey(mock); err == nil || err.Error() != "no keyfile found on disk" { + t.Errorf("invalid error: %v", err) + } +} diff --git a/internal/app/totp.go b/internal/app/totp.go @@ -12,6 +12,7 @@ import ( "github.com/seanenck/lockbox/internal/backend" "github.com/seanenck/lockbox/internal/config" + "github.com/seanenck/lockbox/internal/core" "github.com/seanenck/lockbox/internal/platform" ) @@ -75,12 +76,12 @@ func clearFunc() { fmt.Print("\033[H\033[2J") } -func colorWhenRules() ([]config.ColorWindow, error) { +func colorWhenRules() ([]core.ColorWindow, error) { envTime := config.EnvTOTPColorBetween.Get() if envTime == config.TOTPDefaultBetween { return config.TOTPDefaultColorWindow, nil } - return config.ParseColorWindow(envTime) + return core.ParseColorWindow(envTime) } func (w totpWrapper) generateCode() (string, error) { diff --git a/internal/backend/query.go b/internal/backend/query.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/seanenck/lockbox/internal/config" + "github.com/seanenck/lockbox/internal/core" "github.com/tobischo/gokeepasslib/v3" ) @@ -174,16 +175,16 @@ func (t *Transaction) QueryCallback(args QueryOptions) (QuerySeq2, error) { if err != nil { return nil, err } - jsonMode := config.JSONOutputs.Blank + jsonMode := core.JSONOutputs.Blank if args.Values == JSONValue { - m, err := config.ParseJSONOutput() + m, err := core.ParseJSONOutput(config.EnvJSONMode.Get()) if err != nil { return nil, err } jsonMode = m } var hashLength int - if jsonMode == config.JSONOutputs.Hash { + if jsonMode == core.JSONOutputs.Hash { hashLength, err = config.EnvJSONHashLength.Get() if err != nil { return nil, err @@ -202,9 +203,9 @@ func (t *Transaction) QueryCallback(args QueryOptions) (QuerySeq2, error) { case JSONValue: data := "" switch jsonMode { - case config.JSONOutputs.Raw: + case core.JSONOutputs.Raw: data = val - case config.JSONOutputs.Hash: + case core.JSONOutputs.Hash: data = fmt.Sprintf("%x", sha512.Sum512([]byte(val))) if hashLength > 0 && len(data) > hashLength { data = data[0:hashLength] diff --git a/internal/config/core.go b/internal/config/core.go @@ -3,29 +3,24 @@ package config import ( "bytes" - "errors" "fmt" + "net/url" "os" - "os/exec" "path/filepath" - "reflect" "sort" - "strconv" "strings" + "time" + "github.com/seanenck/lockbox/internal/core" "mvdan.cc/sh/v3/shell" ) const ( - colorWindowDelimiter = " " - colorWindowSpan = ":" - exampleColorWindow = "start" + colorWindowSpan + "end" - yes = "true" - no = "false" - detectEnvironment = "detect" - noEnvironment = "none" - tomlFile = "lockbox.toml" - unknownPlatform = "" + yes = "true" + no = "false" + detectEnvironment = "detect" + noEnvironment = "none" + tomlFile = "lockbox.toml" // sub categories clipCategory keyCategory = "CLIP_" totpCategory keyCategory = "TOTP_" @@ -39,89 +34,43 @@ const ( // NoValue are no (off) values NoValue = no // TemplateVariable is used to handle '$' in shell vars (due to expansion) - TemplateVariable = "[%]" - configDirName = "lockbox" - configDir = ".config" - environmentPrefix = "LOCKBOX_" + TemplateVariable = "[%]" + configDirName = "lockbox" + configDir = ".config" + environmentPrefix = "LOCKBOX_" + commandArgsExample = "[cmd args...]" + fileExample = "<file>" + detectedValue = "<detected>" + requiredKeyOrKeyFile = "a key, a key file, or both must be set" + // ModTimeFormat is the expected modtime format + ModTimeFormat = time.RFC3339 + exampleColorWindow = "start" + core.ColorWindowSpan + "end" ) var ( + exampleColorWindows = []string{fmt.Sprintf("[%s]", strings.Join([]string{exampleColorWindow, exampleColorWindow, exampleColorWindow + "..."}, core.ColorWindowDelimiter))} configDirOffsetFile = filepath.Join(configDirName, tomlFile) xdgPaths = []string{configDirOffsetFile, tomlFile} homePaths = []string{filepath.Join(configDir, configDirOffsetFile), filepath.Join(configDir, tomlFile)} - exampleColorWindows = []string{fmt.Sprintf("[%s]", strings.Join([]string{exampleColorWindow, exampleColorWindow, exampleColorWindow + "..."}, colorWindowDelimiter))} registry = map[string]printer{} + // TOTPDefaultColorWindow is the default coloring rules for totp + TOTPDefaultColorWindow = []core.ColorWindow{{Start: 0, End: 5}, {Start: 30, End: 35}} + // TOTPDefaultBetween is the default color window as a string + TOTPDefaultBetween = func() string { + var results []string + for _, w := range TOTPDefaultColorWindow { + results = append(results, fmt.Sprintf("%d%s%d", w.Start, core.ColorWindowSpan, w.End)) + } + return strings.Join(results, core.ColorWindowDelimiter) + }() ) type ( keyCategory string - // JSONOutputMode is the output mode definition - JSONOutputMode string - // SystemPlatform represents the platform lockbox is running on. - SystemPlatform string - environmentBase struct { - subKey string - cat keyCategory - desc string - requirement string - } - environmentDefault[T any] struct { - environmentBase - defaultValue T - } - // EnvironmentInt are environment settings that are integers - EnvironmentInt struct { - environmentDefault[int] - allowZero bool - shortDesc string - } - // EnvironmentBool are environment settings that are booleans - EnvironmentBool struct { - environmentDefault[bool] - } - // EnvironmentString are string-based settings - EnvironmentString struct { - environmentDefault[string] - canDefault bool - allowed []string - } - // EnvironmentCommand are settings that are parsed as shell commands - EnvironmentCommand struct { - environmentBase - } - // EnvironmentFormatter allows for sending a string into a get request - EnvironmentFormatter struct { - environmentBase - allowed string - fxn func(string, string) string - } - printer interface { + printer interface { values() (string, []string) self() environmentBase } - // ColorWindow for handling terminal colors based on timing - ColorWindow struct { - Start int - End int - } - // ReKeyArgs are the arguments for rekeying - ReKeyArgs struct { - NoKey bool - KeyFile string - } - // PlatformTypes defines systems lockbox is known to run on or can run on - PlatformTypes struct { - MacOSPlatform SystemPlatform - LinuxWaylandPlatform SystemPlatform - LinuxXPlatform SystemPlatform - WindowsLinuxPlatform SystemPlatform - } - // JSONOutputTypes indicate how JSON data can be exported for values - JSONOutputTypes struct { - Hash JSONOutputMode - Blank JSONOutputMode - Raw JSONOutputMode - } ) func shlex(in string) ([]string, error) { @@ -140,190 +89,6 @@ func environOrDefault(envKey, defaultValue string) string { return val } -func (e environmentBase) Key() string { - return fmt.Sprintf(environmentPrefix+"%s%s", string(e.cat), e.subKey) -} - -// Get will get the boolean value for the setting -func (e EnvironmentBool) Get() (bool, error) { - return parseStringYesNo(e, getExpand(e.Key())) -} - -func parseStringYesNo(e EnvironmentBool, in string) (bool, error) { - read := strings.ToLower(strings.TrimSpace(in)) - switch read { - case no: - return false, nil - case yes: - return true, nil - case "": - return e.defaultValue, nil - } - - return false, fmt.Errorf("invalid yes/no env value for %s", e.Key()) -} - -// Get will get the integer value for the setting -func (e EnvironmentInt) Get() (int, error) { - val := e.defaultValue - use := getExpand(e.Key()) - if use != "" { - i, err := strconv.Atoi(use) - if err != nil { - return -1, err - } - invalid := false - check := "" - if e.allowZero { - check = "=" - } - switch i { - case 0: - invalid = !e.allowZero - default: - invalid = i < 0 - } - if invalid { - return -1, fmt.Errorf("%s must be >%s 0", e.shortDesc, check) - } - val = i - } - return val, nil -} - -// Get will read the string from the environment -func (e EnvironmentString) Get() string { - if !e.canDefault { - return getExpand(e.Key()) - } - return environOrDefault(e.Key(), e.defaultValue) -} - -// Get will read (and shlex) the value if set -func (e EnvironmentCommand) Get() ([]string, error) { - value := environOrDefault(e.Key(), "") - if strings.TrimSpace(value) == "" { - return nil, nil - } - return shlex(value) -} - -// KeyValue will get the string representation of the key+value -func (e environmentBase) KeyValue(value string) string { - return fmt.Sprintf("%s=%s", e.Key(), value) -} - -// Setenv will do an environment set for the value to key -func (e environmentBase) Set(value string) error { - unset, err := IsUnset(e.Key(), value) - if err != nil { - return err - } - if unset { - return nil - } - return os.Setenv(e.Key(), value) -} - -// Get will retrieve the value with the formatted input included -func (e EnvironmentFormatter) Get(value string) string { - return e.fxn(e.Key(), value) -} - -func (e EnvironmentString) values() (string, []string) { - return e.defaultValue, e.allowed -} - -func (e environmentBase) self() environmentBase { - return e -} - -func (e EnvironmentBool) values() (string, []string) { - val := no - if e.defaultValue { - val = yes - } - return val, []string{yes, no} -} - -func (e EnvironmentInt) values() (string, []string) { - return fmt.Sprintf("%d", e.defaultValue), []string{"<integer>"} -} - -func (e EnvironmentFormatter) values() (string, []string) { - return strings.ReplaceAll(strings.ReplaceAll(EnvTOTPFormat.Get("%s"), "%25s", "%s"), "&", " \\\n &"), []string{e.allowed} -} - -func (e EnvironmentCommand) values() (string, []string) { - return detectedValue, []string{commandArgsExample} -} - -// NewPlatform gets a new system platform. -func NewPlatform() (SystemPlatform, error) { - env := EnvPlatform.Get() - if env != "" { - for _, p := range EnvPlatform.allowed { - if p == env { - return SystemPlatform(p), nil - } - } - return unknownPlatform, errors.New("unknown platform mode") - } - b, err := exec.Command("uname", "-a").Output() - if err != nil { - return unknownPlatform, err - } - raw := strings.ToLower(strings.TrimSpace(string(b))) - parts := strings.Split(raw, " ") - switch parts[0] { - case "darwin": - return Platforms.MacOSPlatform, nil - case "linux": - if strings.Contains(raw, "microsoft-standard-wsl") { - return Platforms.WindowsLinuxPlatform, nil - } - if strings.TrimSpace(getExpand("WAYLAND_DISPLAY")) == "" { - if strings.TrimSpace(getExpand("DISPLAY")) == "" { - return unknownPlatform, errors.New("unable to detect linux clipboard mode") - } - return Platforms.LinuxXPlatform, nil - } - return Platforms.LinuxWaylandPlatform, nil - } - return unknownPlatform, errors.New("unable to detect clipboard mode") -} - -// ParseColorWindow will handle parsing a window of colors for TOTP operations -func ParseColorWindow(windowString string) ([]ColorWindow, error) { - var rules []ColorWindow - for _, item := range strings.Split(windowString, colorWindowDelimiter) { - line := strings.TrimSpace(item) - if line == "" { - continue - } - parts := strings.Split(line, colorWindowSpan) - if len(parts) != 2 { - return nil, fmt.Errorf("invalid colorization rule found: %s", line) - } - s, err := strconv.Atoi(parts[0]) - if err != nil { - return nil, err - } - e, err := strconv.Atoi(parts[1]) - if err != nil { - return nil, err - } - if s < 0 || e < 0 || e < s || s > 59 || e > 59 { - return nil, fmt.Errorf("invalid time found for colorization rule: %s", line) - } - rules = append(rules, ColorWindow{Start: s, End: e}) - } - if len(rules) == 0 { - return nil, errors.New("invalid colorization rules for totp, none found") - } - return rules, nil -} - // NewConfigFiles will get the list of candidate config files func NewConfigFiles() []string { v := EnvConfig.Get() @@ -437,29 +202,56 @@ func environmentRegister[T printer](obj T) T { return obj } -// List will list the platform types on the struct -func (p PlatformTypes) List() []string { - return listFields[SystemPlatform](p) -} - -// List will list the output modes on the struct -func (p JSONOutputTypes) List() []string { - return listFields[JSONOutputMode](p) -} - -func listFields[T SystemPlatform | JSONOutputMode](p any) []string { - v := reflect.ValueOf(p) - var vals []string - for i := 0; i < v.NumField(); i++ { - vals = append(vals, fmt.Sprintf("%v", v.Field(i).Interface().(T))) - } - sort.Strings(vals) - return vals -} - func newDefaultedEnvironment[T any](val T, base environmentBase) environmentDefault[T] { obj := environmentDefault[T]{} obj.environmentBase = base obj.defaultValue = val return obj } + +func formatterTOTP(key, value string) string { + const ( + otpAuth = "otpauth" + otpIssuer = "lbissuer" + ) + if strings.HasPrefix(value, otpAuth) { + return value + } + override := environOrDefault(key, "") + if override != "" { + return fmt.Sprintf(override, value) + } + v := url.Values{} + v.Set("secret", value) + v.Set("issuer", otpIssuer) + v.Set("period", "30") + v.Set("algorithm", "SHA1") + v.Set("digits", "6") + u := url.URL{ + Scheme: otpAuth, + Host: "totp", + Path: "/" + otpIssuer + ":" + "lbaccount", + RawQuery: v.Encode(), + } + return u.String() +} + +// CanColor indicates if colorized output is allowed (or disabled) +func CanColor() (bool, error) { + if _, noColor := os.LookupEnv("NO_COLOR"); noColor { + return false, nil + } + interactive, err := EnvInteractive.Get() + if err != nil { + return false, err + } + colors := interactive + if colors { + isColored, err := EnvColorEnabled.Get() + if err != nil { + return false, err + } + colors = isColored + } + return colors, nil +} diff --git a/internal/config/core_test.go b/internal/config/core_test.go @@ -9,17 +9,6 @@ import ( "github.com/seanenck/lockbox/internal/config" ) -func TestList(t *testing.T) { - for obj, cnt := range map[interface{ List() []string }]int{ - config.Platforms: 4, - config.JSONOutputs: 3, - } { - if len(obj.List()) != cnt { - t.Errorf("invalid list result: %v", obj) - } - } -} - func isSet(key string) bool { for _, item := range os.Environ() { if strings.HasPrefix(item, fmt.Sprintf("%s=", key)) { @@ -51,60 +40,6 @@ func TestKeyValue(t *testing.T) { } } -func TestNewPlatform(t *testing.T) { - for _, item := range config.Platforms.List() { - t.Setenv("LOCKBOX_PLATFORM", item) - s, err := config.NewPlatform() - if err != nil { - t.Errorf("invalid clipboard: %v", err) - } - if s != config.SystemPlatform(item) { - t.Error("mismatch on input and resulting detection") - } - } -} - -func TestNewPlatformUnknown(t *testing.T) { - t.Setenv("LOCKBOX_PLATFORM", "afleaj") - _, err := config.NewPlatform() - if err == nil || err.Error() != "unknown platform mode" { - t.Errorf("error expected for platform: %v", err) - } -} - -func TestParseWindows(t *testing.T) { - if _, err := config.ParseColorWindow(""); err.Error() != "invalid colorization rules for totp, none found" { - t.Errorf("invalid error: %v", err) - } - if _, err := config.ParseColorWindow(" 2"); err.Error() != "invalid colorization rule found: 2" { - t.Errorf("invalid error: %v", err) - } - if _, err := config.ParseColorWindow(" 1:200"); err.Error() != "invalid time found for colorization rule: 1:200" { - t.Errorf("invalid error: %v", err) - } - if _, err := config.ParseColorWindow(" 1:-1"); err.Error() != "invalid time found for colorization rule: 1:-1" { - t.Errorf("invalid error: %v", err) - } - if _, err := config.ParseColorWindow(" 200:1"); err.Error() != "invalid time found for colorization rule: 200:1" { - t.Errorf("invalid error: %v", err) - } - if _, err := config.ParseColorWindow(" -1:1"); err.Error() != "invalid time found for colorization rule: -1:1" { - t.Errorf("invalid error: %v", err) - } - if _, err := config.ParseColorWindow(" 2:1"); err.Error() != "invalid time found for colorization rule: 2:1" { - t.Errorf("invalid error: %v", err) - } - if _, err := config.ParseColorWindow("xxx:1"); err.Error() != "strconv.Atoi: parsing \"xxx\": invalid syntax" { - t.Errorf("invalid error: %v", err) - } - if _, err := config.ParseColorWindow(" 1:xxx"); err.Error() != "strconv.Atoi: parsing \"xxx\": invalid syntax" { - t.Errorf("invalid error: %v", err) - } - if _, err := config.ParseColorWindow("1:2 11:22"); err != nil { - t.Errorf("invalid error: %v", err) - } -} - func TestNewEnvFiles(t *testing.T) { os.Clearenv() t.Setenv("LOCKBOX_CONFIG_TOML", "none") @@ -192,3 +127,30 @@ func TestWrap(t *testing.T) { t.Errorf("invalid wrap: %s", w) } } + +func TestCanColor(t *testing.T) { + os.Clearenv() + if can, _ := config.CanColor(); !can { + t.Error("should be able to color") + } + for raw, expect := range map[string]bool{ + "INTERACTIVE": true, + "COLOR_ENABLED": true, + } { + os.Clearenv() + key := fmt.Sprintf("LOCKBOX_%s", raw) + t.Setenv(key, "true") + if can, _ := config.CanColor(); can != expect { + t.Errorf("expect != actual: %s", key) + } + t.Setenv(key, "false") + if can, _ := config.CanColor(); can == expect { + t.Errorf("expect == actual: %s", key) + } + } + os.Clearenv() + t.Setenv("NO_COLOR", "1") + if can, _ := config.CanColor(); can { + t.Error("should NOT be able to color") + } +} diff --git a/internal/config/env.go b/internal/config/env.go @@ -0,0 +1,166 @@ +// Package config handles user inputs/UI elements. +package config + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +type ( + environmentBase struct { + subKey string + cat keyCategory + desc string + requirement string + } + environmentDefault[T any] struct { + environmentBase + defaultValue T + } + // EnvironmentInt are environment settings that are integers + EnvironmentInt struct { + environmentDefault[int] + allowZero bool + shortDesc string + } + // EnvironmentBool are environment settings that are booleans + EnvironmentBool struct { + environmentDefault[bool] + } + // EnvironmentString are string-based settings + EnvironmentString struct { + environmentDefault[string] + canDefault bool + allowed []string + } + // EnvironmentCommand are settings that are parsed as shell commands + EnvironmentCommand struct { + environmentBase + } + // EnvironmentFormatter allows for sending a string into a get request + EnvironmentFormatter struct { + environmentBase + allowed string + fxn func(string, string) string + } +) + +func (e environmentBase) Key() string { + return fmt.Sprintf(environmentPrefix+"%s%s", string(e.cat), e.subKey) +} + +// Get will get the boolean value for the setting +func (e EnvironmentBool) Get() (bool, error) { + return parseStringYesNo(e, getExpand(e.Key())) +} + +func parseStringYesNo(e EnvironmentBool, in string) (bool, error) { + read := strings.ToLower(strings.TrimSpace(in)) + switch read { + case no: + return false, nil + case yes: + return true, nil + case "": + return e.defaultValue, nil + } + + return false, fmt.Errorf("invalid yes/no env value for %s", e.Key()) +} + +// Get will get the integer value for the setting +func (e EnvironmentInt) Get() (int, error) { + val := e.defaultValue + use := getExpand(e.Key()) + if use != "" { + i, err := strconv.Atoi(use) + if err != nil { + return -1, err + } + invalid := false + check := "" + if e.allowZero { + check = "=" + } + switch i { + case 0: + invalid = !e.allowZero + default: + invalid = i < 0 + } + if invalid { + return -1, fmt.Errorf("%s must be >%s 0", e.shortDesc, check) + } + val = i + } + return val, nil +} + +// Get will read the string from the environment +func (e EnvironmentString) Get() string { + if !e.canDefault { + return getExpand(e.Key()) + } + return environOrDefault(e.Key(), e.defaultValue) +} + +// Get will read (and shlex) the value if set +func (e EnvironmentCommand) Get() ([]string, error) { + value := environOrDefault(e.Key(), "") + if strings.TrimSpace(value) == "" { + return nil, nil + } + return shlex(value) +} + +// KeyValue will get the string representation of the key+value +func (e environmentBase) KeyValue(value string) string { + return fmt.Sprintf("%s=%s", e.Key(), value) +} + +// Setenv will do an environment set for the value to key +func (e environmentBase) Set(value string) error { + unset, err := IsUnset(e.Key(), value) + if err != nil { + return err + } + if unset { + return nil + } + return os.Setenv(e.Key(), value) +} + +// Get will retrieve the value with the formatted input included +func (e EnvironmentFormatter) Get(value string) string { + return e.fxn(e.Key(), value) +} + +func (e EnvironmentString) values() (string, []string) { + return e.defaultValue, e.allowed +} + +func (e environmentBase) self() environmentBase { + return e +} + +func (e EnvironmentBool) values() (string, []string) { + val := no + if e.defaultValue { + val = yes + } + return val, []string{yes, no} +} + +func (e EnvironmentInt) values() (string, []string) { + return fmt.Sprintf("%d", e.defaultValue), []string{"<integer>"} +} + +func (e EnvironmentFormatter) values() (string, []string) { + return strings.ReplaceAll(strings.ReplaceAll(EnvTOTPFormat.Get("%s"), "%25s", "%s"), "&", " \\\n &"), []string{e.allowed} +} + +func (e EnvironmentCommand) values() (string, []string) { + return detectedValue, []string{commandArgsExample} +} diff --git a/internal/config/vars.go b/internal/config/vars.go @@ -2,53 +2,13 @@ package config import ( - "errors" - "flag" "fmt" - "net/url" - "os" "strings" - "time" -) -const ( - commandArgsExample = "[cmd args...]" - fileExample = "<file>" - detectedValue = "<detected>" - requiredKeyOrKeyFile = "a key, a key file, or both must be set" - // ModTimeFormat is the expected modtime format - ModTimeFormat = time.RFC3339 + "github.com/seanenck/lockbox/internal/core" ) var ( - // Platforms are the known platforms for lockbox - Platforms = PlatformTypes{ - MacOSPlatform: "macos", - LinuxWaylandPlatform: "linux-wayland", - LinuxXPlatform: "linux-x", - WindowsLinuxPlatform: "wsl", - } - // ReKeyFlags are the CLI argument flags for rekey handling - ReKeyFlags = struct { - KeyFile string - NoKey string - }{"keyfile", "nokey"} - // JSONOutputs are the JSON data output types for exporting/output of values - JSONOutputs = JSONOutputTypes{ - Hash: "hash", - Blank: "empty", - Raw: "plaintext", - } - // TOTPDefaultColorWindow is the default coloring rules for totp - TOTPDefaultColorWindow = []ColorWindow{{Start: 0, End: 5}, {Start: 30, End: 35}} - // TOTPDefaultBetween is the default color window as a string - TOTPDefaultBetween = func() string { - var results []string - for _, w := range TOTPDefaultColorWindow { - results = append(results, fmt.Sprintf("%d%s%d", w.Start, colorWindowSpan, w.End)) - } - return strings.Join(results, colorWindowDelimiter) - }() // EnvClipTimeout gets the maximum clipboard time EnvClipTimeout = environmentRegister( EnvironmentInt{ @@ -68,7 +28,7 @@ var ( environmentBase{ cat: jsonCategory, subKey: "HASH_LENGTH", - desc: fmt.Sprintf("Maximum string length of the JSON value when '%s' mode is set for JSON output.", JSONOutputs.Hash), + desc: fmt.Sprintf("Maximum string length of the JSON value when '%s' mode is set for JSON output.", core.JSONOutputs.Hash), }), shortDesc: "hash length", allowZero: true, @@ -184,7 +144,7 @@ var ( subKey: "PLATFORM", desc: "Override the detected platform.", }), - allowed: Platforms.List(), + allowed: core.Platforms.List(), canDefault: false, }) // EnvStore is the location of the keepass file/store @@ -232,7 +192,7 @@ var ( cat: totpCategory, desc: fmt.Sprintf(`Override when to set totp generated outputs to different colors, must be a list of one (or more) rules where a '%s' delimits the start and end second (0-60 for each), -and '%s' allows for multiple windows.`, colorWindowSpan, colorWindowDelimiter), +and '%s' allows for multiple windows.`, core.ColorWindowSpan, core.ColorWindowDelimiter), }), canDefault: true, allowed: exampleColorWindows, @@ -265,14 +225,14 @@ and '%s' allows for multiple windows.`, colorWindowSpan, colorWindowDelimiter), // EnvJSONMode controls how JSON is output in the 'data' field EnvJSONMode = environmentRegister( EnvironmentString{ - environmentDefault: newDefaultedEnvironment(string(JSONOutputs.Hash), + environmentDefault: newDefaultedEnvironment(string(core.JSONOutputs.Hash), environmentBase{ cat: jsonCategory, subKey: "MODE", - desc: fmt.Sprintf("Changes what the data field in JSON outputs will contain.\n\nUse '%s' with CAUTION.", JSONOutputs.Raw), + desc: fmt.Sprintf("Changes what the data field in JSON outputs will contain.\n\nUse '%s' with CAUTION.", core.JSONOutputs.Raw), }), canDefault: true, - allowed: JSONOutputs.List(), + allowed: core.JSONOutputs.List(), }) // EnvTOTPFormat supports formatting the TOTP tokens for generation of tokens EnvTOTPFormat = environmentRegister(EnvironmentFormatter{environmentBase: environmentBase{ @@ -400,76 +360,3 @@ Set to '%s' to ignore the set key value`, noKeyMode, IgnoreKeyMode), canDefault: true, }) ) - -// GetReKey will get the rekey environment settings -func GetReKey(args []string) (ReKeyArgs, error) { - set := flag.NewFlagSet("rekey", flag.ExitOnError) - keyFile := set.String(ReKeyFlags.KeyFile, "", "new keyfile") - noKey := set.Bool(ReKeyFlags.NoKey, false, "disable password/key credential") - if err := set.Parse(args); err != nil { - return ReKeyArgs{}, err - } - noPass := *noKey - file := *keyFile - if strings.TrimSpace(file) == "" && noPass { - return ReKeyArgs{}, errors.New("a key or keyfile must be passed for rekey") - } - return ReKeyArgs{KeyFile: file, NoKey: noPass}, nil -} - -func formatterTOTP(key, value string) string { - const ( - otpAuth = "otpauth" - otpIssuer = "lbissuer" - ) - if strings.HasPrefix(value, otpAuth) { - return value - } - override := environOrDefault(key, "") - if override != "" { - return fmt.Sprintf(override, value) - } - v := url.Values{} - v.Set("secret", value) - v.Set("issuer", otpIssuer) - v.Set("period", "30") - v.Set("algorithm", "SHA1") - v.Set("digits", "6") - u := url.URL{ - Scheme: otpAuth, - Host: "totp", - Path: "/" + otpIssuer + ":" + "lbaccount", - RawQuery: v.Encode(), - } - return u.String() -} - -// ParseJSONOutput handles detecting the JSON output mode -func ParseJSONOutput() (JSONOutputMode, error) { - val := JSONOutputMode(strings.ToLower(strings.TrimSpace(EnvJSONMode.Get()))) - switch val { - case JSONOutputs.Hash, JSONOutputs.Blank, JSONOutputs.Raw: - return val, nil - } - return JSONOutputs.Blank, fmt.Errorf("invalid JSON output mode: %s", val) -} - -// CanColor indicates if colorized output is allowed (or disabled) -func CanColor() (bool, error) { - if _, noColor := os.LookupEnv("NO_COLOR"); noColor { - return false, nil - } - interactive, err := EnvInteractive.Get() - if err != nil { - return false, err - } - colors := interactive - if colors { - isColored, err := EnvColorEnabled.Get() - if err != nil { - return false, err - } - colors = isColored - } - return colors, nil -} diff --git a/internal/config/vars_test.go b/internal/config/vars_test.go @@ -2,7 +2,6 @@ package config_test import ( "fmt" - "os" "testing" "github.com/seanenck/lockbox/internal/config" @@ -91,26 +90,6 @@ func TestTOTP(t *testing.T) { } } -func TestReKey(t *testing.T) { - if _, err := config.GetReKey([]string{"-nokey"}); err == nil || err.Error() != "a key or keyfile must be passed for rekey" { - t.Errorf("failed: %v", err) - } - out, err := config.GetReKey([]string{}) - if err != nil { - t.Errorf("failed: %v", err) - } - if out.NoKey || out.KeyFile != "" { - t.Errorf("invalid args: %v", out) - } - out, err = config.GetReKey([]string{"-keyfile", "vars.go", "-nokey"}) - if err != nil { - t.Errorf("failed: %v", err) - } - if !out.NoKey || out.KeyFile != "vars.go" { - t.Errorf("invalid args: %v", out) - } -} - func TestFormatTOTP(t *testing.T) { otp := config.EnvTOTPFormat.Get("otpauth://abc") if otp != "otpauth://abc" { @@ -132,32 +111,6 @@ func TestFormatTOTP(t *testing.T) { } } -func TestParseJSONMode(t *testing.T) { - m, err := config.ParseJSONOutput() - if m != config.JSONOutputs.Hash || err != nil { - t.Error("invalid mode read") - } - t.Setenv("LOCKBOX_JSON_MODE", "hAsH ") - m, err = config.ParseJSONOutput() - if m != config.JSONOutputs.Hash || err != nil { - t.Error("invalid mode read") - } - t.Setenv("LOCKBOX_JSON_MODE", "EMPTY") - m, err = config.ParseJSONOutput() - if m != config.JSONOutputs.Blank || err != nil { - t.Error("invalid mode read") - } - t.Setenv("LOCKBOX_JSON_MODE", " PLAINtext ") - m, err = config.ParseJSONOutput() - if m != config.JSONOutputs.Raw || err != nil { - t.Error("invalid mode read") - } - t.Setenv("LOCKBOX_JSON_MODE", "a") - if _, err = config.ParseJSONOutput(); err == nil || err.Error() != "invalid JSON output mode: a" { - t.Errorf("invalid error: %v", err) - } -} - func TestClipboardMax(t *testing.T) { checkInt(config.EnvClipTimeout, "LOCKBOX_CLIP_TIMEOUT", "clipboard max time", 45, false, t) } @@ -209,30 +162,3 @@ func checkInt(e config.EnvironmentInt, key, text string, def int, allowZero bool } } } - -func TestCanColor(t *testing.T) { - os.Clearenv() - if can, _ := config.CanColor(); !can { - t.Error("should be able to color") - } - for raw, expect := range map[string]bool{ - "INTERACTIVE": true, - "COLOR_ENABLED": true, - } { - os.Clearenv() - key := fmt.Sprintf("LOCKBOX_%s", raw) - t.Setenv(key, "true") - if can, _ := config.CanColor(); can != expect { - t.Errorf("expect != actual: %s", key) - } - t.Setenv(key, "false") - if can, _ := config.CanColor(); can == expect { - t.Errorf("expect == actual: %s", key) - } - } - os.Clearenv() - t.Setenv("NO_COLOR", "1") - if can, _ := config.CanColor(); can { - t.Error("should NOT be able to color") - } -} diff --git a/internal/core/colors.go b/internal/core/colors.go @@ -0,0 +1,53 @@ +// Package core has to assist with some color components +package core + +import ( + "errors" + "fmt" + "strconv" + "strings" +) + +const ( + // ColorWindowDelimiter indicates how windows are split in env/config keys + ColorWindowDelimiter = " " + // ColorWindowSpan indicates the delineation betwee start -> end (start:end) + ColorWindowSpan = ":" +) + +// ColorWindow for handling terminal colors based on timing +type ColorWindow struct { + Start int + End int +} + +// ParseColorWindow will handle parsing a window of colors for TOTP operations +func ParseColorWindow(windowString string) ([]ColorWindow, error) { + var rules []ColorWindow + for _, item := range strings.Split(windowString, ColorWindowDelimiter) { + line := strings.TrimSpace(item) + if line == "" { + continue + } + parts := strings.Split(line, ColorWindowSpan) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid colorization rule found: %s", line) + } + s, err := strconv.Atoi(parts[0]) + if err != nil { + return nil, err + } + e, err := strconv.Atoi(parts[1]) + if err != nil { + return nil, err + } + if s < 0 || e < 0 || e < s || s > 59 || e > 59 { + return nil, fmt.Errorf("invalid time found for colorization rule: %s", line) + } + rules = append(rules, ColorWindow{Start: s, End: e}) + } + if len(rules) == 0 { + return nil, errors.New("invalid colorization rules for totp, none found") + } + return rules, nil +} diff --git a/internal/core/colors_test.go b/internal/core/colors_test.go @@ -0,0 +1,40 @@ +package core_test + +import ( + "testing" + + "github.com/seanenck/lockbox/internal/core" +) + +func TestParseWindows(t *testing.T) { + if _, err := core.ParseColorWindow(""); err.Error() != "invalid colorization rules for totp, none found" { + t.Errorf("invalid error: %v", err) + } + if _, err := core.ParseColorWindow(" 2"); err.Error() != "invalid colorization rule found: 2" { + t.Errorf("invalid error: %v", err) + } + if _, err := core.ParseColorWindow(" 1:200"); err.Error() != "invalid time found for colorization rule: 1:200" { + t.Errorf("invalid error: %v", err) + } + if _, err := core.ParseColorWindow(" 1:-1"); err.Error() != "invalid time found for colorization rule: 1:-1" { + t.Errorf("invalid error: %v", err) + } + if _, err := core.ParseColorWindow(" 200:1"); err.Error() != "invalid time found for colorization rule: 200:1" { + t.Errorf("invalid error: %v", err) + } + if _, err := core.ParseColorWindow(" -1:1"); err.Error() != "invalid time found for colorization rule: -1:1" { + t.Errorf("invalid error: %v", err) + } + if _, err := core.ParseColorWindow(" 2:1"); err.Error() != "invalid time found for colorization rule: 2:1" { + t.Errorf("invalid error: %v", err) + } + if _, err := core.ParseColorWindow("xxx:1"); err.Error() != "strconv.Atoi: parsing \"xxx\": invalid syntax" { + t.Errorf("invalid error: %v", err) + } + if _, err := core.ParseColorWindow(" 1:xxx"); err.Error() != "strconv.Atoi: parsing \"xxx\": invalid syntax" { + t.Errorf("invalid error: %v", err) + } + if _, err := core.ParseColorWindow("1:2 11:22"); err != nil { + t.Errorf("invalid error: %v", err) + } +} diff --git a/internal/core/core.go b/internal/core/core.go @@ -0,0 +1,18 @@ +// Package core has helpers +package core + +import ( + "fmt" + "reflect" + "sort" +) + +func listFields[T SystemPlatform | JSONOutputMode](p any) []string { + v := reflect.ValueOf(p) + var vals []string + for i := 0; i < v.NumField(); i++ { + vals = append(vals, fmt.Sprintf("%v", v.Field(i).Interface().(T))) + } + sort.Strings(vals) + return vals +} diff --git a/internal/core/json.go b/internal/core/json.go @@ -0,0 +1,41 @@ +// Package core defines JSON outputs +package core + +import ( + "fmt" + "strings" +) + +// JSONOutputs are the JSON data output types for exporting/output of values +var JSONOutputs = JSONOutputTypes{ + Hash: "hash", + Blank: "empty", + Raw: "plaintext", +} + +type ( + // JSONOutputMode is the output mode definition + JSONOutputMode string + + // JSONOutputTypes indicate how JSON data can be exported for values + JSONOutputTypes struct { + Hash JSONOutputMode + Blank JSONOutputMode + Raw JSONOutputMode + } +) + +// List will list the output modes on the struct +func (p JSONOutputTypes) List() []string { + return listFields[JSONOutputMode](p) +} + +// ParseJSONOutput handles detecting the JSON output mode +func ParseJSONOutput(value string) (JSONOutputMode, error) { + val := JSONOutputMode(strings.ToLower(strings.TrimSpace(value))) + switch val { + case JSONOutputs.Hash, JSONOutputs.Blank, JSONOutputs.Raw: + return val, nil + } + return JSONOutputs.Blank, fmt.Errorf("invalid JSON output mode: %s", val) +} diff --git a/internal/core/json_test.go b/internal/core/json_test.go @@ -0,0 +1,31 @@ +package core_test + +import ( + "testing" + + "github.com/seanenck/lockbox/internal/core" +) + +func TestJSONList(t *testing.T) { + if len(core.JSONOutputs.List()) != 3 { + t.Errorf("invalid list result") + } +} + +func TestParseJSONMode(t *testing.T) { + m, err := core.ParseJSONOutput("hAsH ") + if m != core.JSONOutputs.Hash || err != nil { + t.Error("invalid mode read") + } + m, err = core.ParseJSONOutput("EMPTY") + if m != core.JSONOutputs.Blank || err != nil { + t.Error("invalid mode read") + } + m, err = core.ParseJSONOutput(" PLAINtext ") + if m != core.JSONOutputs.Raw || err != nil { + t.Error("invalid mode read") + } + if _, err = core.ParseJSONOutput("a"); err == nil || err.Error() != "invalid JSON output mode: a" { + t.Errorf("invalid error: %v", err) + } +} diff --git a/internal/core/platforms.go b/internal/core/platforms.go @@ -0,0 +1,28 @@ +// Package core defines known platforms +package core + +// Platforms are the known platforms for lockbox +var Platforms = PlatformTypes{ + MacOSPlatform: "macos", + LinuxWaylandPlatform: "linux-wayland", + LinuxXPlatform: "linux-x", + WindowsLinuxPlatform: "wsl", +} + +type ( + // SystemPlatform represents the platform lockbox is running on. + SystemPlatform string + + // PlatformTypes defines systems lockbox is known to run on or can run on + PlatformTypes struct { + MacOSPlatform SystemPlatform + LinuxWaylandPlatform SystemPlatform + LinuxXPlatform SystemPlatform + WindowsLinuxPlatform SystemPlatform + } +) + +// List will list the platform types on the struct +func (p PlatformTypes) List() []string { + return listFields[SystemPlatform](p) +} diff --git a/internal/core/platforms_test.go b/internal/core/platforms_test.go @@ -0,0 +1,13 @@ +package core_test + +import ( + "testing" + + "github.com/seanenck/lockbox/internal/core" +) + +func TestPlatformList(t *testing.T) { + if len(core.Platforms.List()) != 4 { + t.Errorf("invalid list result") + } +} diff --git a/internal/platform/clipboard.go b/internal/platform/clipboard.go @@ -9,6 +9,7 @@ import ( osc "github.com/aymanbagabas/go-osc52" "github.com/seanenck/lockbox/internal/config" + "github.com/seanenck/lockbox/internal/core" ) type ( @@ -57,7 +58,7 @@ func NewClipboard() (Clipboard, error) { c := Clipboard{isOSC52: true} return c, nil } - sys, err := config.NewPlatform() + sys, err := NewPlatform() if err != nil { return Clipboard{}, err } @@ -65,16 +66,16 @@ func NewClipboard() (Clipboard, error) { var copying []string var pasting []string switch sys { - case config.Platforms.MacOSPlatform: + case core.Platforms.MacOSPlatform: copying = []string{"pbcopy"} pasting = []string{"pbpaste"} - case config.Platforms.LinuxXPlatform: + case core.Platforms.LinuxXPlatform: copying = []string{"xclip"} pasting = []string{"xclip", "-o"} - case config.Platforms.LinuxWaylandPlatform: + case core.Platforms.LinuxWaylandPlatform: copying = []string{"wl-copy"} pasting = []string{"wl-paste"} - case config.Platforms.WindowsLinuxPlatform: + case core.Platforms.WindowsLinuxPlatform: copying = []string{"clip.exe"} pasting = []string{"powershell.exe", "-command", "Get-Clipboard"} default: diff --git a/internal/platform/clipboard_test.go b/internal/platform/clipboard_test.go @@ -4,7 +4,7 @@ import ( "fmt" "testing" - "github.com/seanenck/lockbox/internal/config" + "github.com/seanenck/lockbox/internal/core" "github.com/seanenck/lockbox/internal/platform" ) @@ -21,7 +21,7 @@ func TestNoClipboard(t *testing.T) { func TestMaxTime(t *testing.T) { t.Setenv("LOCKBOX_CLIP_ENABLED", "true") t.Setenv("LOCKBOX_CLIP_OSC52", "false") - t.Setenv("LOCKBOX_PLATFORM", string(config.Platforms.LinuxWaylandPlatform)) + t.Setenv("LOCKBOX_PLATFORM", string(core.Platforms.LinuxWaylandPlatform)) t.Setenv("LOCKBOX_CLIP_TIMEOUT", "") c, err := platform.NewClipboard() if err != nil { @@ -54,7 +54,7 @@ func TestClipboardInstances(t *testing.T) { t.Setenv("LOCKBOX_CLIP_ENABLED", "true") t.Setenv("LOCKBOX_CLIP_TIMEOUT", "") t.Setenv("LOCKBOX_CLIP_OSC52", "false") - for _, item := range config.Platforms.List() { + for _, item := range core.Platforms.List() { t.Setenv("LOCKBOX_PLATFORM", item) _, err := platform.NewClipboard() if err != nil { @@ -79,7 +79,7 @@ func TestOSC52(t *testing.T) { func TestArgsOverride(t *testing.T) { t.Setenv("LOCKBOX_CLIP_PASTE_COMMAND", "abc xyz 111") t.Setenv("LOCKBOX_CLIP_OSC52", "false") - t.Setenv("LOCKBOX_PLATFORM", string(config.Platforms.WindowsLinuxPlatform)) + t.Setenv("LOCKBOX_PLATFORM", string(core.Platforms.WindowsLinuxPlatform)) c, _ := platform.NewClipboard() cmd, args, ok := c.Args(true) if cmd != "clip.exe" || len(args) != 0 || !ok { diff --git a/internal/platform/detect.go b/internal/platform/detect.go @@ -0,0 +1,48 @@ +package platform + +import ( + "errors" + "os" + "os/exec" + "strings" + + "github.com/seanenck/lockbox/internal/config" + "github.com/seanenck/lockbox/internal/core" +) + +const unknownPlatform = "" + +// NewPlatform gets a new system platform. +func NewPlatform() (core.SystemPlatform, error) { + env := config.EnvPlatform.Get() + if env != "" { + for _, p := range core.Platforms.List() { + if p == env { + return core.SystemPlatform(p), nil + } + } + return unknownPlatform, errors.New("unknown platform mode") + } + b, err := exec.Command("uname", "-a").Output() + if err != nil { + return unknownPlatform, err + } + raw := strings.ToLower(strings.TrimSpace(string(b))) + parts := strings.Split(raw, " ") + switch parts[0] { + case "darwin": + return core.Platforms.MacOSPlatform, nil + case "linux": + if strings.Contains(raw, "microsoft-standard-wsl") { + return core.Platforms.WindowsLinuxPlatform, nil + } + if strings.TrimSpace(os.Getenv("WAYLAND_DISPLAY")) == "" { + if strings.TrimSpace(os.Getenv("DISPLAY")) == "" { + return unknownPlatform, errors.New("unable to detect linux clipboard mode") + } + return core.Platforms.LinuxXPlatform, nil + } + return core.Platforms.LinuxWaylandPlatform, nil + } + return unknownPlatform, errors.New("unable to detect clipboard mode") +} diff --git a/internal/platform/detect_test.go b/internal/platform/detect_test.go @@ -0,0 +1,29 @@ +package platform_test + +import ( + "testing" + + "github.com/seanenck/lockbox/internal/core" + "github.com/seanenck/lockbox/internal/platform" +) + +func TestNewPlatform(t *testing.T) { + for _, item := range core.Platforms.List() { + t.Setenv("LOCKBOX_PLATFORM", item) + s, err := platform.NewPlatform() + if err != nil { + t.Errorf("invalid clipboard: %v", err) + } + if s != core.SystemPlatform(item) { + t.Error("mismatch on input and resulting detection") + } + } +} + +func TestNewPlatformUnknown(t *testing.T) { + t.Setenv("LOCKBOX_PLATFORM", "afleaj") + _, err := platform.NewPlatform() + if err == nil || err.Error() != "unknown platform mode" { + t.Errorf("error expected for platform: %v", err) + } +}