mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Base's own registry was already org-native in every method — OrgDB(orgID), HasOrg(orgID), orgDir(orgID) — and only the type names said tenant. So this is the vocabulary catching up with the code, not a change of behaviour. TenantRegistry -> Bases app.Bases().OrgDB(org) TenantConfig -> BasesConfig MultiTenant -> PerOrg it enables per-org databases; say that MULTI_TENANT -> MULTI_BASE core/tenant.go -> core/bases.go Tenant was the wrong noun twice over: it is the multi-tenancy word, and this product's unit is the thing a customer creates per org — a Base. Identity here is org, user, project, and nothing else. The UI never said it; this was Go-side only. 64 packages green. One test was collateral and is fixed rather than renamed: TestToHTTPHeader asserts a pure snake_case-to-Header-Case transform, so rewriting its EXPECTED value while its input stayed "tenant_id" made it assert something false. The case is dropped — org_id already covers that transform.
374 lines
13 KiB
Go
374 lines
13 KiB
Go
// At-rest encryption for the per-base SQLite substrate.
|
|
//
|
|
// Every byte a base's SQLite DB writes to durable storage is encrypted
|
|
// with a key UNIQUE to that base's Key tuple (org / app / project / user),
|
|
// rooted in Hanzo KMS. There is exactly ONE at-rest encryption boundary:
|
|
// luxfi/age (post-quantum hybrid ML-KEM-768 + X25519) — the same primitive
|
|
// hanzoai/vfs and hanzoai/replicate speak — so the base key composes across
|
|
// the whole-file, block (vfs), and WAL-stream (replicate) paths without ever
|
|
// double-encrypting the same bytes.
|
|
//
|
|
// # Key hierarchy (KMS-rooted envelope encryption)
|
|
//
|
|
// KMS Vault ── OrgRoot(orgID) ─▶ KEK_org (32 random bytes, per org, in KMS)
|
|
// │ HKDF-SHA256(salt=domain, info=objectKey)
|
|
// ▼
|
|
// WK (per-base wrapping key, ephemeral)
|
|
// │ AES-256-GCM(AAD=objectKey)
|
|
// ▼
|
|
// age Hybrid identity (random per DB) ──wrap──▶ sidecar {objectKey}.agekey
|
|
// │
|
|
// Recipient / Identity ── luxfi/age ──▶ encrypts the SQLite bytes
|
|
//
|
|
// The DB's data-encryption key is the age identity — a random per-DB value
|
|
// generated ONCE and stored only in wrapped form (the sidecar). KMS holds only
|
|
// the org KEK. Rotating the org KEK re-wraps the tiny sidecar; the DB
|
|
// ciphertext is never rewritten (see Keyring.Rotate).
|
|
//
|
|
// # Isolation invariant (proven in keyring_test.go)
|
|
//
|
|
// The FIRST-line cross-base boundary is the request's authenticated
|
|
// claims->OrgID binding, enforced in OrgStore.Get/ForCtx: a caller
|
|
// scoped to org B cannot resolve an org A Key (Get returns ErrCrossOrg).
|
|
// Cryptographic separation is the SECOND line, not the first — one shared
|
|
// store process serves many orgs and CAN fetch any org's KEK, so the KEK does
|
|
// not by itself stop a caller who reaches Get with a foreign OrgID. Given
|
|
// correct scoping the crypto then guarantees isolation: KEK_A != KEK_B are
|
|
// distinct KMS secrets, each DB has an independent random age identity, and
|
|
// each sidecar is AAD-bound to its exact objectKey, so no wrapped blob can be
|
|
// replayed across bases (proven in keyring_test.go).
|
|
|
|
package store
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/hkdf"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"sync"
|
|
|
|
"github.com/hanzoai/base/tools/filesystem"
|
|
"github.com/luxfi/age"
|
|
)
|
|
|
|
const (
|
|
// dekKeyLen is the AES-256 wrapping-key / KEK length in bytes.
|
|
dekKeyLen = 32
|
|
// wrapHKDFSalt domain-separates the store wrapping-key derivation from
|
|
// every other HKDF use in the platform. Never reuse across domains.
|
|
wrapHKDFSalt = "hanzo-base.store.base-wrap.v1"
|
|
// wrapNonceLen is the AES-256-GCM nonce length for the wrap.
|
|
wrapNonceLen = 12
|
|
// sidecarSuffix is appended to a Key.ObjectKey() to locate its wrapped
|
|
// key material in the object store.
|
|
sidecarSuffix = ".agekey"
|
|
)
|
|
|
|
// ErrNoKeyMaterial is returned when a DB object exists but its wrapped-key
|
|
// sidecar cannot be produced — never silently fall back to plaintext.
|
|
var ErrNoKeyMaterial = errors.New("store: no wrapped key material for base")
|
|
|
|
// OrgKey is the per-base at-rest key: a post-quantum hybrid age identity.
|
|
// Recipient encrypts, Identity decrypts. Unique per Key tuple.
|
|
type OrgKey struct {
|
|
Recipient age.Recipient
|
|
Identity age.Identity
|
|
}
|
|
|
|
// KeyProvider yields the per-base at-rest key for a Key. Implementations
|
|
// MUST return a key unique to k, rooted in KMS, and MUST NOT log key material.
|
|
// Resolve is safe for concurrent use.
|
|
type KeyProvider interface {
|
|
Resolve(ctx context.Context, k Key) (*OrgKey, error)
|
|
}
|
|
|
|
// RootSource yields the org-scoped root key (KEK) — the single KMS-rooted
|
|
// master per org. Implementations MUST return exactly one stable 32-byte KEK
|
|
// per orgID and MUST NOT return a shared/global key. The KEK is secret; the
|
|
// source is the only component that ever touches KMS.
|
|
type RootSource interface {
|
|
OrgRoot(ctx context.Context, orgID string) ([]byte, error)
|
|
}
|
|
|
|
// Keyring is the canonical KeyProvider: KMS-rooted envelope encryption with
|
|
// per-DB random age identities. Safe for concurrent use.
|
|
type Keyring struct {
|
|
root RootSource
|
|
sidecar *filesystem.System
|
|
|
|
mu sync.Mutex
|
|
cache map[Key]*OrgKey
|
|
}
|
|
|
|
// NewKeyring builds a Keyring over a RootSource (KMS) and an object store for
|
|
// the wrapped-key sidecars (the SAME durable store as the DBs in production).
|
|
func NewKeyring(root RootSource, sidecar *filesystem.System) (*Keyring, error) {
|
|
if root == nil {
|
|
return nil, errors.New("store: keyring RootSource is required")
|
|
}
|
|
if sidecar == nil {
|
|
return nil, errors.New("store: keyring sidecar store is required")
|
|
}
|
|
return &Keyring{root: root, sidecar: sidecar, cache: make(map[Key]*OrgKey)}, nil
|
|
}
|
|
|
|
// Resolve returns the per-base key for k, loading-and-unwrapping the existing
|
|
// sidecar or generating-and-wrapping a fresh identity on first use. In normal
|
|
// operation the sidecar is written at first hydrate (before any DB object
|
|
// exists), so a DB object without a sidecar can only be hostile injection —
|
|
// which surfaces downstream as an age-decrypt failure (ErrCorruptDB).
|
|
func (kr *Keyring) Resolve(ctx context.Context, k Key) (*OrgKey, error) {
|
|
if err := k.Valid(); err != nil {
|
|
return nil, fmt.Errorf("store: keyring invalid key %s: %w", k, err)
|
|
}
|
|
kr.mu.Lock()
|
|
defer kr.mu.Unlock()
|
|
|
|
if tk, ok := kr.cache[k]; ok {
|
|
return tk, nil
|
|
}
|
|
|
|
sk := k.ObjectKey() + sidecarSuffix
|
|
blob, ok, err := kr.sidecarGet(sk)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: keyring load sidecar %s: %w", sk, err)
|
|
}
|
|
|
|
var tk *OrgKey
|
|
if ok {
|
|
tk, err = kr.unwrap(ctx, k, blob)
|
|
} else {
|
|
tk, err = kr.generate(ctx, k, sk)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
kr.cache[k] = tk
|
|
return tk, nil
|
|
}
|
|
|
|
// Rotate re-wraps k's key material from prevRoot's KEK to newRoot's KEK,
|
|
// leaving the DB ciphertext AND the underlying age identity unchanged. This is
|
|
// how an org KEK rotation propagates: only the tiny sidecar is rewritten, so
|
|
// no DB is re-encrypted. After Rotate, the old KEK can no longer unwrap the
|
|
// sidecar. Idempotent per (k, newRoot).
|
|
func (kr *Keyring) Rotate(ctx context.Context, k Key, prevRoot, newRoot RootSource) error {
|
|
if err := k.Valid(); err != nil {
|
|
return fmt.Errorf("store: keyring rotate invalid key %s: %w", k, err)
|
|
}
|
|
sk := k.ObjectKey() + sidecarSuffix
|
|
blob, ok, err := kr.sidecarGet(sk)
|
|
if err != nil {
|
|
return fmt.Errorf("store: keyring rotate load %s: %w", sk, err)
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("%w: %s", ErrNoKeyMaterial, k)
|
|
}
|
|
|
|
aad := []byte(k.ObjectKey())
|
|
|
|
prevWK, err := deriveWrapKey(ctx, prevRoot, k)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer wipe(prevWK)
|
|
plain, err := aeadOpen(prevWK, blob, aad)
|
|
if err != nil {
|
|
return fmt.Errorf("store: keyring rotate unwrap %s (wrong prev KEK or tampered): %w", k, err)
|
|
}
|
|
defer wipe(plain)
|
|
|
|
newWK, err := deriveWrapKey(ctx, newRoot, k)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer wipe(newWK)
|
|
rewrapped, err := aeadSeal(newWK, plain, aad)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := kr.sidecar.Upload(rewrapped, sk); err != nil {
|
|
return fmt.Errorf("store: keyring rotate persist %s: %w", sk, err)
|
|
}
|
|
|
|
kr.mu.Lock()
|
|
delete(kr.cache, k)
|
|
kr.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// generate mints a fresh random hybrid identity, wraps it under the org KEK,
|
|
// and persists the sidecar. Caller holds kr.mu.
|
|
func (kr *Keyring) generate(ctx context.Context, k Key, sk string) (*OrgKey, error) {
|
|
id, err := age.GenerateHybridIdentity()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: keyring generate identity: %w", err)
|
|
}
|
|
idStr := []byte(id.String())
|
|
defer wipe(idStr)
|
|
|
|
wk, err := deriveWrapKey(ctx, kr.root, k)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer wipe(wk)
|
|
|
|
blob, err := aeadSeal(wk, idStr, []byte(k.ObjectKey()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := kr.sidecar.Upload(blob, sk); err != nil {
|
|
return nil, fmt.Errorf("store: keyring persist sidecar %s: %w", sk, err)
|
|
}
|
|
return &OrgKey{Recipient: id.Recipient(), Identity: id}, nil
|
|
}
|
|
|
|
// unwrap decrypts an existing sidecar under the org KEK. Caller holds kr.mu.
|
|
func (kr *Keyring) unwrap(ctx context.Context, k Key, blob []byte) (*OrgKey, error) {
|
|
wk, err := deriveWrapKey(ctx, kr.root, k)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer wipe(wk)
|
|
|
|
idStr, err := aeadOpen(wk, blob, []byte(k.ObjectKey()))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: keyring unwrap %s (wrong org KEK or tampered sidecar): %w", k, err)
|
|
}
|
|
defer wipe(idStr)
|
|
|
|
id, err := age.ParseHybridIdentity(string(idStr))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: keyring parse identity %s: %w", k, err)
|
|
}
|
|
return &OrgKey{Recipient: id.Recipient(), Identity: id}, nil
|
|
}
|
|
|
|
// sidecarGet reads a wrapped-key blob; ok=false (nil err) means absent.
|
|
func (kr *Keyring) sidecarGet(sk string) (blob []byte, ok bool, err error) {
|
|
exists, err := kr.sidecar.Exists(sk)
|
|
if err != nil || !exists {
|
|
return nil, false, err
|
|
}
|
|
r, err := kr.sidecar.GetReader(sk)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
defer r.Close()
|
|
b, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return b, true, nil
|
|
}
|
|
|
|
// deriveWrapKey computes the per-base AES-256 wrapping key from the org KEK.
|
|
// WK = HKDF-SHA256(secret=KEK_org, salt=domain, info=objectKey). The objectKey
|
|
// (which begins with the orgID and encodes scope + base id) binds the
|
|
// wrapping key to exactly one DB, so distinct bases derive distinct keys.
|
|
//
|
|
// This is deliberately NOT github.com/hanzoai/cek, which is the one key
|
|
// derivation everywhere else in this repo. cek answers "what key does this
|
|
// namespace's database have", and it answers it by derivation alone: no
|
|
// wrapping, and nothing stored. This key answers a different question. What
|
|
// encrypts the DB here is a RANDOM age identity, generated once and kept only
|
|
// in wrapped form, so no derivation can reproduce it — and that is what makes
|
|
// Rotate cheap: a new org KEK re-wraps one sidecar instead of rewriting every
|
|
// byte of ciphertext. The secret is also a per-org KMS KEK rather than one
|
|
// master, so separation lives in the key material here and in the namespace
|
|
// there. Two different shapes; the HKDF calls are not even parameter-wise
|
|
// interchangeable (this one salts, cek does not). Do not "unify" them.
|
|
func deriveWrapKey(ctx context.Context, root RootSource, k Key) ([]byte, error) {
|
|
kek, err := root.OrgRoot(ctx, k.OrgID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: keyring org root %s: %w", k.OrgID, err)
|
|
}
|
|
defer wipe(kek)
|
|
if len(kek) != dekKeyLen {
|
|
return nil, fmt.Errorf("store: keyring org root must be %d bytes, got %d", dekKeyLen, len(kek))
|
|
}
|
|
wk, err := hkdf.Key(sha256.New, kek, []byte(wrapHKDFSalt), k.ObjectKey(), dekKeyLen)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: keyring hkdf: %w", err)
|
|
}
|
|
return wk, nil
|
|
}
|
|
|
|
// aeadSeal AES-256-GCM-encrypts plaintext with a random nonce, binding aad.
|
|
// Output is nonce || ciphertext || tag.
|
|
func aeadSeal(key, plaintext, aad []byte) ([]byte, error) {
|
|
gcm, err := newGCM(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nonce := make([]byte, wrapNonceLen)
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return nil, fmt.Errorf("store: keyring nonce: %w", err)
|
|
}
|
|
return gcm.Seal(nonce, nonce, plaintext, aad), nil
|
|
}
|
|
|
|
// aeadOpen reverses aeadSeal, authenticating aad.
|
|
func aeadOpen(key, data, aad []byte) ([]byte, error) {
|
|
gcm, err := newGCM(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(data) < wrapNonceLen {
|
|
return nil, errors.New("store: keyring wrapped blob too short")
|
|
}
|
|
return gcm.Open(nil, data[:wrapNonceLen], data[wrapNonceLen:], aad)
|
|
}
|
|
|
|
func newGCM(key []byte) (cipher.AEAD, error) {
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: keyring aes: %w", err)
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: keyring gcm: %w", err)
|
|
}
|
|
return gcm, nil
|
|
}
|
|
|
|
// sealDB age-encrypts SQLite bytes to the base recipient (the sole at-rest
|
|
// boundary for the whole-file durable path).
|
|
func sealDB(tk *OrgKey, plain []byte) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
w, err := age.Encrypt(&buf, tk.Recipient)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: age encrypt: %w", err)
|
|
}
|
|
if _, err := w.Write(plain); err != nil {
|
|
return nil, fmt.Errorf("store: age write: %w", err)
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
return nil, fmt.Errorf("store: age finalize: %w", err)
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// openDBBytes age-decrypts SQLite bytes with the base identity.
|
|
func openDBBytes(tk *OrgKey, ciphertext []byte) ([]byte, error) {
|
|
r, err := age.Decrypt(bytes.NewReader(ciphertext), tk.Identity)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: age decrypt: %w", err)
|
|
}
|
|
return io.ReadAll(r)
|
|
}
|
|
|
|
// wipe zeroes key material. Best-effort; Go may keep copies, but we minimize
|
|
// the residency window for KEK/DEK/WK bytes.
|
|
func wipe(b []byte) {
|
|
for i := range b {
|
|
b[i] = 0
|
|
}
|
|
}
|