commit c06f431d13f94c87941fb762a0675e903a1a2c52
parent c8172d500194a7ae126ab17e3ff1ed0bb38f1014
Author: Sean Enck <sean@ttypty.com>
Date: Sat, 1 Oct 2022 11:19:12 -0400
kdbx backend
Diffstat:
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
+}