Skip to content

Commit 80eac5d

Browse files
zeekayHanzo Dev
andauthored
feat(store): per-tenant KMS-rooted at-rest encryption (Pillar 1) (#35)
* fix(deps): realign go.sum to sumdb for retagged luxfi age/keys/pq/zap CI build-base failed at go mod download with a SECURITY ERROR: go.sum held direct-git hashes for four force-moved luxfi tags, mismatching the immutable proxy+sumdb that the CI build resolves against (Dockerfile drops GOPRIVATE): luxfi/age v1.5.0, luxfi/keys v1.2.0, luxfi/pq v1.0.3, luxfi/zap v0.8.8 Realign the four module-zip hashes to the sum.golang.org-verified proxy hashes (the /go.mod hashes already matched). Verified: go mod download clean in a no-GOPRIVATE proxy+sumdb env; go.sum stable, no graph drift. * feat(store): per-tenant KMS-rooted at-rest encryption for the SQLite substrate Every per-tenant SQLite DB is now encrypted at rest with a key UNIQUE to its Key tuple (org/app/project/user), rooted in Hanzo KMS. The store never writes plaintext SQLite to durable storage — Keys is a required Option; New() refuses to boot without it (fail secure, no plaintext path exists). One at-rest boundary: luxfi/age post-quantum hybrid (ML-KEM-768 + X25519) — the same primitive hanzoai/vfs and hanzoai/replicate speak, so the tenant key composes across the whole-file, block, and WAL-stream paths without ever double-encrypting the same bytes (pillars 2 and 3 snap onto this key). Envelope key hierarchy (store/keyring.go): KMS Vault -> OrgRoot(orgID) -> KEK_org (32 random bytes, per org, in KMS) -> HKDF-SHA256(salt=domain, info=objectKey) -> WK (per-tenant wrap key) -> AES-256-GCM(AAD=objectKey) wraps a random per-DB age identity -> stored as a sidecar {objectKey}.agekey next to the DB The DB data key is the random age identity — stored only wrapped. KMS holds only the org KEK. Rotating the KEK re-wraps the tiny sidecar; the DB ciphertext is never rewritten (Keyring.Rotate). Isolation invariant (proven in keyring_test.go): org A cannot decrypt org B's DB — distinct KMS KEKs, and B's process is KMS-scoped to B. Within an org each DB has an independent random identity and its sidecar is AAD-bound to the exact objectKey, so no wrapped blob is portable across tenants. - store/keyring.go KeyProvider/RootSource/TenantKey + envelope Keyring - store/kmskeyring/ KMS-backed RootSource (get-or-create org KEK) - store/multitenant.go resolve key on hydrate; decrypt on download, encrypt on checkpoint/evict; ErrCorruptDB on undecryptable objects Tests (go test -race ./store/...): 10 crypto proofs plus kmskeyring get-or- create, copy semantics, malformed/wrong-length KEK rejection, and fail-secure- on-transient-error. Full existing store suite passes under mandatory encryption. --------- Co-authored-by: Hanzo Dev <dev@hanzo.ai>
1 parent 56f7d4f commit 80eac5d

6 files changed

Lines changed: 1097 additions & 9 deletions

File tree

store/keyring.go

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
// At-rest encryption for the per-tenant SQLite substrate.
2+
//
3+
// Every byte a tenant's SQLite DB writes to durable storage is encrypted
4+
// with a key UNIQUE to that tenant's Key tuple (org / app / project / user),
5+
// rooted in Hanzo KMS. There is exactly ONE at-rest encryption boundary:
6+
// luxfi/age (post-quantum hybrid ML-KEM-768 + X25519) — the same primitive
7+
// hanzoai/vfs and hanzoai/replicate speak — so the tenant key composes across
8+
// the whole-file, block (vfs), and WAL-stream (replicate) paths without ever
9+
// double-encrypting the same bytes.
10+
//
11+
// # Key hierarchy (KMS-rooted envelope encryption)
12+
//
13+
// KMS Vault ── OrgRoot(orgID) ─▶ KEK_org (32 random bytes, per org, in KMS)
14+
// │ HKDF-SHA256(salt=domain, info=objectKey)
15+
// ▼
16+
// WK (per-tenant wrapping key, ephemeral)
17+
// │ AES-256-GCM(AAD=objectKey)
18+
// ▼
19+
// age Hybrid identity (random per DB) ──wrap──▶ sidecar {objectKey}.agekey
20+
// │
21+
// Recipient / Identity ── luxfi/age ──▶ encrypts the SQLite bytes
22+
//
23+
// The DB's data-encryption key is the age identity — a random per-DB value
24+
// generated ONCE and stored only in wrapped form (the sidecar). KMS holds only
25+
// the org KEK. Rotating the org KEK re-wraps the tiny sidecar; the DB
26+
// ciphertext is never rewritten (see Keyring.Rotate).
27+
//
28+
// # Isolation invariant (proven in keyring_test.go)
29+
//
30+
// org A cannot decrypt org B's DB: KEK_A != KEK_B are distinct KMS secrets and
31+
// B's Base process is KMS-scoped to B, so it can never fetch KEK_A. Within an
32+
// org, each DB has an independent random age identity and its sidecar is bound
33+
// to the exact objectKey via the AES-GCM AAD, so no wrapped blob can be
34+
// replayed against another tenant.
35+
36+
package store
37+
38+
import (
39+
"bytes"
40+
"context"
41+
"crypto/aes"
42+
"crypto/cipher"
43+
"crypto/hkdf"
44+
"crypto/rand"
45+
"crypto/sha256"
46+
"errors"
47+
"fmt"
48+
"io"
49+
"sync"
50+
51+
"github.com/hanzoai/base/tools/filesystem"
52+
"github.com/luxfi/age"
53+
)
54+
55+
const (
56+
// dekKeyLen is the AES-256 wrapping-key / KEK length in bytes.
57+
dekKeyLen = 32
58+
// wrapHKDFSalt domain-separates the store wrapping-key derivation from
59+
// every other HKDF use in the platform. Never reuse across domains.
60+
wrapHKDFSalt = "hanzo-base.store.tenant-wrap.v1"
61+
// wrapNonceLen is the AES-256-GCM nonce length for the wrap.
62+
wrapNonceLen = 12
63+
// sidecarSuffix is appended to a Key.ObjectKey() to locate its wrapped
64+
// key material in the object store.
65+
sidecarSuffix = ".agekey"
66+
)
67+
68+
// ErrNoKeyMaterial is returned when a DB object exists but its wrapped-key
69+
// sidecar cannot be produced — never silently fall back to plaintext.
70+
var ErrNoKeyMaterial = errors.New("store: no wrapped key material for tenant")
71+
72+
// TenantKey is the per-tenant at-rest key: a post-quantum hybrid age identity.
73+
// Recipient encrypts, Identity decrypts. Unique per Key tuple.
74+
type TenantKey struct {
75+
Recipient age.Recipient
76+
Identity age.Identity
77+
}
78+
79+
// KeyProvider yields the per-tenant at-rest key for a Key. Implementations
80+
// MUST return a key unique to k, rooted in KMS, and MUST NOT log key material.
81+
// Resolve is safe for concurrent use.
82+
type KeyProvider interface {
83+
Resolve(ctx context.Context, k Key) (*TenantKey, error)
84+
}
85+
86+
// RootSource yields the org-scoped root key (KEK) — the single KMS-rooted
87+
// master per org. Implementations MUST return exactly one stable 32-byte KEK
88+
// per orgID and MUST NOT return a shared/global key. The KEK is secret; the
89+
// source is the only component that ever touches KMS.
90+
type RootSource interface {
91+
OrgRoot(ctx context.Context, orgID string) ([]byte, error)
92+
}
93+
94+
// Keyring is the canonical KeyProvider: KMS-rooted envelope encryption with
95+
// per-DB random age identities. Safe for concurrent use.
96+
type Keyring struct {
97+
root RootSource
98+
sidecar *filesystem.System
99+
100+
mu sync.Mutex
101+
cache map[Key]*TenantKey
102+
}
103+
104+
// NewKeyring builds a Keyring over a RootSource (KMS) and an object store for
105+
// the wrapped-key sidecars (the SAME durable store as the DBs in production).
106+
func NewKeyring(root RootSource, sidecar *filesystem.System) (*Keyring, error) {
107+
if root == nil {
108+
return nil, errors.New("store: keyring RootSource is required")
109+
}
110+
if sidecar == nil {
111+
return nil, errors.New("store: keyring sidecar store is required")
112+
}
113+
return &Keyring{root: root, sidecar: sidecar, cache: make(map[Key]*TenantKey)}, nil
114+
}
115+
116+
// Resolve returns the per-tenant key for k, loading-and-unwrapping the existing
117+
// sidecar or generating-and-wrapping a fresh identity on first use. In normal
118+
// operation the sidecar is written at first hydrate (before any DB object
119+
// exists), so a DB object without a sidecar can only be hostile injection —
120+
// which surfaces downstream as an age-decrypt failure (ErrCorruptDB).
121+
func (kr *Keyring) Resolve(ctx context.Context, k Key) (*TenantKey, error) {
122+
if err := k.Valid(); err != nil {
123+
return nil, fmt.Errorf("store: keyring invalid key %s: %w", k, err)
124+
}
125+
kr.mu.Lock()
126+
defer kr.mu.Unlock()
127+
128+
if tk, ok := kr.cache[k]; ok {
129+
return tk, nil
130+
}
131+
132+
sk := k.ObjectKey() + sidecarSuffix
133+
blob, ok, err := kr.sidecarGet(sk)
134+
if err != nil {
135+
return nil, fmt.Errorf("store: keyring load sidecar %s: %w", sk, err)
136+
}
137+
138+
var tk *TenantKey
139+
if ok {
140+
tk, err = kr.unwrap(ctx, k, blob)
141+
} else {
142+
tk, err = kr.generate(ctx, k, sk)
143+
}
144+
if err != nil {
145+
return nil, err
146+
}
147+
kr.cache[k] = tk
148+
return tk, nil
149+
}
150+
151+
// Rotate re-wraps k's key material from prevRoot's KEK to newRoot's KEK,
152+
// leaving the DB ciphertext AND the underlying age identity unchanged. This is
153+
// how an org KEK rotation propagates: only the tiny sidecar is rewritten, so
154+
// no DB is re-encrypted. After Rotate, the old KEK can no longer unwrap the
155+
// sidecar. Idempotent per (k, newRoot).
156+
func (kr *Keyring) Rotate(ctx context.Context, k Key, prevRoot, newRoot RootSource) error {
157+
if err := k.Valid(); err != nil {
158+
return fmt.Errorf("store: keyring rotate invalid key %s: %w", k, err)
159+
}
160+
sk := k.ObjectKey() + sidecarSuffix
161+
blob, ok, err := kr.sidecarGet(sk)
162+
if err != nil {
163+
return fmt.Errorf("store: keyring rotate load %s: %w", sk, err)
164+
}
165+
if !ok {
166+
return fmt.Errorf("%w: %s", ErrNoKeyMaterial, k)
167+
}
168+
169+
aad := []byte(k.ObjectKey())
170+
171+
prevWK, err := deriveWrapKey(ctx, prevRoot, k)
172+
if err != nil {
173+
return err
174+
}
175+
defer wipe(prevWK)
176+
plain, err := aeadOpen(prevWK, blob, aad)
177+
if err != nil {
178+
return fmt.Errorf("store: keyring rotate unwrap %s (wrong prev KEK or tampered): %w", k, err)
179+
}
180+
defer wipe(plain)
181+
182+
newWK, err := deriveWrapKey(ctx, newRoot, k)
183+
if err != nil {
184+
return err
185+
}
186+
defer wipe(newWK)
187+
rewrapped, err := aeadSeal(newWK, plain, aad)
188+
if err != nil {
189+
return err
190+
}
191+
if err := kr.sidecar.Upload(rewrapped, sk); err != nil {
192+
return fmt.Errorf("store: keyring rotate persist %s: %w", sk, err)
193+
}
194+
195+
kr.mu.Lock()
196+
delete(kr.cache, k)
197+
kr.mu.Unlock()
198+
return nil
199+
}
200+
201+
// generate mints a fresh random hybrid identity, wraps it under the org KEK,
202+
// and persists the sidecar. Caller holds kr.mu.
203+
func (kr *Keyring) generate(ctx context.Context, k Key, sk string) (*TenantKey, error) {
204+
id, err := age.GenerateHybridIdentity()
205+
if err != nil {
206+
return nil, fmt.Errorf("store: keyring generate identity: %w", err)
207+
}
208+
idStr := []byte(id.String())
209+
defer wipe(idStr)
210+
211+
wk, err := deriveWrapKey(ctx, kr.root, k)
212+
if err != nil {
213+
return nil, err
214+
}
215+
defer wipe(wk)
216+
217+
blob, err := aeadSeal(wk, idStr, []byte(k.ObjectKey()))
218+
if err != nil {
219+
return nil, err
220+
}
221+
if err := kr.sidecar.Upload(blob, sk); err != nil {
222+
return nil, fmt.Errorf("store: keyring persist sidecar %s: %w", sk, err)
223+
}
224+
return &TenantKey{Recipient: id.Recipient(), Identity: id}, nil
225+
}
226+
227+
// unwrap decrypts an existing sidecar under the org KEK. Caller holds kr.mu.
228+
func (kr *Keyring) unwrap(ctx context.Context, k Key, blob []byte) (*TenantKey, error) {
229+
wk, err := deriveWrapKey(ctx, kr.root, k)
230+
if err != nil {
231+
return nil, err
232+
}
233+
defer wipe(wk)
234+
235+
idStr, err := aeadOpen(wk, blob, []byte(k.ObjectKey()))
236+
if err != nil {
237+
return nil, fmt.Errorf("store: keyring unwrap %s (wrong org KEK or tampered sidecar): %w", k, err)
238+
}
239+
defer wipe(idStr)
240+
241+
id, err := age.ParseHybridIdentity(string(idStr))
242+
if err != nil {
243+
return nil, fmt.Errorf("store: keyring parse identity %s: %w", k, err)
244+
}
245+
return &TenantKey{Recipient: id.Recipient(), Identity: id}, nil
246+
}
247+
248+
// sidecarGet reads a wrapped-key blob; ok=false (nil err) means absent.
249+
func (kr *Keyring) sidecarGet(sk string) (blob []byte, ok bool, err error) {
250+
exists, err := kr.sidecar.Exists(sk)
251+
if err != nil || !exists {
252+
return nil, false, err
253+
}
254+
r, err := kr.sidecar.GetReader(sk)
255+
if err != nil {
256+
return nil, false, err
257+
}
258+
defer r.Close()
259+
b, err := io.ReadAll(r)
260+
if err != nil {
261+
return nil, false, err
262+
}
263+
return b, true, nil
264+
}
265+
266+
// deriveWrapKey computes the per-tenant AES-256 wrapping key from the org KEK.
267+
// WK = HKDF-SHA256(secret=KEK_org, salt=domain, info=objectKey). The objectKey
268+
// (which begins with the orgID and encodes scope + tenant id) binds the
269+
// wrapping key to exactly one DB, so distinct tenants derive distinct keys.
270+
func deriveWrapKey(ctx context.Context, root RootSource, k Key) ([]byte, error) {
271+
kek, err := root.OrgRoot(ctx, k.OrgID)
272+
if err != nil {
273+
return nil, fmt.Errorf("store: keyring org root %s: %w", k.OrgID, err)
274+
}
275+
defer wipe(kek)
276+
if len(kek) != dekKeyLen {
277+
return nil, fmt.Errorf("store: keyring org root must be %d bytes, got %d", dekKeyLen, len(kek))
278+
}
279+
wk, err := hkdf.Key(sha256.New, kek, []byte(wrapHKDFSalt), k.ObjectKey(), dekKeyLen)
280+
if err != nil {
281+
return nil, fmt.Errorf("store: keyring hkdf: %w", err)
282+
}
283+
return wk, nil
284+
}
285+
286+
// aeadSeal AES-256-GCM-encrypts plaintext with a random nonce, binding aad.
287+
// Output is nonce || ciphertext || tag.
288+
func aeadSeal(key, plaintext, aad []byte) ([]byte, error) {
289+
gcm, err := newGCM(key)
290+
if err != nil {
291+
return nil, err
292+
}
293+
nonce := make([]byte, wrapNonceLen)
294+
if _, err := rand.Read(nonce); err != nil {
295+
return nil, fmt.Errorf("store: keyring nonce: %w", err)
296+
}
297+
return gcm.Seal(nonce, nonce, plaintext, aad), nil
298+
}
299+
300+
// aeadOpen reverses aeadSeal, authenticating aad.
301+
func aeadOpen(key, data, aad []byte) ([]byte, error) {
302+
gcm, err := newGCM(key)
303+
if err != nil {
304+
return nil, err
305+
}
306+
if len(data) < wrapNonceLen {
307+
return nil, errors.New("store: keyring wrapped blob too short")
308+
}
309+
return gcm.Open(nil, data[:wrapNonceLen], data[wrapNonceLen:], aad)
310+
}
311+
312+
func newGCM(key []byte) (cipher.AEAD, error) {
313+
block, err := aes.NewCipher(key)
314+
if err != nil {
315+
return nil, fmt.Errorf("store: keyring aes: %w", err)
316+
}
317+
gcm, err := cipher.NewGCM(block)
318+
if err != nil {
319+
return nil, fmt.Errorf("store: keyring gcm: %w", err)
320+
}
321+
return gcm, nil
322+
}
323+
324+
// sealDB age-encrypts SQLite bytes to the tenant recipient (the sole at-rest
325+
// boundary for the whole-file durable path).
326+
func sealDB(tk *TenantKey, plain []byte) ([]byte, error) {
327+
var buf bytes.Buffer
328+
w, err := age.Encrypt(&buf, tk.Recipient)
329+
if err != nil {
330+
return nil, fmt.Errorf("store: age encrypt: %w", err)
331+
}
332+
if _, err := w.Write(plain); err != nil {
333+
return nil, fmt.Errorf("store: age write: %w", err)
334+
}
335+
if err := w.Close(); err != nil {
336+
return nil, fmt.Errorf("store: age finalize: %w", err)
337+
}
338+
return buf.Bytes(), nil
339+
}
340+
341+
// openDBBytes age-decrypts SQLite bytes with the tenant identity.
342+
func openDBBytes(tk *TenantKey, ciphertext []byte) ([]byte, error) {
343+
r, err := age.Decrypt(bytes.NewReader(ciphertext), tk.Identity)
344+
if err != nil {
345+
return nil, fmt.Errorf("store: age decrypt: %w", err)
346+
}
347+
return io.ReadAll(r)
348+
}
349+
350+
// wipe zeroes key material. Best-effort; Go may keep copies, but we minimize
351+
// the residency window for KEK/DEK/WK bytes.
352+
func wipe(b []byte) {
353+
for i := range b {
354+
b[i] = 0
355+
}
356+
}

0 commit comments

Comments
 (0)