commit f751acbc9a508fac909f97460029065008db63c3
parent 1f0674f8d6a579cb474f6a8af65ffd03a9f39bf3
Author: Sean Enck <sean@ttypty.com>
Date: Tue, 13 Jan 2026 16:25:19 -0500
split out hasher logic
Diffstat:
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) {