From e9e174db7a83848927bd1248f2dc850c0f3da78e Mon Sep 17 00:00:00 2001 From: mintaka Date: Fri, 11 Sep 2026 16:01:32 -0400 Subject: [PATCH] feat(envelope): AES-256-GCM seam for user-secret values (RIG-3655 T1) T1 of the user-secret store: the crypto primitive the Postgres-backed store encrypts values with. Pure package, no Postgres, no callers yet -- T2 adds the columns that use it. The API makes the two classic AEAD misuses unrepresentable rather than documented. `Encrypt` takes no nonce parameter and draws a fresh 96-bit nonce from crypto/rand per call, so nonce reuse has no expression. The key bytes live in an unexported [32]byte, so no reflection-based logger or marshaler can reach them, and `NewKey` copies its input so the caller can zero its own slice. `Decrypt` collapses every failure -- wrong key, tampered ciphertext, tampered nonce, wrong AAD, wrong nonce length -- to one opaque `ErrDecrypt` that wraps no cipher internals. A caller cannot learn which part it got wrong. `UserSecretAAD` binds the full scope tuple from the A9 amendment: version tag, tenant, scope kind, scope id, name, key version, NUL-separated so the encoding is injective. Binding the value to its row identity is what makes a row that is copied to another tenant, renamed, or re-scoped fail to decrypt instead of silently resolving. Tests are red-green and each defends a property rather than a line. I mutation-checked the suite: removing the NUL separators, fixing the nonce, and dropping the scope field from the AAD each turn it red, so the injectivity, freshness, and scope-binding assertions can all actually fail. stdlib crypto only -- no golang.org/x/crypto. Refs RIG-3655 Co-authored-by: Matt Wilkinson --- go/internal/envelope/envelope.go | 121 ++++++++++++ go/internal/envelope/envelope_test.go | 254 ++++++++++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 go/internal/envelope/envelope.go create mode 100644 go/internal/envelope/envelope_test.go diff --git a/go/internal/envelope/envelope.go b/go/internal/envelope/envelope.go new file mode 100644 index 00000000..bbefe4c0 --- /dev/null +++ b/go/internal/envelope/envelope.go @@ -0,0 +1,121 @@ +// Package envelope is the at-rest crypto seam for user-provided secrets: +// AES-256-GCM under a single master key, with the nonce generated internally +// per encryption so reuse is structurally impossible. The key bytes are held +// unexported so no reflection-based logger or marshaler can reach them. +package envelope + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "errors" + "fmt" + "io" + "strconv" +) + +// keyLen is the AES-256 key size and the only length NewKey accepts. +const keyLen = 32 + +// nonceLen is the GCM standard 96-bit nonce. +const nonceLen = 12 + +// ErrDecrypt is the single opaque decrypt failure. It never wraps cipher +// internals and carries no plaintext or key material. +var ErrDecrypt = errors.New("envelope: decrypt failed") + +// Key is a 256-bit AES-GCM key. The bytes are unexported so no exported field, +// formatter, or marshaler can render them. +type Key struct { + k [keyLen]byte +} + +// NewKey copies raw (which must be exactly 32 bytes) into a Key. The copy lets +// the caller zero its own slice afterward. A wrong length is an operator-config +// error, so the message names the requirement rather than staying opaque. +func NewKey(raw []byte) (Key, error) { + if len(raw) != keyLen { + return Key{}, fmt.Errorf("envelope: key must be exactly %d bytes, got %d", keyLen, len(raw)) + } + var k Key + copy(k.k[:], raw) + return k, nil +} + +// Fingerprint returns the salted SHA-256 digest of the key under salt — the +// non-secret server_key_state tripwire value. +func (k Key) Fingerprint(salt []byte) []byte { + h := sha256.New() + h.Write(salt) + h.Write(k.k[:]) + return h.Sum(nil) +} + +// Encrypt seals plaintext under aad with a fresh random 96-bit nonce and +// returns (nonce, ciphertext). There is no nonce parameter, so reuse cannot +// happen by construction. +func (k Key) Encrypt(plaintext, aad []byte) (nonce, ciphertext []byte, err error) { + gcm, err := k.gcm() + if err != nil { + return nil, nil, err + } + nonce = make([]byte, nonceLen) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, nil, fmt.Errorf("envelope: nonce generation: %w", err) + } + ciphertext = gcm.Seal(nil, nonce, plaintext, aad) + return nonce, ciphertext, nil +} + +// Decrypt opens ciphertext under nonce and aad. Any tamper — ciphertext, +// nonce, aad, wrong key, or a malformed nonce — returns ErrDecrypt with no +// cipher internals attached. +func (k Key) Decrypt(nonce, ciphertext, aad []byte) ([]byte, error) { + gcm, err := k.gcm() + if err != nil { + return nil, ErrDecrypt + } + if len(nonce) != gcm.NonceSize() { + return nil, ErrDecrypt + } + pt, err := gcm.Open(nil, nonce, ciphertext, aad) + if err != nil { + return nil, ErrDecrypt + } + return pt, nil +} + +func (k Key) gcm() (cipher.AEAD, error) { + block, err := aes.NewCipher(k.k[:]) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + +// UserSecretAAD builds the canonical user-secret AAD binding a ciphertext to +// its scope tuple, name, tenant, and key generation: +// +// "compass/user-secret/v1\x00" + tenantID + "\x00" + decimal(scopeKind) + +// "\x00" + scopeID + "\x00" + name + "\x00" + decimal(keyVersion) +// +// Every field is bound unconditionally (a tenant-scoped row passes scopeID="") +// so the field count never varies, and the \x00 separators make the encoding +// injective: no two distinct field tuples concatenate to the same bytes. +func UserSecretAAD(tenantID string, scopeKind int16, scopeID, name string, keyVersion int16) []byte { + const sep = "\x00" + buf := make([]byte, 0, len("compass/user-secret/v1")+len(tenantID)+len(scopeID)+len(name)+16) + buf = append(buf, "compass/user-secret/v1"...) + buf = append(buf, sep...) + buf = append(buf, tenantID...) + buf = append(buf, sep...) + buf = append(buf, strconv.FormatInt(int64(scopeKind), 10)...) + buf = append(buf, sep...) + buf = append(buf, scopeID...) + buf = append(buf, sep...) + buf = append(buf, name...) + buf = append(buf, sep...) + buf = append(buf, strconv.FormatInt(int64(keyVersion), 10)...) + return buf +} diff --git a/go/internal/envelope/envelope_test.go b/go/internal/envelope/envelope_test.go new file mode 100644 index 00000000..a93736f5 --- /dev/null +++ b/go/internal/envelope/envelope_test.go @@ -0,0 +1,254 @@ +package envelope + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" +) + +func mustKey(t *testing.T, raw []byte) Key { + t.Helper() + k, err := NewKey(raw) + if err != nil { + t.Fatalf("NewKey: %v", err) + } + return k +} + +func repeat(b byte) []byte { + out := make([]byte, 32) + for i := range out { + out[i] = b + } + return out +} + +func TestNewKeyRejectsWrongLength(t *testing.T) { + for _, n := range []int{0, 1, 16, 31, 33, 64} { + if _, err := NewKey(make([]byte, n)); err == nil { + t.Fatalf("NewKey(%d bytes): want error, got nil", n) + } else if !strings.Contains(err.Error(), "32") { + t.Fatalf("NewKey(%d bytes): error should name the 32-byte requirement, got %q", n, err.Error()) + } + } + if _, err := NewKey(repeat(0x01)); err != nil { + t.Fatalf("NewKey(32 bytes): unexpected error %v", err) + } +} + +func TestNewKeyCopiesInput(t *testing.T) { + raw := repeat(0x07) + k := mustKey(t, raw) + aad := []byte("aad") + nonce, ct, err := k.Encrypt([]byte("hello"), aad) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + // Caller zeroes its slice after NewKey; the Key must be unaffected. + for i := range raw { + raw[i] = 0 + } + pt, err := k.Decrypt(nonce, ct, aad) + if err != nil { + t.Fatalf("Decrypt after caller zeroed slice: %v", err) + } + if string(pt) != "hello" { + t.Fatalf("round-trip mismatch: %q", pt) + } +} + +func TestRoundTrip(t *testing.T) { + k := mustKey(t, repeat(0x02)) + aad := []byte("row-identity") + msg := []byte("super secret value") + nonce, ct, err := k.Encrypt(msg, aad) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + pt, err := k.Decrypt(nonce, ct, aad) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if !bytes.Equal(pt, msg) { + t.Fatalf("round-trip mismatch: %q", pt) + } +} + +func TestEncryptFreshNonce(t *testing.T) { + k := mustKey(t, repeat(0x03)) + aad := []byte("aad") + msg := []byte("identical") + n1, c1, err := k.Encrypt(msg, aad) + if err != nil { + t.Fatalf("Encrypt 1: %v", err) + } + n2, c2, err := k.Encrypt(msg, aad) + if err != nil { + t.Fatalf("Encrypt 2: %v", err) + } + if len(n1) != 12 { + t.Fatalf("nonce length: want 12, got %d", len(n1)) + } + if bytes.Equal(n1, n2) { + t.Fatal("two encrypts produced the same nonce") + } + if bytes.Equal(c1, c2) { + t.Fatal("two encrypts produced the same ciphertext") + } +} + +func TestDecryptTamperCiphertext(t *testing.T) { + k := mustKey(t, repeat(0x04)) + aad := []byte("aad") + nonce, ct, _ := k.Encrypt([]byte("value"), aad) + ct[0] ^= 0xff + if _, err := k.Decrypt(nonce, ct, aad); !errors.Is(err, ErrDecrypt) { + t.Fatalf("tampered ciphertext: want ErrDecrypt, got %v", err) + } +} + +func TestDecryptTamperNonce(t *testing.T) { + k := mustKey(t, repeat(0x05)) + aad := []byte("aad") + nonce, ct, _ := k.Encrypt([]byte("value"), aad) + nonce[0] ^= 0xff + if _, err := k.Decrypt(nonce, ct, aad); !errors.Is(err, ErrDecrypt) { + t.Fatalf("tampered nonce: want ErrDecrypt, got %v", err) + } +} + +func TestDecryptTamperAAD(t *testing.T) { + k := mustKey(t, repeat(0x06)) + nonce, ct, _ := k.Encrypt([]byte("value"), []byte("aad-A")) + if _, err := k.Decrypt(nonce, ct, []byte("aad-B")); !errors.Is(err, ErrDecrypt) { + t.Fatalf("wrong aad: want ErrDecrypt, got %v", err) + } +} + +func TestDecryptWrongKey(t *testing.T) { + k1 := mustKey(t, repeat(0x08)) + k2 := mustKey(t, repeat(0x09)) + aad := []byte("aad") + nonce, ct, _ := k1.Encrypt([]byte("value"), aad) + if _, err := k2.Decrypt(nonce, ct, aad); !errors.Is(err, ErrDecrypt) { + t.Fatalf("wrong key: want ErrDecrypt, got %v", err) + } +} + +func TestDecryptWrongLengthNonce(t *testing.T) { + k := mustKey(t, repeat(0x0a)) + aad := []byte("aad") + _, ct, _ := k.Encrypt([]byte("value"), aad) + if _, err := k.Decrypt(make([]byte, 8), ct, aad); !errors.Is(err, ErrDecrypt) { + t.Fatalf("wrong-length nonce: want ErrDecrypt, got %v", err) + } +} + +func TestKeyDoesNotLeakBytes(t *testing.T) { + secret := repeat(0xAB) + k := mustKey(t, secret) + needle := fmt.Sprintf("%02x", secret[0]) // "ab" + + // %v / %+v / %#v must not render the key bytes. + for _, s := range []string{ + fmt.Sprintf("%v", k), + fmt.Sprintf("%+v", k), + fmt.Sprintf("%#v", k), + fmt.Sprintf("%s", k), + } { + if bytes.Contains(bytes.ToLower([]byte(s)), []byte(needle+needle)) { + t.Fatalf("formatted Key leaks key bytes: %q", s) + } + } + + j, err := json.Marshal(k) //nolint:staticcheck // marshaling a no-exported-field Key to prove it yields no key bytes IS the test + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + if bytes.Contains(bytes.ToLower(j), []byte(needle)) { + t.Fatalf("json.Marshal(Key) leaks key bytes: %s", j) + } + // The bytes must not appear as a base64/array either: marshaling an all-0xAB + // key should not embed a run of the raw value in any form. + if bytes.Contains(j, secret) { + t.Fatalf("json.Marshal(Key) embeds raw key: %s", j) + } +} + +func TestFingerprintStableAndDistinct(t *testing.T) { + k1 := mustKey(t, repeat(0x11)) + k2 := mustKey(t, repeat(0x22)) + saltA := []byte("salt-A") + saltB := []byte("salt-B") + + // Stable for one (key, salt). + if !bytes.Equal(k1.Fingerprint(saltA), k1.Fingerprint(saltA)) { + t.Fatal("Fingerprint not stable for one (key, salt)") + } + // Differs across salts for one key. + if bytes.Equal(k1.Fingerprint(saltA), k1.Fingerprint(saltB)) { + t.Fatal("Fingerprint identical across different salts") + } + // Differs across keys for one salt. + if bytes.Equal(k1.Fingerprint(saltA), k2.Fingerprint(saltA)) { + t.Fatal("Fingerprint identical across different keys") + } + // SHA-256 width. + if got := len(k1.Fingerprint(saltA)); got != 32 { + t.Fatalf("Fingerprint length: want 32, got %d", got) + } +} + +func TestUserSecretAADInjective(t *testing.T) { + // Adjacent-field ambiguity: without the \x00 separators, moving the "\x00B" + // from the name into the scopeID boundary would collide. + a := UserSecretAAD("tenant", 1, "", "A\x00B", 1) + b := UserSecretAAD("tenant", 1, "B", "A", 1) + if bytes.Equal(a, b) { + t.Fatal("UserSecretAAD not injective across name/scopeID field boundary") + } + + // Numeric run-together: a trailing-digit name plus keyVersion must not + // concatenate into the same bytes as a shorter name and a longer version. + c := UserSecretAAD("t", 0, "", "KEY1", 2) + d := UserSecretAAD("t", 0, "", "KEY", 12) + if bytes.Equal(c, d) { + t.Fatal("UserSecretAAD not injective across name/keyVersion digit boundary") + } + + // scopeKind is bound: same everything else, different scope kind differs. + e := UserSecretAAD("t", 1, "acct", "N", 1) + f := UserSecretAAD("t", 2, "acct", "N", 1) + if bytes.Equal(e, f) { + t.Fatal("UserSecretAAD does not bind scopeKind") + } + + // Exact canonical byte string. + want := []byte("compass/user-secret/v1\x00t\x001\x00acct\x00N\x001") + if !bytes.Equal(e, want) { + t.Fatalf("canonical AAD mismatch:\n got %q\nwant %q", e, want) + } +} + +func TestScopeBinding(t *testing.T) { + k := mustKey(t, repeat(0x33)) + // User scope shadows tenant scope for the same name/tenant. An AAD that + // differs in ONLY the scope field must fail to decrypt. + aadUser := UserSecretAAD("tenant-x", 1, "acct-1", "OPENAI_API_KEY", 1) + aadAgent := UserSecretAAD("tenant-x", 2, "acct-1", "OPENAI_API_KEY", 1) + + nonce, ct, err := k.Encrypt([]byte("sk-live"), aadUser) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + if _, err := k.Decrypt(nonce, ct, aadAgent); !errors.Is(err, ErrDecrypt) { + t.Fatalf("decrypt across scope field: want ErrDecrypt, got %v", err) + } + // Same AAD still round-trips. + if pt, err := k.Decrypt(nonce, ct, aadUser); err != nil || string(pt) != "sk-live" { + t.Fatalf("same-AAD round-trip failed: pt=%q err=%v", pt, err) + } +}