From bb4155a228e7e977131450fe32d905db3d593fd4 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 9 Aug 2026 14:34:14 -0400 Subject: [PATCH] feat: implement deployment fingerprints --- internal/deployment/hash.go | 52 ++++++++++++++++++++ internal/deployment/hash_test.go | 84 ++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 internal/deployment/hash.go create mode 100644 internal/deployment/hash_test.go diff --git a/internal/deployment/hash.go b/internal/deployment/hash.go new file mode 100644 index 0000000..e63462e --- /dev/null +++ b/internal/deployment/hash.go @@ -0,0 +1,52 @@ +package deployment + +import "github.com/zeebo/blake3" + +// Digest is a 32-byte BLAKE3 fingerprint exposed across package boundaries. +// The concrete blake3.Hasher type never leaves this package; callers receive +// only Digest values, keeping the BLAKE3 dependency pinned here. +type Digest [32]byte + +// Ordinary returns the unkeyed BLAKE3 digest of an ordinary file's bytes for +// baseline comparison. Apply this only to non-secret content. +func Ordinary(bytes []byte) Digest { + return blake3.Sum256(bytes) +} + +// RawStorage returns the unkeyed BLAKE3 digest of an opaque encrypted secret +// payload as stored on disk. Callers must pass SOPS-encrypted bytes only; +// this package intentionally exposes no unkeyed plaintext-secret digest. +func RawStorage(bytes []byte) Digest { + return blake3.Sum256(bytes) +} + +// SecretSemantic returns the keyed BLAKE3 digest of secret plaintext under a +// per-installation 32-byte key. The keyed mode means low-entropy plaintext +// never compares equal to its unkeyed Ordinary digest. +func SecretSemantic(plaintext []byte, key [32]byte) Digest { + hasher, err := blake3.NewKeyed(key[:]) + if err != nil { + panic("deployment: blake3 NewKeyed rejected a 32-byte key") + } + _, _ = hasher.Write(plaintext) + var out Digest + _, _ = hasher.Digest().Read(out[:]) + return out +} + +// HashKeyIdentifier returns a domain-separated unkeyed BLAKE3 digest that +// names a 32-byte secret key without exposing it. The digest covers the +// fixed literal "cattery/hash-key-id/v1\x00" prefixed to the key, so a key +// identifier cannot collide with any other unkeyed Cattery digest. +func HashKeyIdentifier(key [32]byte) Digest { + hasher := blake3.New() + _, _ = hasher.WriteString(domainSeparator) + _, _ = hasher.Write(key[:]) + var out Digest + _, _ = hasher.Digest().Read(out[:]) + return out +} + +// domainSeparator is the literal prefix mixed into key identifiers so a +// Cattery key identifier cannot collide with any other unkeyed digest. +const domainSeparator = "cattery/hash-key-id/v1\x00" diff --git a/internal/deployment/hash_test.go b/internal/deployment/hash_test.go new file mode 100644 index 0000000..3ccedd3 --- /dev/null +++ b/internal/deployment/hash_test.go @@ -0,0 +1,84 @@ +package deployment + +import "testing" + +func TestFingerprintVectors(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"ordinary of empty matches vector", testOrdinaryEmpty}, + {"ordinary equals raw storage", testOrdinaryRawStorageEqual}, + {"secret semantic differs from ordinary", testSecretSemanticDiffers}, + {"distinct keys distinct identifier", testIdentifierDistinctKeys}, + {"same key same identifier", testIdentifierSameKey}, + {"identifier is domain separated", testIdentifierDomainSeparated}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +// ordinaryEmptyVector is the canonical BLAKE3-256 of the empty input. +var ordinaryEmptyVector = Digest{ + 0xaf, 0x13, 0x49, 0xb9, 0xf5, 0xf9, 0xa1, 0xa6, + 0xa0, 0x40, 0x4d, 0xea, 0x36, 0xdc, 0xc9, 0x49, + 0x9b, 0xcb, 0x25, 0xc9, 0xad, 0xc1, 0x12, 0xb7, + 0xcc, 0x9a, 0x93, 0xca, 0xe4, 0x1f, 0x32, 0x62, +} + +func testOrdinaryEmpty(t *testing.T) { + got := Ordinary(nil) + if got != ordinaryEmptyVector { + t.Fatalf("Ordinary(empty) = %x, want %x", got, ordinaryEmptyVector) + } +} + +func testOrdinaryRawStorageEqual(t *testing.T) { + payload := []byte("hello cattery") + if Ordinary(payload) != RawStorage(payload) { + t.Fatal("Ordinary and RawStorage must agree on identical bytes") + } +} + +func testSecretSemanticDiffers(t *testing.T) { + plaintext := []byte("token") + if SecretSemantic(plaintext, sampleKey(1)) == Ordinary(plaintext) { + t.Fatal("keyed SecretSemantic must differ from unkeyed Ordinary") + } +} + +func testIdentifierDistinctKeys(t *testing.T) { + a := HashKeyIdentifier(sampleKey(1)) + b := HashKeyIdentifier(sampleKey(2)) + if a == b { + t.Fatal("distinct keys must yield distinct identifiers") + } +} + +func testIdentifierSameKey(t *testing.T) { + key := sampleKey(7) + first := HashKeyIdentifier(key) + second := HashKeyIdentifier(key) + if first != second { + t.Fatal("same key must yield identical identifiers") + } +} + +func testIdentifierDomainSeparated(t *testing.T) { + // HashKeyIdentifier mixes the literal domain separator into the digest, so + // it must differ from Ordinary of the bare key bytes alone. + key := sampleKey(3) + bare := Ordinary(key[:]) + if HashKeyIdentifier(key) == bare { + t.Fatal("HashKeyIdentifier must differ from Ordinary of the bare key") + } +} + +func sampleKey(seed byte) [32]byte { + var key [32]byte + for i := range key { + key[i] = seed + byte(i) + } + return key +} -- 2.51.2