lockbox

password manager
Log | Files | Refs | README | LICENSE

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

kdbx backend

Diffstat:
Ainternal/backend/query.go | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/backend/transact.go | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 338 insertions(+), 0 deletions(-)

diff --git a/internal/backend/query.go b/internal/backend/query.go @@ -0,0 +1,138 @@ +// Package backend handles querying a store +package backend + +import ( + "crypto/sha512" + "errors" + "fmt" + "sort" + "strings" + + "github.com/tobischo/gokeepasslib/v3" +) + +type ( + // QueryMode indicates HOW an entity will be found + QueryMode int + // ValueMode indicates what to do with the store value of the entity + ValueMode int + // QueryOptions indicates how to find entities + QueryOptions struct { + Mode QueryMode + Values ValueMode + Criteria string + } + // QueryEntity is the result of a query + QueryEntity struct { + Path string + Value string + Index int + backing gokeepasslib.Entry + } +) + +const ( + noneMode QueryMode = iota + // ListMode indicates ALL entities will be listed + ListMode + // FindMode indicates a _contains_ search for an entity + FindMode + // ExactMode means an entity must MATCH the string exactly + ExactMode + // SuffixMode will look for an entity ending in a specific value + SuffixMode +) + +const ( + // BlankValue will not decrypt secrets, empty value + BlankValue ValueMode = iota + // HashedValue will decrypt and then hash the password + HashedValue + // SecretValue will have the raw secret onboard + SecretValue +) + +// Get will request a singular entity +func (t *Transaction) Get(path string, mode ValueMode) (*QueryEntity, error) { + e, err := t.QueryCallback(QueryOptions{Mode: ExactMode, Criteria: path, Values: mode}) + if err != nil { + return nil, err + } + switch len(e) { + case 0: + return nil, nil + case 1: + return &e[0], nil + default: + return nil, errors.New("too many entities matched") + } +} + +// QueryCallback will retrieve a query based on setting +func (t *Transaction) QueryCallback(args QueryOptions) ([]QueryEntity, error) { + if args.Mode == noneMode { + return nil, errors.New("no query mode specified") + } + var keys []string + entities := make(map[string]QueryEntity) + isSort := args.Mode == ListMode || args.Mode == FindMode || args.Mode == SuffixMode + err := t.Act(func(ctx Context) error { + for idx, entry := range ctx.db.Content.Root.Groups[0].Entries { + path := getPathName(entry) + if isSort { + switch args.Mode { + case FindMode: + if !strings.Contains(path, args.Criteria) { + continue + } + case SuffixMode: + if !strings.HasSuffix(path, args.Criteria) { + continue + } + } + + } else { + if args.Mode == ExactMode { + if path != args.Criteria { + continue + } + } + } + keys = append(keys, path) + entities[path] = QueryEntity{backing: entry, Index: idx} + } + return nil + }) + if err != nil { + return nil, err + } + if isSort { + sort.Strings(keys) + } + var results []QueryEntity + decrypt := args.Values != BlankValue + if decrypt { + if err := t.db.UnlockProtectedEntries(); err != nil { + return nil, err + } + } + for _, k := range keys { + entity := QueryEntity{Path: k} + if args.Values != BlankValue { + e := entities[k] + entity.Index = e.Index + val := getValue(e.backing, notesKey) + if strings.TrimSpace(val) == "" { + val = e.backing.GetPassword() + } + switch args.Values { + case SecretValue: + entity.Value = val + case HashedValue: + entity.Value = fmt.Sprintf("%x", sha512.New().Sum([]byte(val))) + } + } + results = append(results, entity) + } + return results, nil +} diff --git a/internal/backend/transact.go b/internal/backend/transact.go @@ -0,0 +1,200 @@ +// Package backend handles kdbx interactions +package backend + +import ( + "errors" + "os" + "path/filepath" + "strings" + + "github.com/enckse/lockbox/internal/inputs" + "github.com/tobischo/gokeepasslib/v3" + "github.com/tobischo/gokeepasslib/v3/wrappers" +) + +const ( + userNameKey = "UserName" + notesKey = "Notes" + titleKey = "Title" + passKey = "Password" +) + +type ( + Action func(t Context) error + Transaction struct { + valid bool + file string + exists bool + db *gokeepasslib.Database + write bool + } + Context struct { + db *gokeepasslib.Database + } +) + +func Load(file string) (*Transaction, error) { + return loadFile(file, true) +} + +func loadFile(file string, must bool) (*Transaction, error) { + if !strings.HasSuffix(file, ".kdbx") { + return nil, errors.New("should use a .kdbx extension") + } + exists := pathExists(file) + if must { + if !exists { + return nil, errors.New("invalid file, does not exists") + } + } + return &Transaction{valid: true, file: file, exists: exists}, nil +} + +func NewTransaction() (*Transaction, error) { + return loadFile(os.Getenv(inputs.StoreEnv), false) +} + +func create(file, key string) error { + root := gokeepasslib.NewGroup() + root.Name = "root" + db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4()) + db.Credentials = gokeepasslib.NewPasswordCredentials(key) + db.Content.Root = + &gokeepasslib.RootData{ + Groups: []gokeepasslib.Group{root}, + } + f, err := os.Create(file) + if err != nil { + return err + } + defer f.Close() + return encode(f, db) +} + +func encode(f *os.File, db *gokeepasslib.Database) error { + return gokeepasslib.NewEncoder(f).Encode(db) +} + +func (t *Transaction) Act(cb Action) error { + if !t.valid { + return errors.New("invalid transaction") + } + key, err := inputs.GetKey("", "") + if err != nil { + return err + } + k := string(key) + if !t.exists { + if err := create(t.file, k); err != nil { + return err + } + } + f, err := os.Open(t.file) + if err != nil { + return err + } + defer f.Close() + db := gokeepasslib.NewDatabase() + db.Credentials = gokeepasslib.NewPasswordCredentials(k) + if err := gokeepasslib.NewDecoder(f).Decode(db); err != nil { + return err + } + if len(db.Content.Root.Groups) != 1 { + return errors.New("kdbx must only have ONE root group") + } + t.db = db + cErr := cb(Context{db: t.db}) + if t.write { + if err := t.db.LockProtectedEntries(); err != nil { + return err + } + if err := f.Close(); err != nil { + return err + } + f, err = os.Create(t.file) + if err != nil { + return err + } + defer f.Close() + return encode(f, t.db) + } + return cErr +} + +func (t *Transaction) change(cb Action) error { + return t.Act(func(c Context) error { + if err := c.db.UnlockProtectedEntries(); err != nil { + return err + } + t.write = true + return cb(c) + }) +} + +func (t *Transaction) Insert(path, val string, multi bool) error { + return t.change(func(c Context) error { + e := gokeepasslib.NewEntry() + e.Values = append(e.Values, value(titleKey, filepath.Dir(path))) + e.Values = append(e.Values, value(userNameKey, filepath.Base(path))) + field := passKey + if multi { + field = notesKey + } + + e.Values = append(e.Values, protectedValue(field, val)) + c.db.Content.Root.Groups[0].Entries = append(c.db.Content.Root.Groups[0].Entries, e) + return nil + }) +} + +func (t *Transaction) Remove(entity *QueryEntity) error { + if entity == nil { + return errors.New("entity is empty/invalid") + } + return t.change(func(c Context) error { + entries := c.db.Content.Root.Groups[0].Entries + if entity.Index >= len(entries) { + return errors.New("index out of bounds") + } + e := entries[entity.Index] + n := getPathName(e) + if n != entity.Path { + return errors.New("validation failed, index/name mismatch") + } + c.db.Content.Root.Groups[0].Entries = append(entries[:entity.Index], entries[entity.Index+1:]...) + return nil + }) +} + +func getValue(e gokeepasslib.Entry, key string) string { + v := e.Get(key) + if v == nil { + return "" + } + return v.Value.Content +} + +func value(key string, value string) gokeepasslib.ValueData { + return gokeepasslib.ValueData{Key: key, Value: gokeepasslib.V{Content: value}} +} + +func getPathName(entry gokeepasslib.Entry) string { + return filepath.Join(entry.GetTitle(), getValue(entry, userNameKey)) +} + +func protectedValue(key string, value string) gokeepasslib.ValueData { + return gokeepasslib.ValueData{ + Key: key, + Value: gokeepasslib.V{Content: value, Protected: wrappers.NewBoolWrapper(true)}, + } +} + +// 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 +}