lockbox

password manager
Log | Files | Refs | README | LICENSE

commit 036116b8955a9120c4fd323d99ce63076d23a46e
parent 5d79caf3c018b6659e92e3a8030147a85f5f88b7
Author: Sean Enck <sean@ttypty.com>
Date:   Sat, 17 Sep 2022 10:26:19 -0400

adding kdbx conversion helper

Diffstat:
Mcmd/main.go | 2++
Mcontrib/completions.bash | 5++++-
Mgo.mod | 3+++
Mgo.sum | 13+++++++++++++
Minternal/encrypt/core.go | 50+-------------------------------------------------
Minternal/encrypt/core_test.go | 9+++++----
Minternal/inputs/env.go | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/subcommands/display.go | 5+++--
Ainternal/subcommands/kdbx.go | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 177 insertions(+), 56 deletions(-)

diff --git a/cmd/main.go b/cmd/main.go @@ -47,6 +47,8 @@ func internalCallback(name string) callbackFunction { return subcommands.ReadWrite case "totp": return subcommands.TOTP + case "kdbx": + return subcommands.ToKeepass } return nil } diff --git a/contrib/completions.bash b/contrib/completions.bash @@ -18,7 +18,7 @@ _lb() { fi cur=${COMP_WORDS[COMP_CWORD]} if [ "$COMP_CWORD" -eq 1 ]; then - opts="version ls show insert rm rekey totp list dump find$clip_enabled" + opts="version ls show insert rm rekey totp list dump kdbx find$clip_enabled" # shellcheck disable=SC2207 COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) else @@ -36,6 +36,9 @@ _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 @@ -5,10 +5,13 @@ go 1.18 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-20220829220503-c86fa9a7ed90 ) 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/sys v0.0.0-20220913120320-3275c407cedc // indirect ) diff --git a/go.sum b/go.sum @@ -1,3 +1,7 @@ +github.com/aead/argon2 v0.0.0-20180111183520-a87724528b07 h1:i9/M2RadeVsPBMNwXFiaYkXQi9lY9VuZeI4Onavd3pA= +github.com/aead/argon2 v0.0.0-20180111183520-a87724528b07/go.mod h1:Tnm/osX+XXr9R+S71o5/F0E60sRkPVALdhWw25qPImQ= +github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY= +github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= @@ -12,7 +16,16 @@ github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/tobischo/gokeepasslib/v3 v3.4.1 h1:K7PwcVL4bUCmVFYQUNoBlUhl5GMPu67pY6QL07GL81Q= +github.com/tobischo/gokeepasslib/v3 v3.4.1/go.mod h1:iwxOzUuk/ccA0mitrFC4MovT1p0IRY8EA35L4u1x/ug= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 h1:Y/gsMcFOcR+6S6f3YeMKl5g+dZMEWqcz5Czj/GWYbkM= golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200513112337-417ce2331b5c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220913120320-3275c407cedc h1:dpclq5m2YrqPGStKmtw7IcNbKLfbIqKXvNxDJKdIKYc= golang.org/x/sys v0.0.0-20220913120320-3275c407cedc/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/encrypt/core.go b/internal/encrypt/core.go @@ -8,12 +8,9 @@ import ( "io" random "math/rand" "os" - "os/exec" - "strings" "time" "github.com/enckse/lockbox/internal/inputs" - "github.com/google/shlex" "golang.org/x/crypto/nacl/secretbox" "golang.org/x/crypto/pbkdf2" ) @@ -23,10 +20,6 @@ const ( nonceLength = 24 padLength = 256 saltLength = 16 - // PlainKeyMode is plaintext based key resolution. - PlainKeyMode = "plaintext" - // CommandKeyMode will run an external command to get the key (from stdout). - CommandKeyMode = "command" ) var ( @@ -73,29 +66,10 @@ func NewLockbox(options LockboxOptions) (Lockbox, error) { } func newLockbox(key, keyMode, file string) (Lockbox, error) { - useKeyMode := keyMode - if useKeyMode == "" { - useKeyMode = os.Getenv(inputs.KeyModeEnv) - } - if useKeyMode == "" { - useKeyMode = CommandKeyMode - } - useKey := key - if useKey == "" { - useKey = os.Getenv(inputs.KeyEnv) - } - if useKey == "" { - return Lockbox{}, errors.New("no key given") - } - b, err := getKey(useKeyMode, useKey) + b, err := inputs.GetKey(key, keyMode) if err != nil { return Lockbox{}, err } - - if len(b) == 0 { - return Lockbox{}, errors.New("key is empty") - } - var secretKey [keyLength]byte copy(secretKey[:], b) return Lockbox{secret: secretKey, file: file}, nil @@ -111,28 +85,6 @@ func pad(salt, key []byte) ([keyLength]byte, error) { return obj, nil } -func getKey(keyMode, name string) ([]byte, error) { - var data []byte - switch keyMode { - case CommandKeyMode: - parts, err := shlex.Split(name) - if err != nil { - return nil, err - } - cmd := exec.Command(parts[0], parts[1:]...) - b, err := cmd.Output() - if err != nil { - return nil, err - } - data = b - case PlainKeyMode: - data = []byte(name) - default: - return nil, errors.New("unknown keymode") - } - return []byte(strings.TrimSpace(string(data))), nil -} - func init() { random.Seed(time.Now().UnixNano()) } diff --git a/internal/encrypt/core_test.go b/internal/encrypt/core_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/enckse/lockbox/internal/encrypt" + "github.com/enckse/lockbox/internal/inputs" "github.com/enckse/lockbox/internal/store" ) @@ -26,7 +27,7 @@ func setupData(t *testing.T) string { } func TestEncryptDecryptCommand(t *testing.T) { - e, err := encrypt.NewLockbox(encrypt.LockboxOptions{Key: "echo test", KeyMode: encrypt.CommandKeyMode, File: setupData(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) } @@ -49,7 +50,7 @@ func TestEmptyKey(t *testing.T) { if err == nil || err.Error() != "no key given" { t.Errorf("invalid error: %v", err) } - _, err = encrypt.NewLockbox(encrypt.LockboxOptions{KeyMode: encrypt.CommandKeyMode, Key: "echo"}) + _, err = encrypt.NewLockbox(encrypt.LockboxOptions{KeyMode: inputs.CommandKeyMode, Key: "echo"}) if err == nil || err.Error() != "key is empty" { t.Errorf("invalid error: %v", err) } @@ -59,7 +60,7 @@ func TestKeyLength(t *testing.T) { val := "" for i := 0; i < 42; i++ { val = fmt.Sprintf("a%s", val) - _, err := encrypt.NewLockbox(encrypt.LockboxOptions{KeyMode: encrypt.PlainKeyMode, Key: val}) + _, err := encrypt.NewLockbox(encrypt.LockboxOptions{KeyMode: inputs.PlainKeyMode, Key: val}) if err != nil { t.Error("no error expected") } @@ -74,7 +75,7 @@ func TestUnknownMode(t *testing.T) { } func TestEncryptDecryptPlainText(t *testing.T) { - e, err := encrypt.NewLockbox(encrypt.LockboxOptions{Key: "plain", KeyMode: encrypt.PlainKeyMode, File: setupData(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) } diff --git a/internal/inputs/env.go b/internal/inputs/env.go @@ -2,9 +2,13 @@ package inputs import ( + "errors" "fmt" "os" + "os/exec" "strings" + + "github.com/google/shlex" ) const ( @@ -30,6 +34,10 @@ const ( ClipMaxEnv = prefixKey + "CLIPMAX" // ColorBetweenEnv is a comma-delimited list of times to color totp outputs (e.g. 0:5,30:35 which is the default). ColorBetweenEnv = prefixKey + "TOTPBETWEEN" + // PlainKeyMode is plaintext based key resolution. + PlainKeyMode = "plaintext" + // CommandKeyMode will run an external command to get the key (from stdout). + CommandKeyMode = "command" ) // EnvOrDefault will get the environment value OR default if env is not set. @@ -41,6 +49,54 @@ func EnvOrDefault(envKey, defaultValue string) string { return val } +// GetKey will get the encryption key setup for lb +func GetKey(key, keyMode string) ([]byte, error) { + useKeyMode := keyMode + if useKeyMode == "" { + useKeyMode = os.Getenv(KeyModeEnv) + } + if useKeyMode == "" { + useKeyMode = CommandKeyMode + } + useKey := key + if useKey == "" { + useKey = os.Getenv(KeyEnv) + } + if useKey == "" { + return nil, errors.New("no key given") + } + b, err := getKey(useKeyMode, useKey) + if err != nil { + return nil, err + } + if len(b) == 0 { + return nil, errors.New("key is empty") + } + return b, nil +} + +func getKey(keyMode, name string) ([]byte, error) { + var data []byte + switch keyMode { + case CommandKeyMode: + parts, err := shlex.Split(name) + if err != nil { + return nil, err + } + cmd := exec.Command(parts[0], parts[1:]...) + b, err := cmd.Output() + if err != nil { + return nil, err + } + data = b + case PlainKeyMode: + data = []byte(name) + default: + return nil, errors.New("unknown keymode") + } + return []byte(strings.TrimSpace(string(data))), nil +} + func isYesNoEnv(defaultValue bool, env string) (bool, error) { value := strings.ToLower(strings.TrimSpace(os.Getenv(env))) if len(value) == 0 { diff --git a/internal/subcommands/display.go b/internal/subcommands/display.go @@ -21,6 +21,7 @@ type ( Entry string Show bool Glob string + All bool Store store.FileSystem } ) @@ -28,8 +29,8 @@ type ( // DisplayCallback handles getting entries for display. func DisplayCallback(args DisplayOptions) ([]dump.ExportEntity, error) { entries := []string{args.Entry} - if strings.Contains(args.Entry, "*") { - if args.Entry == args.Glob { + 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 diff --git a/internal/subcommands/kdbx.go b/internal/subcommands/kdbx.go @@ -0,0 +1,90 @@ +package subcommands + +import ( + "errors" + "flag" + "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 + } + root := gokeepasslib.NewGroup() + root.Name = "root" + 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))) + multi := len(strings.Split(strings.TrimSpace(val), "\n")) > 1 + if multi { + e.Values = append(e.Values, value("Notes", val)) + } else { + e.Values = append(e.Values, protectedValue("Password", val)) + } + root.Entries = append(root.Entries, e) + } + db := &gokeepasslib.Database{ + Header: gokeepasslib.NewHeader(), + Credentials: gokeepasslib.NewPasswordCredentials(key), + Content: &gokeepasslib.DBContent{ + Meta: gokeepasslib.NewMetaData(), + 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 + } + return nil +}