Files
e0d1f5f5dd 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 <[email protected]>
2026-07-04 13:57:44 -07:00

143 lines
3.6 KiB
Go

package kmskeyring_test
import (
"context"
"errors"
"sync"
"testing"
"github.com/hanzoai/base/store/kmskeyring"
)
// mockKMS is an in-memory SecretStore: a per-(org,name) string map that
// returns ErrNotFound on miss, exactly like the real KMS contract.
type mockKMS struct {
mu sync.Mutex
data map[string]string
puts int
}
func newMockKMS() *mockKMS { return &mockKMS{data: make(map[string]string)} }
func (m *mockKMS) key(org, name string) string { return org + "\x00" + name }
func (m *mockKMS) Get(_ context.Context, org, name string) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
v, ok := m.data[m.key(org, name)]
if !ok {
return "", kmskeyring.ErrNotFound
}
return v, nil
}
func (m *mockKMS) Put(_ context.Context, org, name, value string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.data[m.key(org, name)] = value
m.puts++
return nil
}
func TestOrgRoot_CreateThenReuse(t *testing.T) {
kms := newMockKMS()
src, err := kmskeyring.New(kms)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
k1, err := src.OrgRoot(ctx, "acme")
if err != nil {
t.Fatal(err)
}
if len(k1) != 32 {
t.Fatalf("KEK len = %d, want 32", len(k1))
}
k2, err := src.OrgRoot(ctx, "acme")
if err != nil {
t.Fatal(err)
}
if string(k1) != string(k2) {
t.Fatal("second OrgRoot returned a different KEK — not stable")
}
if kms.puts != 1 {
t.Fatalf("expected exactly 1 Put (create-once), got %d", kms.puts)
}
}
func TestOrgRoot_ReturnsCopy(t *testing.T) {
kms := newMockKMS()
src, _ := kmskeyring.New(kms)
ctx := context.Background()
k1, _ := src.OrgRoot(ctx, "acme")
for i := range k1 {
k1[i] = 0 // caller zeroes its copy
}
k2, _ := src.OrgRoot(ctx, "acme")
allZero := true
for _, b := range k2 {
if b != 0 {
allZero = false
break
}
}
if allZero {
t.Fatal("OrgRoot handed back the caller's zeroed slice — not a copy")
}
}
func TestOrgRoot_DistinctOrgsDistinctKEKs(t *testing.T) {
kms := newMockKMS()
src, _ := kmskeyring.New(kms)
ctx := context.Background()
a, _ := src.OrgRoot(ctx, "orga")
b, _ := src.OrgRoot(ctx, "orgb")
if string(a) == string(b) {
t.Fatal("distinct orgs share a KEK")
}
}
func TestOrgRoot_EmptyOrgRejected(t *testing.T) {
src, _ := kmskeyring.New(newMockKMS())
if _, err := src.OrgRoot(context.Background(), ""); err == nil {
t.Fatal("empty orgID must be rejected")
}
}
func TestOrgRoot_RejectsMalformedKEK(t *testing.T) {
kms := newMockKMS()
// Plant a non-hex value under the KEK name.
_ = kms.Put(context.Background(), "acme", kmskeyring.KEKSecretName, "not-hex-zzzz")
src, _ := kmskeyring.New(kms)
if _, err := src.OrgRoot(context.Background(), "acme"); err == nil {
t.Fatal("malformed (non-hex) KEK must be rejected")
}
}
func TestOrgRoot_RejectsWrongLengthKEK(t *testing.T) {
kms := newMockKMS()
_ = kms.Put(context.Background(), "acme", kmskeyring.KEKSecretName, "abcd") // 2 bytes
src, _ := kmskeyring.New(kms)
if _, err := src.OrgRoot(context.Background(), "acme"); err == nil {
t.Fatal("wrong-length KEK must be rejected")
}
}
// errKMS returns a non-notfound error to prove OrgRoot does NOT create on
// transient backend errors (fail secure — never mint a second KEK).
type errKMS struct{}
func (errKMS) Get(context.Context, string, string) (string, error) {
return "", errors.New("kms transport down")
}
func (errKMS) Put(context.Context, string, string, string) error { return nil }
func TestOrgRoot_DoesNotCreateOnTransientError(t *testing.T) {
src, _ := kmskeyring.New(errKMS{})
if _, err := src.OrgRoot(context.Background(), "acme"); err == nil {
t.Fatal("transient KMS error must not be swallowed into a KEK create")
}
}