mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
* 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]>
143 lines
3.6 KiB
Go
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")
|
|
}
|
|
}
|