Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions go/internal/envelope/envelope.go
Original file line number Diff line number Diff line change
@@ -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
}
254 changes: 254 additions & 0 deletions go/internal/envelope/envelope_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading