lockbox

password manager
Log | Files | Refs | README | LICENSE

commit c8172d500194a7ae126ab17e3ff1ed0bb38f1014
parent 7f64666c41a9e2f2dd46b154b43c903dca864004
Author: Sean Enck <sean@ttypty.com>
Date:   Sat,  1 Oct 2022 11:19:06 -0400

using a kdbx backend now

Diffstat:
Mcmd/main.go | 164+++++++++++++++++++++++++------------------------------------------------------
Mcmd/vers.txt | 2+-
Mcontrib/completions.bash | 8+-------
Mgo.mod | 2+-
Dinternal/dump/export.go | 19-------------------
Dinternal/encrypt/core.go | 169-------------------------------------------------------------------------------
Dinternal/encrypt/core_test.go | 123-------------------------------------------------------------------------------
Dinternal/hooks/execute.go | 54------------------------------------------------------
Minternal/inputs/env.go | 14--------------
Dinternal/store/filesystem.go | 171-------------------------------------------------------------------------------
Dinternal/store/filesystem_test.go | 123-------------------------------------------------------------------------------
Dinternal/subcommands/display.go | 79-------------------------------------------------------------------------------
Minternal/subcommands/gitdiff.go | 12++++++++----
Dinternal/subcommands/kdbx.go | 92-------------------------------------------------------------------------------
Dinternal/subcommands/listfind.go | 35-----------------------------------
Dinternal/subcommands/readwrite.go | 42------------------------------------------
Dinternal/subcommands/rekey.go | 50--------------------------------------------------
Minternal/subcommands/totp.go | 40+++++++++++++++++-----------------------
Mtests/expected.log | 52++++++++++++----------------------------------------
Mtests/run.sh | 36++++--------------------------------
20 files changed, 95 insertions(+), 1192 deletions(-)

