lockbox

password manager
Log | Files | Refs | README | LICENSE

commit f751acbc9a508fac909f97460029065008db63c3
parent 1f0674f8d6a579cb474f6a8af65ffd03a9f39bf3
Author: Sean Enck <sean@ttypty.com>
Date:   Tue, 13 Jan 2026 16:25:19 -0500

split out hasher logic

Diffstat:
Ainternal/kdbx/hasher.go | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/kdbx/hasher_test.go | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/kdbx/query.go | 76+++++++++-------------------------------------------------------------------
3 files changed, 247 insertions(+), 67 deletions(-)

diff --git a/internal/kdbx/hasher.go b/internal/kdbx/hasher.go @@ -0,0 +1,118 @@ +package kdbx + +import ( + "crypto/sha512" + "fmt" + "slices" + "strings" + + "github.com/enckse/lockbox/internal/config" + "github.com/enckse/lockbox/internal/output" +) + +type ( + checksummable struct { + value string + typeof string + index int + } + // Hasher is the manager of data output transform via hashing + Hasher struct { + isRaw bool + isHashed bool + isChecksum bool + checksums []checksummable + checksumTo int + requiredLength int + } +) + +// Reset will clear any current hashing data +func (h *Hasher) Reset() { + h.checksums = nil +} + +// NewHasher will create a new hasher to manage data outputs +func NewHasher(mode ValueMode) (*Hasher, error) { + jsonMode := output.JSONModes.Blank + if mode == JSONValue { + m, err := output.ParseJSONMode(config.EnvJSONMode.Get()) + if err != nil { + return nil, err + } + jsonMode = m + } + + obj := &Hasher{} + obj.checksumTo = 1 + obj.isRaw = mode == SecretValue + switch jsonMode { + case output.JSONModes.Raw: + obj.isRaw = true + case output.JSONModes.Hash: + obj.isHashed = true + obj.isChecksum = mode == JSONValue + length, err := config.EnvJSONHashLength.Get() + if err != nil { + return nil, err + } + obj.checksumTo = max(int(length), obj.checksumTo) + } + obj.requiredLength = (len(AllowedFields) + 2) * obj.checksumTo + return obj, nil +} + +// Transform will transform a value to the preferred output type +func (h *Hasher) Transform(value string) string { + if h.isChecksum { + return value + } + return h.compute(value) +} + +func (h *Hasher) compute(value string) string { + if h.isRaw { + return value + } + if h.isHashed { + return fmt.Sprintf("%x", sha512.Sum512([]byte(value))) + } + return "" +} + +// Add will add a value for checksum computation if needed +func (h *Hasher) Add(field, value string) bool { + if h.isChecksum { + if len(field) > 0 && len(value) > 0 { + if r := h.compute(value); len(r) > 0 { + typeof := field[0] + h.checksums = append(h.checksums, checksummable{r[0:h.checksumTo], string(typeof), int(typeof)}) + } + } + return true + } + return false +} + +// Calculate will generate the output checksum +func (h *Hasher) Calculate(key string) (string, bool) { + if !h.isChecksum { + return "", false + } + var check string + if len(h.checksums) > 0 { + h.Add("d", key) + slices.SortFunc(h.checksums, func(x, y checksummable) int { + return x.index - y.index + }) + var vals []string + for _, item := range h.checksums { + vals = append(vals, fmt.Sprintf("%s%s", item.value, string(item.typeof))) + } + for len(vals) < h.requiredLength { + vals = append([]string{"0" + strings.Repeat("0", h.checksumTo)}, vals...) + } + check = fmt.Sprintf("[%s]", strings.Join(vals, " ")) + } + return check, true +} diff --git a/internal/kdbx/hasher_test.go b/internal/kdbx/hasher_test.go @@ -0,0 +1,120 @@ +package kdbx_test + +import ( + "testing" + + "github.com/enckse/lockbox/internal/config/store" + "github.com/enckse/lockbox/internal/kdbx" +) + +func TestTransform(t *testing.T) { + hasher, _ := kdbx.NewHasher(kdbx.BlankValue) + if hasher.Transform("xyz") != "" { + t.Error("should be empty") + } + hasher, _ = kdbx.NewHasher(kdbx.SecretValue) + if hasher.Transform("xyz") != "xyz" { + t.Error("should be empty") + } + hasher, _ = kdbx.NewHasher(kdbx.JSONValue) + if hasher.Transform("xyz") != "xyz" { + t.Error("should be empty") + } + store.SetString("LOCKBOX_JSON_MODE", "plaintext") + hasher, _ = kdbx.NewHasher(kdbx.JSONValue) + if hasher.Transform("xyz") != "xyz" { + t.Error("should be empty") + } + store.SetString("LOCKBOX_JSON_MODE", "hash") + hasher, _ = kdbx.NewHasher(kdbx.JSONValue) + if hasher.Transform("xyz") != "xyz" { + t.Error("should be empty") + } +} + +func TestAdd(t *testing.T) { + hasher, _ := kdbx.NewHasher(kdbx.BlankValue) + if hasher.Add("x", "y") { + t.Error("add should be false") + } + hasher, _ = kdbx.NewHasher(kdbx.SecretValue) + if hasher.Add("x", "y") { + t.Error("add should be false") + } + store.SetString("LOCKBOX_JSON_MODE", "plaintext") + hasher, _ = kdbx.NewHasher(kdbx.JSONValue) + if hasher.Add("x", "y") { + t.Error("add should be false") + } + store.SetString("LOCKBOX_JSON_MODE", "hash") + hasher, _ = kdbx.NewHasher(kdbx.JSONValue) + if !hasher.Add("x", "y") { + t.Error("add should be true") + } + if !hasher.Add("", "") { + t.Error("add should be true") + } + if !hasher.Add("x", "") { + t.Error("add should be true") + } + if !hasher.Add("", "y") { + t.Error("add should be true") + } +} + +func TestCalculate(t *testing.T) { + hasher, _ := kdbx.NewHasher(kdbx.BlankValue) + hasher.Add("x", "y") + if v, ok := hasher.Calculate("x"); ok || v != "" { + t.Error("calculate is a noop") + } + hasher, _ = kdbx.NewHasher(kdbx.SecretValue) + hasher.Add("x", "y") + if v, ok := hasher.Calculate("x"); ok || v != "" { + t.Error("calculate is a noop") + } + store.SetString("LOCKBOX_JSON_MODE", "plaintext") + hasher, _ = kdbx.NewHasher(kdbx.JSONValue) + hasher.Add("x", "y") + if v, ok := hasher.Calculate("x"); ok || v != "" { + t.Error("calculate is a noop") + } + store.SetString("LOCKBOX_JSON_MODE", "hash") + hasher, _ = kdbx.NewHasher(kdbx.JSONValue) + if v, ok := hasher.Calculate(""); !ok || v != "" { + t.Error("result is ok, but empty") + } + hasher.Add("", "") + if v, ok := hasher.Calculate(""); !ok || v != "" { + t.Error("result is ok, but empty (nothing really added)") + } + if v, ok := hasher.Calculate("d"); !ok || v != "" { + t.Error("result is ok, but empty (nothing really added, even with key)") + } + hasher.Add("x", "y") + if v, ok := hasher.Calculate(""); !ok || v != "[00 00 00 00 00 1x]" { + t.Errorf("results invalid for calculate: %s", v) + } + hasher.Add("z", "1") + if v, ok := hasher.Calculate("d"); !ok || v != "[00 00 00 4d 1x 4z]" { + t.Errorf("results invalid for calculate: %s", v) + } +} + +func TestReset(t *testing.T) { + store.SetString("LOCKBOX_JSON_MODE", "hash") + hasher, _ := kdbx.NewHasher(kdbx.JSONValue) + hasher.Add("x", "y") + if v, ok := hasher.Calculate(""); !ok || v != "[00 00 00 00 00 1x]" { + t.Errorf("results invalid for calculate: %s", v) + } + hasher.Add("1", "y") + if v, ok := hasher.Calculate(""); !ok || v != "[00 00 00 00 11 1x]" { + t.Errorf("results invalid for calculate: %s", v) + } + hasher.Reset() + hasher.Add("1", "z") + if v, ok := hasher.Calculate(""); !ok || v != "[00 00 00 00 00 51]" { + t.Errorf("results invalid for calculate: %s", v) + } +} diff --git a/internal/kdbx/query.go b/internal/kdbx/query.go @@ -2,15 +2,11 @@ package kdbx import ( - "crypto/sha512" "errors" - "fmt" "path/filepath" "slices" "strings" - "github.com/enckse/lockbox/internal/config" - "github.com/enckse/lockbox/internal/output" "github.com/tobischo/gokeepasslib/v3" ) @@ -165,46 +161,16 @@ func (t *Transaction) QueryCallback(args QueryOptions) (QuerySeq2, error) { if err != nil { return nil, err } - jsonMode := output.JSONModes.Blank - if args.Values == JSONValue { - m, err := output.ParseJSONMode(config.EnvJSONMode.Get()) - if err != nil { - return nil, err - } - jsonMode = m - } - jsonHasher := func(string) string { - return "" - } - isChecksum := false - to := 1 - switch jsonMode { - case output.JSONModes.Raw: - jsonHasher = func(val string) string { - return val - } - case output.JSONModes.Hash: - length, err := config.EnvJSONHashLength.Get() - if err != nil { - return nil, err - } - to = max(int(length), to) - isChecksum = args.Values == JSONValue - jsonHasher = func(val string) string { - return fmt.Sprintf("%x", sha512.Sum512([]byte(val))) - } - } - requiredChecksum := (len(AllowedFields) + 2) * to - type checksummable struct { - value string - typeof byte + hasher, err := NewHasher(args.Values) + if err != nil { + return nil, err } return func(yield func(Entity, error) bool) { for _, item := range entities { + hasher.Reset() entity := Entity{Path: item.path} var err error values := make(EntityValues) - var checksums []checksummable for _, v := range item.backing.Values { val := "" raw := "" @@ -213,44 +179,20 @@ func (t *Transaction) QueryCallback(args QueryOptions) (QuerySeq2, error) { if args.Values == JSONValue { values["modtime"] = getValue(item.backing, modTimeKey) } - val = v.Value.Content - raw = val - if !isChecksum { - switch args.Values { - case JSONValue: - val = jsonHasher(val) - } - } + raw = v.Value.Content + val = hasher.Transform(raw) } if key == modTimeKey || key == titleKey { continue } field := strings.ToLower(key) - if isChecksum { - if r := jsonHasher(raw); len(r) > 0 { - checksums = append(checksums, checksummable{r[0:to], field[0]}) - } + if hasher.Add(field, raw) { continue } values[field] = val } - if isChecksum { - var check string - if len(checksums) > 0 { - checksums = append(checksums, checksummable{jsonHasher(entity.Path)[0:to], byte('d')}) - slices.SortFunc(checksums, func(x, y checksummable) int { - return int(x.typeof) - int(y.typeof) - }) - var vals []string - for _, item := range checksums { - vals = append(vals, fmt.Sprintf("%s%s", item.value, string(item.typeof))) - } - for len(vals) < requiredChecksum { - vals = append([]string{"0" + strings.Repeat("0", to)}, vals...) - } - check = fmt.Sprintf("[%s]", strings.Join(vals, " ")) - } - values[checksumKey] = check + if v, ok := hasher.Calculate(entity.Path); ok { + values[checksumKey] = v } entity.Values = values if !yield(entity, err) {