lockbox

password manager
Log | Files | Refs | README | LICENSE

commit 99786ce7ac838b8cfd144c986d18675c58b8842a
parent ab74338ba8796f44b7b2d810691a0125da2d39f5
Author: Sean Enck <sean@ttypty.com>
Date:   Sun, 21 Aug 2022 10:08:54 -0400

all libexec is gone

Diffstat:
MMakefile | 10+++++-----
Dcmd/lb-totp/main.go | 228-------------------------------------------------------------------------------
Dcmd/lb/main.go | 204-------------------------------------------------------------------------------
Acmd/main.go | 202+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/inputs/env.go | 2--
Ainternal/subcommands/totp.go | 232+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/run.sh | 4++--
7 files changed, 441 insertions(+), 441 deletions(-)

diff --git a/Makefile b/Makefile @@ -1,21 +1,21 @@ VERSION := development DESTDIR := BUILD := bin/ -TARGETS := $(BUILD)lb $(BUILD)lb-totp +TARGET := $(BUILD)lb MAIN := $(DESTDIR)bin/lb TESTDIR := $(sort $(dir $(wildcard internal/**/*_test.go))) .PHONY: $(TESTDIR) -all: $(TARGETS) +all: $(TARGET) -$(TARGETS): cmd/**/* internal/**/*.go go.* - go build -ldflags '-X main.version=$(VERSION) -X main.mainExe=$(MAIN)' -trimpath -buildmode=pie -mod=readonly -modcacherw -o $@ cmd/$(shell basename $@)/main.go +$(TARGET): cmd/main.go internal/**/*.go go.* + go build -ldflags '-X main.version=$(VERSION) -X main.mainExe=$(MAIN)' -trimpath -buildmode=pie -mod=readonly -modcacherw -o $@ cmd/main.go $(TESTDIR): cd $@ && go test -check: $(TARGETS) $(TESTDIR) +check: $(TARGET) $(TESTDIR) cd tests && make BUILD=../$(BUILD) clean: diff --git a/cmd/lb-totp/main.go b/cmd/lb-totp/main.go @@ -1,228 +0,0 @@ -// support TOTP tokens in lockbox. -package main - -import ( - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "sort" - "strconv" - "strings" - "time" - - "github.com/enckse/lockbox/internal/cli" - "github.com/enckse/lockbox/internal/colors" - "github.com/enckse/lockbox/internal/encrypt" - "github.com/enckse/lockbox/internal/inputs" - "github.com/enckse/lockbox/internal/misc" - "github.com/enckse/lockbox/internal/platform" - "github.com/enckse/lockbox/internal/store" - otp "github.com/pquerna/otp/totp" -) - -var ( - mainExe = "" -) - -type ( - colorWhen struct { - start int - end int - } -) - -func clear() { - cmd := exec.Command("clear") - cmd.Stdout = os.Stdout - if err := cmd.Run(); err != nil { - fmt.Printf("unable to clear screen: %v\n", err) - } -} - -func totpEnv() string { - return inputs.EnvOrDefault(inputs.TotpEnv, "totp") -} - -func colorWhenRules() ([]colorWhen, error) { - envTime := os.Getenv(inputs.ColorBetweenEnv) - if envTime == "" { - return []colorWhen{ - colorWhen{start: 0, end: 5}, - colorWhen{start: 30, end: 35}, - }, nil - } - var rules []colorWhen - for _, item := range strings.Split(envTime, ",") { - line := strings.TrimSpace(item) - if line == "" { - continue - } - parts := strings.Split(line, ":") - 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, colorWhen{start: s, end: e}) - } - if len(rules) == 0 { - return nil, errors.New("invalid colorization rules for totp, none found") - } - return rules, nil -} - -func display(token string, args cli.Arguments) error { - interactive, err := inputs.IsInteractive() - if err != nil { - return err - } - if args.Short { - interactive = false - } - if !interactive && args.Clip { - return errors.New("clipboard not available in non-interactive mode") - } - coloring, err := colors.NewTerminal(colors.Red) - if err != nil { - return err - } - f := store.NewFileSystemStore() - tok := filepath.Join(strings.TrimSpace(token), totpEnv()) - pathing := f.NewPath(tok) - if !misc.PathExists(pathing) { - return errors.New("object does not exist") - } - val, err := encrypt.FromFile(pathing) - if err != nil { - return err - } - exe := inputs.EnvOrDefault(inputs.ExeEnv, mainExe) - totpToken := string(val) - if !interactive { - code, err := otp.GenerateCode(totpToken, time.Now()) - if err != nil { - return err - } - fmt.Println(code) - return nil - } - first := true - running := 0 - lastSecond := -1 - if !args.Clip { - if !args.Once { - clear() - } - } - clipboard := platform.Clipboard{} - if args.Clip { - clipboard, err = platform.NewClipboard() - if err != nil { - misc.Die("invalid clipboard", err) - } - } - colorRules, err := colorWhenRules() - if err != nil { - misc.Die("invalid totp output coloring rules", err) - } - for { - if !first { - time.Sleep(500 * time.Millisecond) - } - first = false - running++ - if running > 120 { - fmt.Println("exiting (timeout)") - return nil - } - now := time.Now() - last := now.Second() - if last == lastSecond { - continue - } - lastSecond = last - left := 60 - last - code, err := otp.GenerateCode(totpToken, now) - if err != nil { - return err - } - startColor := "" - endColor := "" - for _, when := range colorRules { - if left < when.end && left >= when.start { - startColor = coloring.Start - endColor = coloring.End - } - } - leftString := fmt.Sprintf("%d", left) - if len(leftString) < 2 { - leftString = "0" + leftString - } - expires := fmt.Sprintf("%s%s (%s)%s", startColor, now.Format("15:04:05"), leftString, endColor) - outputs := []string{expires} - if !args.Clip { - outputs = append(outputs, fmt.Sprintf("%s\n %s", tok, code)) - if !args.Once { - outputs = append(outputs, "-> CTRL+C to exit") - } - } else { - fmt.Printf("-> %s\n", expires) - clipboard.CopyTo(code, exe) - return nil - } - if !args.Once { - clear() - } - fmt.Printf("%s\n", strings.Join(outputs, "\n\n")) - if args.Once { - return nil - } - } -} - -func main() { - args := os.Args - if len(args) > 3 || len(args) < 2 { - misc.Die("subkey required", errors.New("invalid arguments")) - } - cmd := args[1] - options := cli.ParseArgs(cmd) - if options.List { - f := store.NewFileSystemStore() - token := f.NewFile(totpEnv()) - results, err := f.List(store.ViewOptions{ErrorOnEmpty: true, Filter: func(path string) string { - if filepath.Base(path) == token { - return filepath.Dir(f.CleanPath(path)) - } - return "" - }}) - if err != nil { - misc.Die("invalid list response", err) - } - sort.Strings(results) - for _, entry := range results { - fmt.Println(entry) - } - return - } - if len(args) == 3 { - if !options.Clip && !options.Short && !options.Once { - misc.Die("subcommand not supported", errors.New("invalid sub command")) - } - cmd = args[2] - } - if err := display(cmd, options); err != nil { - misc.Die("failed to show totp token", err) - } -} diff --git a/cmd/lb/main.go b/cmd/lb/main.go @@ -1,204 +0,0 @@ -// provides the binary runs or calls lockbox commands. -package main - -import ( - "errors" - "fmt" - "os" - "path/filepath" - - "github.com/enckse/lockbox/internal/cli" - "github.com/enckse/lockbox/internal/dump" - "github.com/enckse/lockbox/internal/encrypt" - "github.com/enckse/lockbox/internal/hooks" - "github.com/enckse/lockbox/internal/inputs" - "github.com/enckse/lockbox/internal/misc" - "github.com/enckse/lockbox/internal/platform" - "github.com/enckse/lockbox/internal/store" - "github.com/enckse/lockbox/internal/subcommands" -) - -var ( - version = "development" - libExec = "" -) - -type ( - callbackFunction func([]string) error -) - -func getEntry(fs store.FileSystem, args []string, idx int) string { - if len(args) != idx+1 { - misc.Die("invalid entry given", errors.New("specific entry required")) - } - return fs.NewPath(args[idx]) -} - -func internalCallback(name string) callbackFunction { - switch name { - case "gitdiff": - return subcommands.GitDiff - case "rekey": - return subcommands.Rekey - case "rw": - return subcommands.ReadWrite - } - return nil -} - -func main() { - args := os.Args - if len(args) < 2 { - misc.Die("missing arguments", errors.New("requires subcommand")) - } - command := args[1] - switch command { - case "ls", "list", "find": - opts := subcommands.ListFindOptions{Find: command == "find", Search: "", Store: store.NewFileSystemStore()} - if opts.Find { - if len(args) < 3 { - misc.Die("find requires an argument to search for", errors.New("search term required")) - } - opts.Search = args[2] - } - files, err := subcommands.ListFindCallback(opts) - if err != nil { - misc.Die("unable to list files", err) - } - for _, f := range files { - fmt.Println(f) - } - case "version": - fmt.Printf("version: %s\n", version) - case "insert": - options := cli.Arguments{} - idx := 2 - switch len(args) { - case 2: - misc.Die("insert missing required arguments", errors.New("entry required")) - case 3: - case 4: - options = cli.ParseArgs(args[2]) - if !options.Multi { - misc.Die("multi-line insert must be after 'insert'", errors.New("invalid command")) - } - idx = 3 - default: - misc.Die("too many arguments", errors.New("insert can only perform one operation")) - } - isPipe := inputs.IsInputFromPipe() - entry := getEntry(store.NewFileSystemStore(), args, idx) - if misc.PathExists(entry) { - if !isPipe { - if !confirm("overwrite existing") { - return - } - } - } else { - dir := filepath.Dir(entry) - if !misc.PathExists(dir) { - if err := os.MkdirAll(dir, 0755); err != nil { - misc.Die("failed to create directory structure", err) - } - } - } - password, err := inputs.GetUserInputPassword(isPipe, options.Multi) - if err != nil { - misc.Die("invalid input", err) - } - if err := encrypt.ToFile(entry, password); err != nil { - misc.Die("unable to encrypt object", err) - } - fmt.Println("") - hooks.Run(hooks.Insert, hooks.PostStep) - case "rm": - entry := getEntry(store.NewFileSystemStore(), args, 2) - if !misc.PathExists(entry) { - misc.Die("does not exists", errors.New("can not delete unknown entry")) - } - if confirm("remove entry") { - os.Remove(entry) - hooks.Run(hooks.Remove, hooks.PostStep) - } - case "show", "clip", "dump": - fs := store.NewFileSystemStore() - opts := subcommands.DisplayOptions{Dump: command == "dump", Show: command == "show", Glob: getEntry(fs, []string{"***"}, 0), Store: fs} - opts.Show = opts.Show || opts.Dump - startEntry := 2 - options := cli.Arguments{} - if opts.Dump { - if len(args) > 2 { - options = cli.ParseArgs(args[2]) - if options.Yes { - startEntry = 3 - } - } - } - opts.Entry = getEntry(fs, args, startEntry) - var err error - dumpData, err := subcommands.DisplayCallback(opts) - if err != nil { - misc.Die("display command failed to retrieve data", err) - } - if opts.Dump { - if !options.Yes { - if !confirm("dump data to stdout as plaintext") { - return - } - } - d, err := dump.Marshal(dumpData) - if err != nil { - misc.Die("failed to marshal items", err) - } - fmt.Println(string(d)) - return - } - clipboard := platform.Clipboard{} - exe := "" - if !opts.Show { - clipboard, err = platform.NewClipboard() - if err != nil { - misc.Die("unable to get clipboard", err) - } - exe, err = os.Executable() - if err != nil { - misc.Die("unable to get executable", err) - } - } - for _, obj := range dumpData { - if opts.Show { - if obj.Path != "" { - fmt.Println(obj.Path) - } - fmt.Println(obj.Value) - continue - } - clipboard.CopyTo(obj.Value, exe) - } - case "clear": - if err := subcommands.ClearClipboardCallback(); err != nil { - misc.Die("failed to handle clipboard clear", err) - } - default: - a := args[2:] - callback := internalCallback(command) - if callback != nil { - if err := callback(a); err != nil { - misc.Die(fmt.Sprintf("%s command failure", command), err) - } - return - } - lib := inputs.EnvOrDefault(inputs.LibExecEnv, libExec) - if err := subcommands.LibExecCallback(subcommands.LibExecOptions{Directory: lib, Command: command, Args: a}); err != nil { - misc.Die("subcommand failed", err) - } - } -} - -func confirm(prompt string) bool { - yesNo, err := inputs.ConfirmYesNoPrompt(prompt) - if err != nil { - misc.Die("failed to get response", err) - } - return yesNo -} diff --git a/cmd/main.go b/cmd/main.go @@ -0,0 +1,202 @@ +// provides the binary runs or calls lockbox commands. +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/enckse/lockbox/internal/cli" + "github.com/enckse/lockbox/internal/dump" + "github.com/enckse/lockbox/internal/encrypt" + "github.com/enckse/lockbox/internal/hooks" + "github.com/enckse/lockbox/internal/inputs" + "github.com/enckse/lockbox/internal/misc" + "github.com/enckse/lockbox/internal/platform" + "github.com/enckse/lockbox/internal/store" + "github.com/enckse/lockbox/internal/subcommands" +) + +var ( + version = "development" +) + +type ( + callbackFunction func([]string) error +) + +func getEntry(fs store.FileSystem, args []string, idx int) string { + if len(args) != idx+1 { + misc.Die("invalid entry given", errors.New("specific entry required")) + } + return fs.NewPath(args[idx]) +} + +func internalCallback(name string) callbackFunction { + switch name { + case "gitdiff": + return subcommands.GitDiff + case "rekey": + return subcommands.Rekey + case "rw": + return subcommands.ReadWrite + case "totp": + return subcommands.TOTP + } + return nil +} + +func main() { + args := os.Args + if len(args) < 2 { + misc.Die("missing arguments", errors.New("requires subcommand")) + } + command := args[1] + switch command { + case "ls", "list", "find": + opts := subcommands.ListFindOptions{Find: command == "find", Search: "", Store: store.NewFileSystemStore()} + if opts.Find { + if len(args) < 3 { + misc.Die("find requires an argument to search for", errors.New("search term required")) + } + opts.Search = args[2] + } + files, err := subcommands.ListFindCallback(opts) + if err != nil { + misc.Die("unable to list files", err) + } + for _, f := range files { + fmt.Println(f) + } + case "version": + fmt.Printf("version: %s\n", version) + case "insert": + options := cli.Arguments{} + idx := 2 + switch len(args) { + case 2: + misc.Die("insert missing required arguments", errors.New("entry required")) + case 3: + case 4: + options = cli.ParseArgs(args[2]) + if !options.Multi { + misc.Die("multi-line insert must be after 'insert'", errors.New("invalid command")) + } + idx = 3 + default: + misc.Die("too many arguments", errors.New("insert can only perform one operation")) + } + isPipe := inputs.IsInputFromPipe() + entry := getEntry(store.NewFileSystemStore(), args, idx) + if misc.PathExists(entry) { + if !isPipe { + if !confirm("overwrite existing") { + return + } + } + } else { + dir := filepath.Dir(entry) + if !misc.PathExists(dir) { + if err := os.MkdirAll(dir, 0755); err != nil { + misc.Die("failed to create directory structure", err) + } + } + } + password, err := inputs.GetUserInputPassword(isPipe, options.Multi) + if err != nil { + misc.Die("invalid input", err) + } + if err := encrypt.ToFile(entry, password); err != nil { + misc.Die("unable to encrypt object", err) + } + fmt.Println("") + hooks.Run(hooks.Insert, hooks.PostStep) + case "rm": + entry := getEntry(store.NewFileSystemStore(), args, 2) + if !misc.PathExists(entry) { + misc.Die("does not exists", errors.New("can not delete unknown entry")) + } + if confirm("remove entry") { + os.Remove(entry) + hooks.Run(hooks.Remove, hooks.PostStep) + } + case "show", "clip", "dump": + fs := store.NewFileSystemStore() + opts := subcommands.DisplayOptions{Dump: command == "dump", Show: command == "show", Glob: getEntry(fs, []string{"***"}, 0), Store: fs} + opts.Show = opts.Show || opts.Dump + startEntry := 2 + options := cli.Arguments{} + if opts.Dump { + if len(args) > 2 { + options = cli.ParseArgs(args[2]) + if options.Yes { + startEntry = 3 + } + } + } + opts.Entry = getEntry(fs, args, startEntry) + var err error + dumpData, err := subcommands.DisplayCallback(opts) + if err != nil { + misc.Die("display command failed to retrieve data", err) + } + if opts.Dump { + if !options.Yes { + if !confirm("dump data to stdout as plaintext") { + return + } + } + d, err := dump.Marshal(dumpData) + if err != nil { + misc.Die("failed to marshal items", err) + } + fmt.Println(string(d)) + return + } + clipboard := platform.Clipboard{} + exe := "" + if !opts.Show { + clipboard, err = platform.NewClipboard() + if err != nil { + misc.Die("unable to get clipboard", err) + } + exe, err = os.Executable() + if err != nil { + misc.Die("unable to get executable", err) + } + } + for _, obj := range dumpData { + if opts.Show { + if obj.Path != "" { + fmt.Println(obj.Path) + } + fmt.Println(obj.Value) + continue + } + clipboard.CopyTo(obj.Value, exe) + } + case "clear": + if err := subcommands.ClearClipboardCallback(); err != nil { + misc.Die("failed to handle clipboard clear", err) + } + default: + a := args[2:] + callback := internalCallback(command) + if callback != nil { + if err := callback(a); err != nil { + misc.Die(fmt.Sprintf("%s command failure", command), err) + } + return + } + misc.Die("unknown command", errors.New(command)) + } +} + +func confirm(prompt string) bool { + yesNo, err := inputs.ConfirmYesNoPrompt(prompt) + if err != nil { + misc.Die("failed to get response", err) + } + return yesNo +} diff --git a/internal/inputs/env.go b/internal/inputs/env.go @@ -14,8 +14,6 @@ const ( interactiveEnv = prefixKey + "INTERACTIVE" // TotpEnv allows for overriding of the special name for totp entries. TotpEnv = prefixKey + "TOTP" - // ExeEnv allows for installing lb to various locations. - ExeEnv = prefixKey + "EXE" // KeyModeEnv indicates what the KEY value is (e.g. command, plaintext). KeyModeEnv = prefixKey + "KEYMODE" // KeyEnv is the key value used by the lockbox store. diff --git a/internal/subcommands/totp.go b/internal/subcommands/totp.go @@ -0,0 +1,232 @@ +// Package subcommands handles TOTP tokens. +package subcommands + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/enckse/lockbox/internal/cli" + "github.com/enckse/lockbox/internal/colors" + "github.com/enckse/lockbox/internal/encrypt" + "github.com/enckse/lockbox/internal/inputs" + "github.com/enckse/lockbox/internal/misc" + "github.com/enckse/lockbox/internal/platform" + "github.com/enckse/lockbox/internal/store" + otp "github.com/pquerna/otp/totp" +) + +var ( + mainExe = "" +) + +type ( + colorWhen struct { + start int + end int + } +) + +func clear() { + cmd := exec.Command("clear") + cmd.Stdout = os.Stdout + if err := cmd.Run(); err != nil { + fmt.Printf("unable to clear screen: %v\n", err) + } +} + +func totpEnv() string { + return inputs.EnvOrDefault(inputs.TotpEnv, "totp") +} + +func colorWhenRules() ([]colorWhen, error) { + envTime := os.Getenv(inputs.ColorBetweenEnv) + if envTime == "" { + return []colorWhen{ + colorWhen{start: 0, end: 5}, + colorWhen{start: 30, end: 35}, + }, nil + } + var rules []colorWhen + for _, item := range strings.Split(envTime, ",") { + line := strings.TrimSpace(item) + if line == "" { + continue + } + parts := strings.Split(line, ":") + 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, colorWhen{start: s, end: e}) + } + if len(rules) == 0 { + return nil, errors.New("invalid colorization rules for totp, none found") + } + return rules, nil +} + +func display(token string, args cli.Arguments) error { + interactive, err := inputs.IsInteractive() + if err != nil { + return err + } + if args.Short { + interactive = false + } + if !interactive && args.Clip { + return errors.New("clipboard not available in non-interactive mode") + } + coloring, err := colors.NewTerminal(colors.Red) + if err != nil { + return err + } + f := store.NewFileSystemStore() + tok := filepath.Join(strings.TrimSpace(token), totpEnv()) + pathing := f.NewPath(tok) + if !misc.PathExists(pathing) { + return errors.New("object does not exist") + } + val, err := encrypt.FromFile(pathing) + if err != nil { + return err + } + exe, err := os.Executable() + if err != nil { + return err + } + totpToken := string(val) + if !interactive { + code, err := otp.GenerateCode(totpToken, time.Now()) + if err != nil { + return err + } + fmt.Println(code) + return nil + } + first := true + running := 0 + lastSecond := -1 + if !args.Clip { + if !args.Once { + clear() + } + } + clipboard := platform.Clipboard{} + if args.Clip { + clipboard, err = platform.NewClipboard() + if err != nil { + misc.Die("invalid clipboard", err) + } + } + colorRules, err := colorWhenRules() + if err != nil { + misc.Die("invalid totp output coloring rules", err) + } + for { + if !first { + time.Sleep(500 * time.Millisecond) + } + first = false + running++ + if running > 120 { + fmt.Println("exiting (timeout)") + return nil + } + now := time.Now() + last := now.Second() + if last == lastSecond { + continue + } + lastSecond = last + left := 60 - last + code, err := otp.GenerateCode(totpToken, now) + if err != nil { + return err + } + startColor := "" + endColor := "" + for _, when := range colorRules { + if left < when.end && left >= when.start { + startColor = coloring.Start + endColor = coloring.End + } + } + leftString := fmt.Sprintf("%d", left) + if len(leftString) < 2 { + leftString = "0" + leftString + } + expires := fmt.Sprintf("%s%s (%s)%s", startColor, now.Format("15:04:05"), leftString, endColor) + outputs := []string{expires} + if !args.Clip { + outputs = append(outputs, fmt.Sprintf("%s\n %s", tok, code)) + if !args.Once { + outputs = append(outputs, "-> CTRL+C to exit") + } + } else { + fmt.Printf("-> %s\n", expires) + clipboard.CopyTo(code, exe) + return nil + } + if !args.Once { + clear() + } + fmt.Printf("%s\n", strings.Join(outputs, "\n\n")) + if args.Once { + return nil + } + } +} + +// TOTP handles UI for TOTP tokens. +func TOTP(args []string) error { + if len(args) > 2 || len(args) < 1 { + return errors.New("invalid arguments, subkey and entry required") + } + cmd := args[0] + options := cli.ParseArgs(cmd) + if options.List { + f := store.NewFileSystemStore() + token := f.NewFile(totpEnv()) + results, err := f.List(store.ViewOptions{ErrorOnEmpty: true, Filter: func(path string) string { + if filepath.Base(path) == token { + return filepath.Dir(f.CleanPath(path)) + } + return "" + }}) + if err != nil { + return err + } + sort.Strings(results) + for _, entry := range results { + fmt.Println(entry) + } + return nil + } + if len(args) == 2 { + if !options.Clip && !options.Short && !options.Once { + return errors.New("invalid sub command") + } + cmd = args[1] + } + if err := display(cmd, options); err != nil { + return err + } + return nil +} diff --git a/tests/run.sh b/tests/run.sh @@ -43,8 +43,8 @@ _run() { "$BIN/lb" show keys2/three echo "y" | "$BIN/lb" dump keys2/three echo "5ae472abqdekjqykoyxk7hvc2leklq5n" | "$BIN/lb" insert test/totp - "$BIN/lb-totp" -list - "$BIN/lb-totp" test | tr '[:digit:]' 'X' + "$BIN/lb" "totp" -list + "$BIN/lb" "totp" test | tr '[:digit:]' 'X' "$BIN/lb" "gitdiff" bin/lb/keys/one.lb bin/lb/keys/one2.lb yes 2>/dev/null | "$BIN/lb" rm keys2/three echo