diff --git a/cmd/main.go b/cmd/main.go @@ -6,16 +6,12 @@ import ( "errors" "fmt" "os" - "path/filepath" "strings" + "github.com/enckse/lockbox/internal/backend" "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/platform" - "github.com/enckse/lockbox/internal/store" "github.com/enckse/lockbox/internal/subcommands" ) @@ -32,25 +28,19 @@ type ( } ) -func getEntry(fs store.FileSystem, args []string, idx int) string { +func getEntry(args []string, idx int) string { if len(args) != idx+1 { exit("invalid entry given", errors.New("specific entry required")) } - return fs.NewPath(args[idx]) + return args[idx] } func internalCallback(name string) callbackFunction { switch name { - case "gitdiff": + case "diff": return subcommands.GitDiff - case "rekey": - return subcommands.Rekey - case "rw": - return subcommands.ReadWrite case "totp": return subcommands.TOTP - case "kdbx": - return subcommands.ToKeepass } return nil } @@ -79,22 +69,28 @@ func run() *programError { if len(args) < 2 { return newError("missing arguments", errors.New("requires subcommand")) } + t, err := backend.NewTransaction() + if err != nil { + return newError("unable to build transaction model", err) + } command := args[1] switch command { - case "ls", "list", "find": - opts := subcommands.ListFindOptions{Find: command == "find", Search: "", Store: store.NewFileSystemStore()} - if opts.Find { + case "ls", "find": + opts := backend.QueryOptions{} + opts.Mode = backend.ListMode + if command == "find" { + opts.Mode = backend.FindMode if len(args) < 3 { return newError("find requires an argument to search for", errors.New("search term required")) } - opts.Search = args[2] + opts.Criteria = args[2] } - files, err := subcommands.ListFindCallback(opts) + e, err := t.QueryCallback(opts) if err != nil { return newError("unable to list files", err) } - for _, f := range files { - fmt.Println(f) + for _, f := range e { + fmt.Println(f.Path) } case "version": fmt.Printf("version: %s\n", version) @@ -115,122 +111,64 @@ func run() *programError { return newError("too many arguments", errors.New("insert can only perform one operation")) } isPipe := inputs.IsInputFromPipe() - s := store.NewFileSystemStore() - entry := getEntry(s, args, idx) - if s.Exists(entry) { + entry := getEntry(args, idx) + existing, err := t.Get(entry, backend.BlankValue) + if err != nil { + return newError("unable to find an exact, existing match", err) + } + if existing != nil { if !isPipe { if !confirm("overwrite existing") { return nil } } - } else { - dir := filepath.Dir(entry) - if !s.Exists(dir) { - if err := os.MkdirAll(dir, 0755); err != nil { - return newError("failed to create directory structure", err) - } - } } password, err := inputs.GetUserInputPassword(isPipe, options.Multi) if err != nil { return newError("invalid input", err) } - if err := encrypt.ToFile(entry, password); err != nil { - return newError("unable to encrypt object", err) + p := strings.TrimSpace(string(password)) + if err := t.Insert(entry, p, len(strings.Split(p, "\n")) > 1); err != nil { + return newError("failed to insert", err) } fmt.Println("") - hooks.Run(hooks.Insert, hooks.PostStep) - if err := s.GitCommit(entry); err != nil { - return newError("failed to git commit changed", err) - } case "rm": - s := store.NewFileSystemStore() - value := args[2] - var deletes []string - confirmText := "entry" - if strings.Contains(value, "*") { - globs, err := s.Globs(value) - if err != nil { - return newError("rm glob failed", err) - } - if len(globs) > 1 { - confirmText = "entries" - } - deletes = append(deletes, globs...) - } else { - deletes = []string{getEntry(s, args, 2)} - } - if len(deletes) == 0 { - return newError("nothing to delete", errors.New("no files to remove")) - } - if confirm(fmt.Sprintf("remove %s", confirmText)) { - for _, entry := range deletes { - if !s.Exists(entry) { - return newError("does not exists", errors.New("can not delete unknown entry")) - } - } - for _, entry := range deletes { - if err := os.Remove(entry); err != nil { - return newError("unable to remove entry", err) - } - } - hooks.Run(hooks.Remove, hooks.PostStep) - if err := s.GitRemove(deletes); err != nil { - return newError("failed to git remove", err) - } - } - 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) + deleting := getEntry(args, 2) + existing, err := t.Get(deleting, backend.BlankValue) if err != nil { - return newError("display command failed to retrieve data", err) + return newError("unable to get entity to delete", err) } - if opts.Dump { - if !options.Yes { - if !confirm("dump data to stdout as plaintext") { - return nil - } + if confirm("delete entry") { + if err := t.Remove(existing); err != nil { + return newError("unable to remove entry", err) } - d, err := dump.Marshal(dumpData) - if err != nil { - return newError("failed to marshal items", err) - } - fmt.Println(string(d)) - return nil + } + case "show", "clip": + entry := getEntry(args, 2) clipboard := platform.Clipboard{} - if !opts.Show { + isShow := command == "show" + if isShow { clipboard, err = platform.NewClipboard() if err != nil { return newError("unable to get clipboard", err) } } - for _, obj := range dumpData { - if opts.Show { - if obj.Path != "" { - fmt.Println(obj.Path) - } - fmt.Println(obj.Value) - continue - } - if err := clipboard.CopyTo(obj.Value); err != nil { - return newError("clipboard failed", err) - } + existing, err := t.Get(entry, backend.SecretValue) + if err != nil { + return newError("unable to get entity", err) + } + if existing == nil { + return newError("entity not found", errors.New("can not find entry")) } + if isShow { + fmt.Println(existing.Value) + return nil + } + if err := clipboard.CopyTo(existing.Value); err != nil { + return newError("clipboard failed", err) + } + case "clear": if err := subcommands.ClearClipboardCallback(); err != nil { return newError("failed to handle clipboard clear", err) diff --git a/cmd/vers.txt b/cmd/vers.txt @@ -1 +1 @@ -v22.09.06 +v22.10.00 diff --git a/contrib/completions.bash b/contrib/completions.bash @@ -18,15 +18,12 @@ _lb() { fi cur=${COMP_WORDS[COMP_CWORD]} if [ "$COMP_CWORD" -eq 1 ]; then - opts="version ls show insert rm rekey totp list dump kdbx find$clip_enabled" + opts="version ls show insert rm totp find$clip_enabled" # shellcheck disable=SC2207 COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) else if [ "$COMP_CWORD" -eq 2 ]; then case ${COMP_WORDS[1]} in - "dump") - opts="-yes $(lb ls)" - ;; "insert") opts="-multi $(lb ls)" ;; @@ -36,9 +33,6 @@ _lb() { opts="$opts -clip" fi ;; - "kdbx") - opts="-file -password" - ;; "show" | "rm" | "clip") opts=$(lb ls) if [ $(_is_clip "${COMP_WORDS[1]}" "") == 1 ]; then diff --git a/go.mod b/go.mod @@ -6,12 +6,12 @@ require ( github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/pquerna/otp v1.3.0 github.com/tobischo/gokeepasslib/v3 v3.4.1 - golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be ) require ( github.com/aead/argon2 v0.0.0-20180111183520-a87724528b07 // indirect github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect github.com/boombuler/barcode v1.0.1 // indirect + golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be // indirect golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec // indirect ) diff --git a/internal/dump/export.go b/internal/dump/export.go @@ -1,19 +0,0 @@ -// Package dump handles export lockbox definitions to other formats. -package dump - -import ( - "encoding/json" -) - -type ( - // ExportEntity represents the output structure from a JSON dump. - ExportEntity struct { - Path string `json:"path,omitempty"` - Value string `json:"value"` - } -) - -// Marshal handles marshalling of entities to output formats. -func Marshal(entities []ExportEntity) ([]byte, error) { - return json.MarshalIndent(entities, "", " ") -} diff --git a/internal/encrypt/core.go b/internal/encrypt/core.go @@ -1,169 +0,0 @@ -// Package encrypt handles encryption/decryption. -package encrypt - -import ( - "crypto/rand" - "crypto/sha512" - "errors" - "io" - random "math/rand" - "os" - "time" - - "github.com/enckse/lockbox/internal/inputs" - "golang.org/x/crypto/nacl/secretbox" - "golang.org/x/crypto/pbkdf2" -) - -const ( - keyLength = 32 - nonceLength = 24 - padLength = 256 - saltLength = 16 -) - -var ( - cryptoMajorVers = uint8(0) - cryptoMinorVers = uint8(1) - cryptoVers = []byte{cryptoMajorVers, cryptoMinorVers} - cryptoVersLength = len(cryptoVers) - requiredEncryptLength = cryptoVersLength + saltLength + nonceLength -) - -type ( - // Lockbox represents a method to encrypt/decrypt locked files. - Lockbox struct { - secret [keyLength]byte - file string - } - - // LockboxOptions represent options to create a lockbox from. - LockboxOptions struct { - Key string - KeyMode string - File string - } -) - -// FromFile decrypts a file-system based encrypted file. -func FromFile(file string) ([]byte, error) { - l, err := NewLockbox(LockboxOptions{File: file}) - if err != nil { - return nil, err - } - return l.Decrypt() -} - -// ToFile encrypts data to a file-system based file. -func ToFile(file string, data []byte) error { - l, err := NewLockbox(LockboxOptions{File: file}) - if err != nil { - return err - } - return l.Encrypt(data) -} - -// NewLockbox creates a new usable lockbox instance. -func NewLockbox(options LockboxOptions) (Lockbox, error) { - return newLockbox(options.Key, options.KeyMode, options.File) -} - -func newLockbox(key, keyMode, file string) (Lockbox, error) { - b, err := inputs.GetKey(key, keyMode) - if err != nil { - return Lockbox{}, err - } - var secretKey [keyLength]byte - copy(secretKey[:], b) - return Lockbox{secret: secretKey, file: file}, nil -} - -func pad(salt, key []byte) ([keyLength]byte, error) { - d := pbkdf2.Key(key, salt, 4096, keyLength, sha512.New) - if len(d) != keyLength { - return [keyLength]byte{}, errors.New("invalid key result from pad") - } - var obj [keyLength]byte - copy(obj[:], d[:keyLength]) - return obj, nil -} - -func init() { - random.Seed(time.Now().UnixNano()) -} - -// Encrypt will encrypt contents to file. -func (l Lockbox) Encrypt(datum []byte) error { - data := datum - if data == nil { - b, err := inputs.RawStdin() - if err != nil { - return err - } - data = b - } - if len(data) == 0 { - return errors.New("no data") - } - var padding [padLength]byte - if _, err := io.ReadFull(rand.Reader, padding[:]); err != nil { - return err - } - padTo := random.Intn(padLength) - var write []byte - write = append(write, byte(padTo)) - write = append(write, padding[0:padTo]...) - write = append(write, data...) - var salt [saltLength]byte - if _, err := io.ReadFull(rand.Reader, salt[:]); err != nil { - return err - } - var nonce [nonceLength]byte - if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil { - return err - } - key, err := pad(salt[:], l.secret[:]) - if err != nil { - return err - } - encrypted := secretbox.Seal(nonce[:], write, &nonce, &key) - var persist []byte - persist = append(persist, cryptoVers...) - persist = append(persist, salt[:]...) - persist = append(persist, encrypted...) - return os.WriteFile(l.file, persist, 0600) -} - -// Decrypt will decrypt an object from file. -func (l Lockbox) Decrypt() ([]byte, error) { - encrypted, err := os.ReadFile(l.file) - if err != nil { - return nil, err - } - if len(encrypted) <= requiredEncryptLength { - return nil, errors.New("invalid encrypted data") - } - major := encrypted[0] - minor := encrypted[1] - if major != cryptoMajorVers || minor != cryptoMinorVers { - return nil, errors.New("invalid data, bad header") - } - var salt [saltLength]byte - copy(salt[:], encrypted[cryptoVersLength:saltLength+cryptoVersLength]) - key, err := pad(salt[:], l.secret[:]) - if err != nil { - return nil, err - } - var nonce [nonceLength]byte - copy(nonce[:], encrypted[cryptoVersLength+saltLength:cryptoVersLength+saltLength+nonceLength]) - decrypted, ok := secretbox.Open(nil, encrypted[cryptoVersLength+saltLength+nonceLength:], &nonce, &key) - if !ok { - return nil, errors.New("decrypt not ok") - } - - padding := 1 + int(decrypted[0]) - if len(decrypted) < padding { - return nil, errors.New("invalid decrypted data, bad padding") - } - return decrypted[padding:], nil -} diff --git a/internal/encrypt/core_test.go b/internal/encrypt/core_test.go @@ -1,123 +0,0 @@ -package encrypt_test - -import ( - "fmt" - "os" - "path/filepath" - "testing" - - "github.com/enckse/lockbox/internal/encrypt" - "github.com/enckse/lockbox/internal/inputs" - "github.com/enckse/lockbox/internal/store" -) - -func setupData(t *testing.T) string { - os.Setenv("LOCKBOX_KEYMODE", "") - os.Setenv("LOCKBOX_KEY", "") - if store.NewFileSystemStore().Exists("bin") { - if err := os.RemoveAll("bin"); err != nil { - t.Errorf("unable to cleanup dir: %v", err) - } - } - - if err := os.MkdirAll("bin", 0755); err != nil { - t.Errorf("failed to setup bin directory: %v", err) - } - return filepath.Join("bin", "test.lb") -} - -func TestEncryptDecryptCommand(t *testing.T) { - e, err := encrypt.NewLockbox(encrypt.LockboxOptions{Key: "echo test", KeyMode: inputs.CommandKeyMode, File: setupData(t)}) - if err != nil { - t.Errorf("failed to create lockbox: %v", err) - } - data := []byte("datum") - if err := e.Encrypt(data); err != nil { - t.Errorf("failed to encrypt: %v", err) - } - d, err := e.Decrypt() - if err != nil { - t.Errorf("failed to encrypt: %v", err) - } - if string(d) != string(data) { - t.Error("data mismatch") - } -} - -func TestEmptyKey(t *testing.T) { - setupData(t) - _, err := encrypt.NewLockbox(encrypt.LockboxOptions{}) - if err == nil || err.Error() != "no key given" { - t.Errorf("invalid error: %v", err) - } - _, err = encrypt.NewLockbox(encrypt.LockboxOptions{KeyMode: inputs.CommandKeyMode, Key: "echo"}) - if err == nil || err.Error() != "key is empty" { - t.Errorf("invalid error: %v", err) - } -} - -func TestKeyLength(t *testing.T) { - val := "" - for i := 0; i < 42; i++ { - val = fmt.Sprintf("a%s", val) - _, err := encrypt.NewLockbox(encrypt.LockboxOptions{KeyMode: inputs.PlainKeyMode, Key: val}) - if err != nil { - t.Error("no error expected") - } - } -} - -func TestUnknownMode(t *testing.T) { - _, err := encrypt.NewLockbox(encrypt.LockboxOptions{KeyMode: "aaa", Key: "echo"}) - if err == nil || err.Error() != "unknown keymode" { - t.Errorf("invalid error: %v", err) - } -} - -func TestEncryptDecryptPlainText(t *testing.T) { - e, err := encrypt.NewLockbox(encrypt.LockboxOptions{Key: "plain", KeyMode: inputs.PlainKeyMode, File: setupData(t)}) - if err != nil { - t.Errorf("failed to create lockbox: %v", err) - } - data := []byte("datum") - if err := e.Encrypt(data); err != nil { - t.Errorf("failed to encrypt: %v", err) - } - d, err := e.Decrypt() - if err != nil { - t.Errorf("failed to decrypt: %v", err) - } - if string(d) != string(data) { - t.Error("data mismatch") - } -} - -func TestEncryptDecryptErrors(t *testing.T) { - file := setupData(t) - e, _ := encrypt.NewLockbox(encrypt.LockboxOptions{Key: "plain", KeyMode: inputs.PlainKeyMode, File: file}) - if err := e.Encrypt([]byte{}); err.Error() != "no data" { - t.Errorf("failed, should be no data: %v", err) - } - os.WriteFile(file, []byte{0, 2, 3}, 0600) - if _, err := e.Decrypt(); err.Error() != "invalid encrypted data" { - t.Errorf("failed, should be invalid data: %v", err) - } - e.Encrypt([]byte("TEST")) - b, _ := os.ReadFile(file) - b[0] = 1 - os.WriteFile(file, b, 0600) - if _, err := e.Decrypt(); err.Error() != "invalid data, bad header" { - t.Errorf("failed, should be invalid header data: %v", err) - } - b[0] = 0 - b[1] = 0 - os.WriteFile(file, b, 0600) - if _, err := e.Decrypt(); err.Error() != "invalid data, bad header" { - t.Errorf("failed, should be invalid header data: %v", err) - } - b[1] = 1 - os.WriteFile(file, b, 0600) - if _, err := e.Decrypt(); err != nil { - t.Error("decrypt should succeed") - } -} diff --git a/internal/hooks/execute.go b/internal/hooks/execute.go @@ -1,54 +0,0 @@ -// Package hooks handles executing lockbox hooks. -package hooks - -import ( - "errors" - "os" - "os/exec" - "path/filepath" - - "github.com/enckse/lockbox/internal/inputs" - "github.com/enckse/lockbox/internal/store" -) - -type ( - // Action are specific steps that may call a hook. - Action string - // Step is the step, during command execution, when the hook was called. - Step string -) - -const ( - // Remove is called when a store entry is removed. - Remove Action = "remove" - // Insert is called when a store entry is inserted. - Insert Action = "insert" - // PostStep is a hook running at the end of a command. - PostStep Step = "post" -) - -// Run executes any configured hooks. -func Run(action Action, step Step) error { - hookDir := os.Getenv(inputs.HooksDirEnv) - if !store.NewFileSystemStore().Exists(hookDir) { - return nil - } - dirs, err := os.ReadDir(hookDir) - if err != nil { - return err - } - for _, d := range dirs { - if !d.IsDir() { - name := d.Name() - cmd := exec.Command(filepath.Join(hookDir, name), string(action), string(step)) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return err - } - continue - } - return errors.New("hook is not a file") - } - return nil -} diff --git a/internal/inputs/env.go b/internal/inputs/env.go @@ -16,16 +16,12 @@ const ( noClipEnv = prefixKey + "NOCLIP" noColorEnv = prefixKey + "NOCOLOR" interactiveEnv = prefixKey + "INTERACTIVE" - gitEnabledEnv = prefixKey + "GIT" - gitQuietEnv = gitEnabledEnv + "_QUIET" // TotpEnv allows for overriding of the special name for totp entries. TotpEnv = prefixKey + "TOTP" // KeyModeEnv indicates what the KEY value is (e.g. command, plaintext). KeyModeEnv = prefixKey + "KEYMODE" // KeyEnv is the key value used by the lockbox store. KeyEnv = prefixKey + "KEY" - // HooksDirEnv is the location of hooks to run before/after operations. - HooksDirEnv = prefixKey + "HOOKDIR" // PlatformEnv is the platform lb is running on. PlatformEnv = prefixKey + "PLATFORM" // StoreEnv is the location of the filesystem store that lb is operating on. @@ -116,16 +112,6 @@ func IsNoClipEnabled() (bool, error) { return isYesNoEnv(false, noClipEnv) } -// IsGitQuiet indicates if git operations should be 'quiet' (no stdout/stderr) -func IsGitQuiet() (bool, error) { - return isYesNoEnv(true, gitQuietEnv) -} - -// IsGitEnabled indicates if the filesystem store is a git repo -func IsGitEnabled() (bool, error) { - return isYesNoEnv(true, gitEnabledEnv) -} - // IsNoColorEnabled indicates if the flag is set to disable color. func IsNoColorEnabled() (bool, error) { return isYesNoEnv(false, noColorEnv) diff --git a/internal/store/filesystem.go b/internal/store/filesystem.go @@ -1,171 +0,0 @@ -// Package store handles filesystem operations for a lockbox store. -package store - -import ( - "errors" - "fmt" - "io/fs" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - - "github.com/enckse/lockbox/internal/inputs" -) - -const ( - extension = ".lb" -) - -type ( - // ListEntryFilter allows for filtering/changing view results. - ListEntryFilter func(string) string - // FileSystem represents a filesystem store. - FileSystem struct { - path string - } - // ViewOptions represent list options for parsing store entries. - ViewOptions struct { - Display bool - Filter ListEntryFilter - ErrorOnEmpty bool - } -) - -// NewFileSystemStore gets the lockbox directory (filesystem-based) store. -func NewFileSystemStore() FileSystem { - return FileSystem{path: os.Getenv(inputs.StoreEnv)} -} - -// Globs will return any globs from the input path from within the store. -func (s FileSystem) Globs(inputPath string) ([]string, error) { - return filepath.Glob(filepath.Join(s.path, inputPath)) -} - -// List will get all lockbox files in a store. -func (s FileSystem) List(options ViewOptions) ([]string, error) { - var results []string - if !pathExists(s.path) { - return nil, errors.New("store does not exist") - } - err := filepath.Walk(s.path, func(path string, info fs.FileInfo, err error) error { - if err != nil { - return err - } - if strings.HasSuffix(path, extension) { - usePath := path - if options.Display { - usePath = s.trim(usePath) - } - if options.Filter != nil { - usePath = options.Filter(usePath) - if usePath == "" { - return nil - } - } - results = append(results, usePath) - } - return nil - }) - - if err != nil { - return nil, err - } - if options.ErrorOnEmpty && len(results) == 0 { - return nil, errors.New("no results found") - } - if options.Display { - sort.Strings(results) - } - return results, nil -} - -// NewPath creates a new filesystem store path for an entry. -func (s FileSystem) NewPath(file string) string { - return s.NewFile(filepath.Join(s.path, file)) -} - -// NewFile creates a new file with the proper extension. -func (s FileSystem) NewFile(file string) string { - if !strings.HasSuffix(file, extension) { - return file + extension - } - return file -} - -// CleanPath will clean store and extension information from an entry. -func (s FileSystem) CleanPath(fullPath string) string { - return s.trim(fullPath) -} - -func (s FileSystem) trim(path string) string { - f := strings.TrimPrefix(path, s.path) - f = strings.TrimPrefix(f, string(os.PathSeparator)) - return strings.TrimSuffix(f, extension) -} - -// Exists will check if a path exists -func (s FileSystem) Exists(path string) bool { - return pathExists(path) -} - -// pathExists indicates if a path exists. -func pathExists(path string) bool { - if _, err := os.Stat(path); err != nil { - if os.IsNotExist(err) { - return false - } - } - return true -} - -// GitCommit is for adding/changing entities -func (s FileSystem) GitCommit(entry string) error { - return s.gitAction("add", []string{entry}) -} - -// GitRemove is for removing entities -func (s FileSystem) GitRemove(entries []string) error { - return s.gitAction("rm", entries) -} - -func (s FileSystem) gitAction(action string, entries []string) error { - ok, err := inputs.IsGitEnabled() - if err != nil { - return err - } - if !ok { - return nil - } - if !pathExists(filepath.Join(s.path, ".git")) { - return nil - } - var message []string - for _, entry := range entries { - useEntry, err := filepath.Rel(s.path, entry) - if err != nil { - return err - } - if err := s.gitRun(action, useEntry); err != nil { - return err - } - message = append(message, fmt.Sprintf("lb %s: %s", action, useEntry)) - } - return s.gitRun("commit", "-m", strings.Join(message, "\n")) -} - -func (s FileSystem) gitRun(args ...string) error { - arguments := []string{"-C", s.path} - arguments = append(arguments, args...) - cmd := exec.Command("git", arguments...) - ok, err := inputs.IsGitQuiet() - if err != nil { - return err - } - if !ok { - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - } - return cmd.Run() -} diff --git a/internal/store/filesystem_test.go b/internal/store/filesystem_test.go @@ -1,123 +0,0 @@ -package store_test - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/enckse/lockbox/internal/inputs" - "github.com/enckse/lockbox/internal/store" -) - -func TestListErrors(t *testing.T) { - os.Setenv(inputs.StoreEnv, "aaa") - _, err := store.NewFileSystemStore().List(store.ViewOptions{}) - if err == nil || err.Error() != "store does not exist" { - t.Errorf("invalid store error: %v", err) - } -} - -func TestList(t *testing.T) { - testStore := "bin" - if store.NewFileSystemStore().Exists(testStore) { - if err := os.RemoveAll(testStore); err != nil { - t.Errorf("invalid error on remove: %v", err) - } - } - if err := os.MkdirAll(filepath.Join(testStore, "sub"), 0755); err != nil { - t.Errorf("unable to makedir: %v", err) - } - for _, path := range []string{"test", "test2", "aaa", "sub/aaaaajk", "sub/12lkjafav"} { - if err := os.WriteFile(filepath.Join(testStore, path+".lb"), []byte(""), 0644); err != nil { - t.Errorf("failed to write %s: %v", path, err) - } - } - os.Setenv(inputs.StoreEnv, testStore) - s := store.NewFileSystemStore() - res, err := s.List(store.ViewOptions{}) - if err != nil { - t.Errorf("unable to list: %v", err) - } - if len(res) != 5 { - t.Error("mismatched results") - } - res, err = s.List(store.ViewOptions{Display: true}) - if err != nil { - t.Errorf("unable to list: %v", err) - } - if len(res) != 5 { - t.Error("mismatched results") - } - if res[0] != "aaa" || res[1] != "sub/12lkjafav" || res[2] != "sub/aaaaajk" || res[3] != "test" || res[4] != "test2" { - t.Errorf("not sorted: %v", res) - } - idx := 0 - res, err = s.List(store.ViewOptions{Filter: func(path string) string { - if strings.Contains(path, "test") { - idx++ - return fmt.Sprintf("%d", idx) - } - return "" - }}) - if err != nil { - t.Errorf("unable to list: %v", err) - } - if len(res) != 2 || res[0] != "1" || res[1] != "2" { - t.Error("mismatch filter results") - } - res, err = s.List(store.ViewOptions{ErrorOnEmpty: false, Filter: func(path string) string { - return "" - }}) - if err != nil { - t.Errorf("should be non-error: %v", err) - } - if len(res) != 0 { - t.Error("should be empty list") - } - _, err = s.List(store.ViewOptions{ErrorOnEmpty: true, Filter: func(path string) string { - return "" - }}) - if err == nil || err.Error() != "no results found" { - t.Errorf("should be non-error: %v", err) - } -} - -func TestFileSystemFile(t *testing.T) { - os.Setenv(inputs.StoreEnv, "abc") - f := store.NewFileSystemStore() - p := f.NewPath("test") - if p != "abc/test.lb" { - t.Error("invalid join result") - } -} - -func TestCleanPath(t *testing.T) { - os.Setenv(inputs.StoreEnv, "abc") - f := store.NewFileSystemStore() - c := f.CleanPath("xyz") - if c != "xyz" { - t.Error("invalid clean") - } - c = f.CleanPath("abc/xyz") - if c != "xyz" { - t.Error("invalid clean") - } - c = f.CleanPath("xyz.lb.lb") - if c != "xyz.lb" { - t.Error("invalid clean") - } -} - -func TestNewFile(t *testing.T) { - os.Setenv(inputs.StoreEnv, "abc") - f := store.NewFileSystemStore().NewFile("xyz") - if f != "xyz.lb" { - t.Error("invalid file") - } - f = store.NewFileSystemStore().NewFile("xyz.lb") - if f != "xyz.lb" { - t.Error("invalid file, had suffix") - } -} diff --git a/internal/subcommands/display.go b/internal/subcommands/display.go @@ -1,79 +0,0 @@ -// Package subcommands handles displaying various lockbox structures to the UI. -package subcommands - -import ( - "errors" - "fmt" - "path/filepath" - "sort" - "strings" - - "github.com/enckse/lockbox/internal/colors" - "github.com/enckse/lockbox/internal/dump" - "github.com/enckse/lockbox/internal/encrypt" - "github.com/enckse/lockbox/internal/store" -) - -type ( - // DisplayOptions for getting a set of items for display uses. - DisplayOptions struct { - Dump bool - Entry string - Show bool - Glob string - All bool - Store store.FileSystem - } -) - -// DisplayCallback handles getting entries for display. -func DisplayCallback(args DisplayOptions) ([]dump.ExportEntity, error) { - entries := []string{args.Entry} - if strings.Contains(args.Entry, "*") || args.All { - if args.Entry == args.Glob || args.All { - all, err := args.Store.List(store.ViewOptions{}) - if err != nil { - return nil, err - } - entries = all - } else { - matches, err := filepath.Glob(args.Entry) - if err != nil { - return nil, err - } - entries = matches - } - } - isGlob := len(entries) > 1 - if isGlob { - if !args.Show { - return nil, errors.New("bad glob request") - } - sort.Strings(entries) - } - coloring, err := colors.NewTerminal(colors.Red) - if err != nil { - return nil, err - } - dumpData := []dump.ExportEntity{} - for _, entry := range entries { - if !args.Store.Exists(entry) { - return nil, errors.New("entry not found") - } - decrypt, err := encrypt.FromFile(entry) - if err != nil { - return nil, err - } - entity := dump.ExportEntity{Value: strings.TrimSpace(string(decrypt))} - if args.Show && isGlob { - fileName := args.Store.CleanPath(entry) - if args.Dump { - entity.Path = fileName - } else { - entity.Path = fmt.Sprintf("%s%s:%s", coloring.Start, fileName, coloring.End) - } - } - dumpData = append(dumpData, entity) - } - return dumpData, nil -} diff --git a/internal/subcommands/gitdiff.go b/internal/subcommands/gitdiff.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" - "github.com/enckse/lockbox/internal/encrypt" + "github.com/enckse/lockbox/internal/backend" ) // GitDiff handles git diffing of lb entries. @@ -13,12 +13,16 @@ func GitDiff(args []string) error { if len(args) == 0 { return errors.New("git diff requires a file") } - result, err := encrypt.FromFile(args[len(args)-1]) + t, err := backend.Load(args[len(args)-1]) if err != nil { return err } - if result != nil { - fmt.Println(string(result)) + e, err := t.QueryCallback(backend.QueryOptions{Mode: backend.ListMode, Values: backend.HashedValue}) + if err != nil { + return err + } + for _, item := range e { + fmt.Printf("%s:\nhash:%s\n", item.Path, item.Value) } return nil } diff --git a/internal/subcommands/kdbx.go b/internal/subcommands/kdbx.go @@ -1,92 +0,0 @@ -package subcommands - -import ( - "errors" - "flag" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/enckse/lockbox/internal/inputs" - "github.com/enckse/lockbox/internal/store" - "github.com/tobischo/gokeepasslib/v3" - "github.com/tobischo/gokeepasslib/v3/wrappers" -) - -func value(key string, value string) gokeepasslib.ValueData { - return gokeepasslib.ValueData{Key: key, Value: gokeepasslib.V{Content: value}} -} - -func protectedValue(key string, value string) gokeepasslib.ValueData { - return gokeepasslib.ValueData{ - Key: key, - Value: gokeepasslib.V{Content: value, Protected: wrappers.NewBoolWrapper(true)}, - } -} - -// ToKeepass converts the lb store to a kdbx file. -func ToKeepass(args []string) error { - flags := flag.NewFlagSet("kdbx", flag.ExitOnError) - file := flags.String("file", "", "file to write to") - pass := flags.String("password", "", "password to use for the kdbx output (default is lb store key)") - if err := flags.Parse(args); err != nil { - return err - } - fileName := *file - if fileName == "" { - return errors.New("no file given") - } - key := *pass - if strings.TrimSpace(key) == "" { - v, err := inputs.GetKey("", "") - if err != nil { - return err - } - key = string(v) - } - entries, err := DisplayCallback(DisplayOptions{All: true, Dump: true, Show: true, Store: store.NewFileSystemStore()}) - if err != nil { - return err - } - if len(entries) == 0 { - return errors.New("nothing to convert") - } - root := gokeepasslib.NewGroup() - root.Name = "root" - count := 0 - for _, entry := range entries { - e := gokeepasslib.NewEntry() - path := entry.Path - val := entry.Value - e.Values = append(e.Values, value("Title", filepath.Dir(path))) - e.Values = append(e.Values, value("UserName", filepath.Base(path))) - field := "Password" - if len(strings.Split(strings.TrimSpace(val), "\n")) > 1 { - field = "Notes" - } - e.Values = append(e.Values, protectedValue(field, val)) - root.Entries = append(root.Entries, e) - count++ - } - db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4()) - db.Credentials = gokeepasslib.NewPasswordCredentials(key) - db.Content.Root = - &gokeepasslib.RootData{ - Groups: []gokeepasslib.Group{root}, - } - if err := db.LockProtectedEntries(); err != nil { - return err - } - f, err := os.Create(fileName) - if err != nil { - return err - } - defer f.Close() - encoder := gokeepasslib.NewEncoder(f) - if err := encoder.Encode(db); err != nil { - return err - } - fmt.Printf("exported %d entries to %s\n", count, fileName) - return nil -} diff --git a/internal/subcommands/listfind.go b/internal/subcommands/listfind.go @@ -1,35 +0,0 @@ -// Package subcommands handles listing items from the lockbox store. -package subcommands - -import ( - "strings" - - "github.com/enckse/lockbox/internal/store" -) - -type ( - // ListFindOptions for listing/finding entries in a store. - ListFindOptions struct { - Find bool - Search string - Store store.FileSystem - } -) - -// ListFindCallback for searching/finding/listing entries. -func ListFindCallback(args ListFindOptions) ([]string, error) { - viewOptions := store.ViewOptions{Display: true} - if args.Find { - viewOptions.Filter = func(inPath string) string { - if strings.Contains(inPath, args.Search) { - return inPath - } - return "" - } - } - files, err := args.Store.List(viewOptions) - if err != nil { - return nil, err - } - return files, nil -} diff --git a/internal/subcommands/readwrite.go b/internal/subcommands/readwrite.go @@ -1,42 +0,0 @@ -// Package subcommands perform a read/write against a specific lockbox object. -package subcommands - -import ( - "errors" - "flag" - "fmt" - - "github.com/enckse/lockbox/internal/encrypt" -) - -// ReadWrite performs singular read/write encryption operations. -func ReadWrite(args []string) error { - flags := flag.NewFlagSet("readwrite", flag.ExitOnError) - mode := flags.String("mode", "", "decrypt/encrypt") - key := flags.String("key", "", "security key") - file := flags.String("file", "", "file to process") - keyMode := flags.String("keymode", "", "key lookup mode") - if err := flags.Parse(args); err != nil { - return err - } - - l, err := encrypt.NewLockbox(encrypt.LockboxOptions{Key: *key, KeyMode: *keyMode, File: *file}) - if err != nil { - return err - } - switch *mode { - case "encrypt": - if err := l.Encrypt(nil); err != nil { - return err - } - case "decrypt": - results, err := l.Decrypt() - if err != nil { - return err - } - fmt.Println(string(results)) - default: - return errors.New("invalid read/write modeE") - } - return nil -} diff --git a/internal/subcommands/rekey.go b/internal/subcommands/rekey.go @@ -1,50 +0,0 @@ -// Package subcommands handles rekeying. -package subcommands - -import ( - "flag" - "fmt" - "strings" - - "github.com/enckse/lockbox/internal/encrypt" - "github.com/enckse/lockbox/internal/store" -) - -// Rekey handles rekeying a lockbox entirely. -func Rekey(args []string) error { - flags := flag.NewFlagSet("rekey", flag.ExitOnError) - inKey := flags.String("inkey", "", "input encryption key to read current values") - outKey := flags.String("outkey", "", "output encryption key to update values with") - inMode := flags.String("inmode", "", "input encryption key mode") - outMode := flags.String("outmode", "", "output encryption key mode") - if err := flags.Parse(args); err != nil { - return err - } - found, err := store.NewFileSystemStore().List(store.ViewOptions{}) - if err != nil { - return err - } - inOpts := encrypt.LockboxOptions{Key: *inKey, KeyMode: *inMode} - outOpts := encrypt.LockboxOptions{Key: *outKey, KeyMode: *outMode} - for _, file := range found { - fmt.Printf("rekeying: %s\n", file) - inOpts.File = file - in, err := encrypt.NewLockbox(inOpts) - if err != nil { - return err - } - decrypt, err := in.Decrypt() - if err != nil { - return err - } - outOpts.File = file - out, err := encrypt.NewLockbox(outOpts) - if err != nil { - return err - } - if err := out.Encrypt([]byte(strings.TrimSpace(string(decrypt)))); err != nil { - return err - } - } - return nil -} diff --git a/internal/subcommands/totp.go b/internal/subcommands/totp.go @@ -6,18 +6,15 @@ import ( "fmt" "os" "os/exec" - "path/filepath" - "sort" "strconv" "strings" "time" + "github.com/enckse/lockbox/internal/backend" "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/platform" - "github.com/enckse/lockbox/internal/store" otp "github.com/pquerna/otp/totp" ) @@ -92,17 +89,18 @@ func display(token string, args cli.Arguments) error { if err != nil { return err } - f := store.NewFileSystemStore() - tok := filepath.Join(strings.TrimSpace(token), totpEnv()) - pathing := f.NewPath(tok) - if !f.Exists(pathing) { - return errors.New("object does not exist") + t, err := backend.NewTransaction() + if err != nil { + return err } - val, err := encrypt.FromFile(pathing) + entity, err := t.Get(token, backend.SecretValue) if err != nil { return err } - totpToken := string(val) + if entity == nil { + return errors.New("object does not exist") + } + totpToken := string(entity.Value) if !interactive { code, err := otp.GenerateCode(totpToken, time.Now()) if err != nil { @@ -166,7 +164,7 @@ func display(token string, args cli.Arguments) error { 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)) + outputs = append(outputs, fmt.Sprintf("%s\n %s", token, code)) if !args.Once { outputs = append(outputs, "-> CTRL+C to exit") } @@ -192,20 +190,16 @@ func TOTP(args []string) error { 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 "" - }}) + t, err := backend.NewTransaction() + if err != nil { + return err + } + e, err := t.QueryCallback(backend.QueryOptions{Mode: backend.SuffixMode, Criteria: fmt.Sprintf("%c%s", os.PathSeparator, totpEnv())}) if err != nil { return err } - sort.Strings(results) - for _, entry := range results { - fmt.Println(entry) + for _, entry := range e { + fmt.Println(entry.Path) } return nil } diff --git a/tests/expected.log b/tests/expected.log @@ -1,32 +1,10 @@ -[] -HOOK RAN insert post -HOOK RAN insert post -keys/one: -test -keys/one2: -test2 -[ - { - "path": "keys/one", - "value": "test" - }, - { - "path": "keys/one2", - "value": "test2" - } -] -HOOK RAN insert post keys/one keys/one2 keys2/three -rekeying: /keys/one.lb -rekeying: /keys/one2.lb -rekeying: /keys2/three.lb -remove entry? (y/N) HOOK RAN remove post - +delete entry? (y/N) keys/one2 keys2/three keys/one2 @@ -34,21 +12,14 @@ keys2/three test2 test3 test4 -dump data to stdout as plaintext? (y/N) [ - { - "value": "test3\ntest4" - } -] - -HOOK RAN insert post -test -XXXXXX -test2 -remove entry? (y/N) HOOK RAN remove post - -remove entry? (y/N) HOOK RAN remove post -exported 1 entries to bin/file.kdbx -display command failed to retrieve data (decrypt not ok) -rekeying: /keys/one2.lb -test2 +test/totp +totp command failure (object does not exist) +keys/one2: +hash:7465737432cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e +keys2/three: +hash:74657374330a7465737434cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e +test/totp: +hash:35616534373261627164656b6a71796b6f79786b37687663326c656b6c71356ecf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e +delete entry? (y/N) +delete entry? (y/N) +\ No newline at end of file diff --git a/tests/run.sh b/tests/run.sh @@ -2,61 +2,33 @@ BIN="$1" TESTS="$2" -export LOCKBOX_STORE="$TESTS/lb" +export LOCKBOX_STORE="$TESTS/lb.kdbx" export LOCKBOX_KEYMODE="plaintext" export LOCKBOX_KEY="plaintextkey" export LOCKBOX_TOTP="totp" export LOCKBOX_INTERACTIVE="no" -export LOCKBOX_HOOKDIR="$TESTS/hooks" -export LOCKBOX_GIT="no" rm -rf $TESTS -mkdir -p $LOCKBOX_STORE -mkdir -p $LOCKBOX_STORE/$LOCKBOX_TOTP -git -C $LOCKBOX_STORE init -echo "TEST" > $LOCKBOX_STORE/init -git -C $LOCKBOX_STORE add . -git -C $LOCKBOX_STORE config user.email "you@example.com" -git -C $LOCKBOX_STORE config user.name "Your Name" -git -C $LOCKBOX_STORE commit -am "init" -HOOK=$LOCKBOX_HOOKDIR/hook -mkdir -p $LOCKBOX_HOOKDIR - -_hook() { - echo "#!/bin/sh" - echo "echo HOOK RAN \$@" -} +mkdir -p $TESTS _run() { - echo "y" | "$BIN/lb" dump -yes "*" echo "test" | "$BIN/lb" insert keys/one echo "test2" | "$BIN/lb" insert keys/one2 - "$BIN/lb" show keys/* - "$BIN/lb" dump -yes '***' echo -e "test3\ntest4" | "$BIN/lb" insert keys2/three "$BIN/lb" ls - "$BIN/lb" "rekey" yes 2>/dev/null | "$BIN/lb" rm keys/one echo - "$BIN/lb" list + "$BIN/lb" ls "$BIN/lb" find e "$BIN/lb" show keys/one2 "$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" "gitdiff" bin/lb/keys/one.lb bin/lb/keys/one2.lb + "$BIN/lb" "diff" $LOCKBOX_STORE yes 2>/dev/null | "$BIN/lb" rm keys2/three echo yes 2>/dev/null | "$BIN/lb" rm test/totp - echo - "$BIN/lb" kdbx -file bin/file.kdbx - LOCKBOX_KEY="invalid" "$BIN/lb" show keys/one2 - "$BIN/lb" "rekey" -outkey "test" -outmode "plaintext" - "$BIN/lb" rw -file bin/lb/keys/one2.lb -key "test" -keymode "plaintext" -mode "decrypt" } -_hook > $HOOK -chmod 755 $HOOK _run 2>&1 | sed "s#$LOCKBOX_STORE##g" > $TESTS/actual.log