From e054d5255101563c78e31f6403a799a71d63b090 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 17:18:07 +0000 Subject: [PATCH 1/8] feat(registry,migrations): FEE wrap material on blob_locations (FIL-480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend ingot.blob_locations with the FEE encryption columns a (range) GET needs to decrypt an encrypted blob without fetching the COSE envelope header: region_wrapped_cek, region_key_version, tenant_recipient_kid, base_nonce, chunk_size, and protected_header. All nullable — an unencrypted blob (the appliance topology today) leaves them NULL. Rather than a new object/segment table, these live on blob_locations: it is already keyed by (space, digest), and a fresh CEK per encryption event makes each ciphertext digest unique to one encryption, so the wrap material is a 1:1 fact about the row — like the existing provider/url/size. Object-level values (plaintext size/ETag/identity) stay in the content-addressed ObjectManifest; overwrite already swaps the MST leaf to a new manifest CID atomically, so no plaintext_* columns and no generation counter are needed. Raw CEK bytes are never stored — only the region-KEK-wrapped CEK plus opaque key-version/recipient identifiers, so no schema change is needed if the region-key or Hilt wrap-key cardinality decisions (FIL-572/FIL-574) later go multi-key. Per-blob crypto-shred is nulling these columns or deleting the row; a rotation re-wrap is a PutLocation with a new wrapped CEK + key version. - migration 00004: additive ALTER TABLE ADD COLUMN (+ Down) - registry.BlobLocation gains the six fields; Put/GetLocation carry them (nullString/nullInt64 for optional text/bigint, bytea via nil) - inmem MemStore deep-copies the new byte-slice fields - tests: inmem FEE round-trip + mutation-safety; live-PG bytea/NULL round-trip and re-wrap-in-place; migration column existence + nullability Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XSxwh7XfPtFQtyeoDR65hC --- inmem/stores.go | 18 ++++-- inmem/stores_test.go | 71 ++++++++++++++++++++++++ migrations/sql/00004_blob_encryption.sql | 53 ++++++++++++++++++ migrations/up_live_test.go | 18 ++++++ registry/postgres_live_test.go | 52 +++++++++++++++++ registry/stores.go | 50 +++++++++++++++-- registry/stores_postgres.go | 52 +++++++++++++++-- 7 files changed, 298 insertions(+), 16 deletions(-) create mode 100644 migrations/sql/00004_blob_encryption.sql diff --git a/inmem/stores.go b/inmem/stores.go index 66114c7..5ee4cde 100644 --- a/inmem/stores.go +++ b/inmem/stores.go @@ -121,9 +121,7 @@ func (m *MemStore) DeleteIntent(_ context.Context, digest []byte) error { func (m *MemStore) PutLocation(_ context.Context, loc registry.BlobLocation) error { m.mu.Lock() defer m.mu.Unlock() - cp := loc - cp.Digest = cloneBytes(loc.Digest) - m.locations[locKey{loc.Space, string(loc.Digest)}] = cp + m.locations[locKey{loc.Space, string(loc.Digest)}] = cloneLocation(loc) return nil } @@ -134,8 +132,7 @@ func (m *MemStore) GetLocation(_ context.Context, space string, digest []byte) ( if !ok { return nil, registry.ErrNotFound } - cp := loc - cp.Digest = cloneBytes(loc.Digest) + cp := cloneLocation(loc) return &cp, nil } @@ -259,6 +256,17 @@ func cloneSession(s registry.MultipartSession) registry.MultipartSession { return s } +// cloneLocation deep-copies a BlobLocation's byte-slice fields (the digest and +// the FEE wrap material) so the stored copy and any returned copy never alias +// the caller's slices. Nil slices stay nil, preserving "unencrypted blob". +func cloneLocation(loc registry.BlobLocation) registry.BlobLocation { + loc.Digest = cloneBytes(loc.Digest) + loc.RegionWrappedCEK = cloneBytes(loc.RegionWrappedCEK) + loc.BaseNonce = cloneBytes(loc.BaseNonce) + loc.ProtectedHeader = cloneBytes(loc.ProtectedHeader) + return loc +} + func clonePart(p registry.MultipartPart) registry.MultipartPart { p.ETagMD5 = cloneBytes(p.ETagMD5) if p.BlobDigests != nil { diff --git a/inmem/stores_test.go b/inmem/stores_test.go index 3f448c0..30409e6 100644 --- a/inmem/stores_test.go +++ b/inmem/stores_test.go @@ -239,6 +239,11 @@ func TestLocations_RoundTrip(t *testing.T) { if loc.URL != "http://piri/blob" || loc.Size != 100 || loc.Provider != "did:piri:1" { t.Fatalf("GetLocation = %+v", loc) } + // An unencrypted blob carries no FEE wrap material. + if loc.RegionWrappedCEK != nil || loc.BaseNonce != nil || loc.ProtectedHeader != nil || + loc.RegionKeyVersion != "" || loc.TenantRecipientKID != "" || loc.ChunkSize != 0 { + t.Fatalf("unencrypted location carries wrap material: %+v", loc) + } if _, err := m.GetLocation(ctx, "did:other", digest); err != registry.ErrNotFound { t.Fatalf("GetLocation wrong space err = %v, want ErrNotFound", err) } @@ -250,6 +255,72 @@ func TestLocations_RoundTrip(t *testing.T) { } } +// TestLocations_FEEWrapMaterial round-trips the FEE wrap columns and verifies +// that (a) the stored copy does not alias the caller's byte slices, (b) a +// returned copy does not either, and (c) a re-wrap in place (a PutLocation with +// a new RegionWrappedCEK/RegionKeyVersion) updates only the wrap material. +func TestLocations_FEEWrapMaterial(t *testing.T) { + ctx := context.Background() + m := NewMemStore() + const space = "did:space:enc" + digest := []byte("enc-digest") + + enc := registry.BlobLocation{ + Space: space, Digest: digest, Provider: "did:piri:1", URL: "http://piri/enc", Size: 4096, + RegionWrappedCEK: []byte("wrapped-cek"), + RegionKeyVersion: "region-v1", + TenantRecipientKID: "did:key:tenant#wrap", + BaseNonce: []byte("nonce07"), + ChunkSize: 65536, + ProtectedHeader: []byte("cose-protected"), + } + if err := m.PutLocation(ctx, enc); err != nil { + t.Fatalf("PutLocation: %v", err) + } + + // Mutating the caller's slices after Put must not corrupt the store. + enc.RegionWrappedCEK[0] = 'X' + enc.BaseNonce[0] = 'X' + enc.ProtectedHeader[0] = 'X' + + got, err := m.GetLocation(ctx, space, digest) + if err != nil { + t.Fatalf("GetLocation: %v", err) + } + if string(got.RegionWrappedCEK) != "wrapped-cek" || string(got.BaseNonce) != "nonce07" || + string(got.ProtectedHeader) != "cose-protected" { + t.Fatalf("store aliased caller slices: %+v", got) + } + if got.RegionKeyVersion != "region-v1" || got.TenantRecipientKID != "did:key:tenant#wrap" || got.ChunkSize != 65536 { + t.Fatalf("GetLocation wrap scalars = %+v", got) + } + + // Mutating a returned copy must not corrupt the store either. + got.RegionWrappedCEK[0] = 'Y' + again, _ := m.GetLocation(ctx, space, digest) + if string(again.RegionWrappedCEK) != "wrapped-cek" { + t.Fatalf("store mutated through returned slice: %q", again.RegionWrappedCEK) + } + + // Re-wrap in place: new CEK + key version, same location, other fields kept. + rewrapped := *again + rewrapped.RegionWrappedCEK = []byte("wrapped-cek-v2") + rewrapped.RegionKeyVersion = "region-v2" + if err := m.PutLocation(ctx, rewrapped); err != nil { + t.Fatalf("PutLocation (re-wrap): %v", err) + } + after, err := m.GetLocation(ctx, space, digest) + if err != nil { + t.Fatalf("GetLocation after re-wrap: %v", err) + } + if string(after.RegionWrappedCEK) != "wrapped-cek-v2" || after.RegionKeyVersion != "region-v2" { + t.Fatalf("re-wrap did not take: %+v", after) + } + if after.URL != "http://piri/enc" || string(after.BaseNonce) != "nonce07" || after.ChunkSize != 65536 { + t.Fatalf("re-wrap disturbed non-wrap fields: %+v", after) + } +} + // helpers func mustAdd(t *testing.T, m *MemStore, c registry.BlobClaim) { diff --git a/migrations/sql/00004_blob_encryption.sql b/migrations/sql/00004_blob_encryption.sql new file mode 100644 index 0000000..bf44ea5 --- /dev/null +++ b/migrations/sql/00004_blob_encryption.sql @@ -0,0 +1,53 @@ +-- +goose Up +-- FEE (FilOne encryption envelope) per-blob wrap material, added to +-- ingot.blob_locations. When Ingot encrypts an object's body, each body blob +-- is stored as an independent COSE/STREAM ciphertext envelope; a (range) GET +-- must be able to decrypt any byte span of that envelope WITHOUT first fetching +-- and parsing its header. These columns cache exactly the inputs the read +-- path's aesstream decryptor needs, so a read unwraps the CEK (under the region +-- KEK) and goes straight to a body-range fetch — no envelope-header round-trip. +-- See FIL-480; the aesstream.Config inputs are FIL-569 / FIL-472 / FIL-487. +-- +-- WHY blob_locations rather than a new table. blob_locations is keyed by +-- (space, digest) — exactly the granularity FEE context lives at. A fresh CEK +-- is generated per encryption event, so every ciphertext digest is unique to +-- one encryption (never shared across objects, even for identical plaintext). +-- The wrap material is therefore a 1:1 fact about the row, just like the +-- existing provider/url/size columns. This broadens blob_locations from "where +-- the bytes are" toward "the full per-blob record" — a rename (e.g. +-- blob_records) is worth discussing but out of scope here (see the FIL-480 +-- reviewer note). +-- +-- Object-level values (plaintext size, ETag, object identity) are NOT stored +-- here: they live in the content-addressed ObjectManifest / MST leaf, computed +-- before encryption in the PUT pipeline, and an overwrite already swaps the MST +-- leaf to a new manifest CID atomically — so no plaintext_* columns and no +-- generation counter are needed. No region column either: one Ingot instance is +-- one region, so region_key_version alone names which region KEK version wrapped +-- the CEK. +-- +-- All columns are nullable. An unencrypted blob (the appliance topology today) +-- carries a location row with these columns NULL. Raw CEK bytes are never +-- stored — only the CEK wrapped under the region KEK (region_wrapped_cek) plus +-- the key-version / recipient identifiers needed to unwrap it. Per-blob +-- crypto-shred is nulling these columns (or deleting the row) — no separate +-- mechanism. Re-wrap under a rotated region key updates region_wrapped_cek and +-- region_key_version in place. Because region_key_version / tenant_recipient_kid +-- are opaque identifiers, no schema change is needed if the region-key or Hilt +-- wrap-key cardinality decisions (FIL-572 / FIL-574) later go multi-key. +ALTER TABLE ingot.blob_locations + ADD COLUMN region_wrapped_cek bytea, -- CEK wrapped under the region KEK (A256KW) + ADD COLUMN region_key_version text, -- opaque id of the region KEK version used (rotation re-wrap) + ADD COLUMN tenant_recipient_kid text, -- opaque id of the Hilt wrap key (insurance-recovery unwrap) + ADD COLUMN base_nonce bytea, -- COSE iv: the STREAM nonce seed for this blob's ciphertext + ADD COLUMN chunk_size bigint, -- FEE chunk size from the COSE protected header + ADD COLUMN protected_header bytea; -- raw COSE protected header bytes (Enc_structure/AAD reconstruction) + +-- +goose Down +ALTER TABLE ingot.blob_locations + DROP COLUMN protected_header, + DROP COLUMN chunk_size, + DROP COLUMN base_nonce, + DROP COLUMN tenant_recipient_kid, + DROP COLUMN region_key_version, + DROP COLUMN region_wrapped_cek; diff --git a/migrations/up_live_test.go b/migrations/up_live_test.go index 5ccbdec..feae3e0 100644 --- a/migrations/up_live_test.go +++ b/migrations/up_live_test.go @@ -71,4 +71,22 @@ func TestUp_Live(t *testing.T) { t.Errorf("column ingot.buckets.%s does not exist after migration", col) } } + + // The FEE wrap columns added to blob_locations (00004) exist and are nullable. + for _, col := range []string{ + "region_wrapped_cek", "region_key_version", "tenant_recipient_kid", + "base_nonce", "chunk_size", "protected_header", + } { + var nullable string + err := pool.QueryRow(ctx, + `SELECT is_nullable FROM information_schema.columns + WHERE table_schema = 'ingot' AND table_name = 'blob_locations' AND column_name = $1`, col).Scan(&nullable) + if err != nil { + t.Errorf("column ingot.blob_locations.%s missing after migration: %v", col, err) + continue + } + if nullable != "YES" { + t.Errorf("column ingot.blob_locations.%s is_nullable = %q, want YES", col, nullable) + } + } } diff --git a/registry/postgres_live_test.go b/registry/postgres_live_test.go index e3d76af..93bba39 100644 --- a/registry/postgres_live_test.go +++ b/registry/postgres_live_test.go @@ -1,6 +1,7 @@ package registry_test import ( + "bytes" "context" "os" "testing" @@ -127,6 +128,7 @@ func TestPostgresStores_Live(t *testing.T) { }) t.Run("location round trip", func(t *testing.T) { + // Unencrypted blob: the FEE wrap columns store as NULL and read back zero. if err := r.PutLocation(ctx, registry.BlobLocation{Space: "s", Digest: digest, Provider: "did:piri", URL: "http://piri/b", Size: 100}); err != nil { t.Fatalf("PutLocation: %v", err) } @@ -134,6 +136,10 @@ func TestPostgresStores_Live(t *testing.T) { if err != nil || loc.URL != "http://piri/b" || loc.Size != 100 { t.Fatalf("GetLocation = %+v, err %v", loc, err) } + if loc.RegionWrappedCEK != nil || loc.BaseNonce != nil || loc.ProtectedHeader != nil || + loc.RegionKeyVersion != "" || loc.TenantRecipientKID != "" || loc.ChunkSize != 0 { + t.Fatalf("unencrypted location read back wrap material (NULL round-trip): %+v", loc) + } if err := r.DeleteLocation(ctx, "s", digest); err != nil { t.Fatalf("DeleteLocation: %v", err) } @@ -142,6 +148,52 @@ func TestPostgresStores_Live(t *testing.T) { } }) + t.Run("location FEE wrap material", func(t *testing.T) { + // Binary bytea (with an embedded NUL) exercises real byte round-trips. + encDigest := []byte{0x00, 0x01, 0x02, 0xff} + enc := registry.BlobLocation{ + Space: "s", Digest: encDigest, Provider: "did:piri", URL: "http://piri/enc", Size: 4096, + RegionWrappedCEK: []byte{0x00, 0xde, 0xad, 0xbe, 0xef}, + RegionKeyVersion: "region-v1", + TenantRecipientKID: "did:key:tenant#wrap", + BaseNonce: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}, + ChunkSize: 65536, + ProtectedHeader: []byte{0xa1, 0x00, 0x18, 0x20}, + } + if err := r.PutLocation(ctx, enc); err != nil { + t.Fatalf("PutLocation (encrypted): %v", err) + } + got, err := r.GetLocation(ctx, "s", encDigest) + if err != nil { + t.Fatalf("GetLocation (encrypted): %v", err) + } + if !bytes.Equal(got.RegionWrappedCEK, enc.RegionWrappedCEK) || + !bytes.Equal(got.BaseNonce, enc.BaseNonce) || + !bytes.Equal(got.ProtectedHeader, enc.ProtectedHeader) { + t.Fatalf("bytea wrap material round-trip mismatch: %+v", got) + } + if got.RegionKeyVersion != "region-v1" || got.TenantRecipientKID != "did:key:tenant#wrap" || got.ChunkSize != 65536 { + t.Fatalf("wrap scalar round-trip mismatch: %+v", got) + } + + // Re-wrap in place: a PutLocation upsert swaps the CEK + key version. + got.RegionWrappedCEK = []byte{0x11, 0x22, 0x33} + got.RegionKeyVersion = "region-v2" + if err := r.PutLocation(ctx, *got); err != nil { + t.Fatalf("PutLocation (re-wrap): %v", err) + } + after, err := r.GetLocation(ctx, "s", encDigest) + if err != nil || !bytes.Equal(after.RegionWrappedCEK, []byte{0x11, 0x22, 0x33}) || after.RegionKeyVersion != "region-v2" { + t.Fatalf("re-wrap round-trip = %+v, err %v", after, err) + } + if after.ChunkSize != 65536 || !bytes.Equal(after.BaseNonce, enc.BaseNonce) { + t.Fatalf("re-wrap disturbed non-wrap fields: %+v", after) + } + if err := r.DeleteLocation(ctx, "s", encDigest); err != nil { + t.Fatalf("DeleteLocation: %v", err) + } + }) + t.Run("multipart session parts latch metadata", func(t *testing.T) { const id = "upl-1" meta := map[string]string{"x-amz-meta-foo": "bar"} diff --git a/registry/stores.go b/registry/stores.go index 0deba49..31467dd 100644 --- a/registry/stores.go +++ b/registry/stores.go @@ -58,14 +58,49 @@ type UploadIntent struct { Bucket string } -// BlobLocation is one row of ingot.blob_locations: where a blob can be -// retrieved from, captured at accept. Keyed by (Space, Digest). +// BlobLocation is one row of ingot.blob_locations: the full per-blob record +// keyed by (Space, Digest) — where the blob can be retrieved from (captured at +// accept) and, when the blob is an encrypted FEE envelope, the wrap material +// the read path needs to decrypt it. +// +// The FEE fields are populated only for encrypted blobs; they are the cached +// inputs a (range) GET's aesstream decryptor needs, so a read unwraps the CEK +// and goes straight to a body-range fetch with no COSE envelope-header +// round-trip. They are all-or-nothing: an unencrypted blob leaves them zero +// (stored as SQL NULL). A fresh CEK per encryption event makes every ciphertext +// digest unique to one encryption, so this wrap material is a 1:1 fact about the +// row. Raw CEK bytes are never stored — only the region-KEK-wrapped CEK and the +// identifiers needed to unwrap it. See docs/architecture.md §8 and FIL-480. type BlobLocation struct { Space string Digest []byte Provider string URL string Size int64 + + // RegionWrappedCEK is the content-encryption key wrapped under the region + // KEK (A256KW). Nil for an unencrypted blob; its presence marks the blob as + // an encrypted FEE envelope. Never the raw CEK. + RegionWrappedCEK []byte + // RegionKeyVersion identifies which version of the region KEK wrapped the + // CEK, so a rotation can re-wrap in place. Opaque, so it is agnostic to the + // region-key cardinality decision (FIL-572). Empty for an unencrypted blob. + RegionKeyVersion string + // TenantRecipientKID identifies the Hilt wrap key used for insurance-recovery + // unwrap. Opaque, so it is agnostic to the tenant-vs-bucket granularity + // decision (FIL-574). Empty for an unencrypted blob. + TenantRecipientKID string + // BaseNonce is the COSE iv: the STREAM nonce seed for this blob's ciphertext. + // Nil for an unencrypted blob. + BaseNonce []byte + // ChunkSize is the FEE chunk size written into the COSE protected header, + // cached so the read path need not fetch and decode the envelope header. + // Zero for an unencrypted blob. + ChunkSize int64 + // ProtectedHeader is the raw COSE protected header bytes, cached to + // reconstruct the Enc_structure (AAD) without an envelope round-trip. Nil for + // an unencrypted blob. + ProtectedHeader []byte } // MultipartSession is one row of ingot.multipart_sessions. @@ -112,9 +147,14 @@ type IntentStore interface { DeleteIntent(ctx context.Context, digest []byte) error } -// LocationStore is the local blob-location table (§8, appliance topology): -// the (space, digest) → provider/URL mapping captured at accept, resolved -// on read in place of the indexing-service. +// LocationStore is the local blob-location table (§8, appliance topology): the +// per-blob record keyed by (space, digest), captured at accept and resolved on +// read in place of the indexing-service. Besides the provider/URL/size that +// locate the bytes, a row also carries the FEE wrap material for an encrypted +// blob (see BlobLocation) — PutLocation writes it, GetLocation reads it back. +// Per-blob crypto-shred is nulling that material (write a row with the FEE +// fields zero) or DeleteLocation; a rotation re-wrap is a PutLocation with a new +// RegionWrappedCEK/RegionKeyVersion. type LocationStore interface { PutLocation(ctx context.Context, loc BlobLocation) error GetLocation(ctx context.Context, space string, digest []byte) (*BlobLocation, error) diff --git a/registry/stores_postgres.go b/registry/stores_postgres.go index bbc3c9e..592ae94 100644 --- a/registry/stores_postgres.go +++ b/registry/stores_postgres.go @@ -143,12 +143,26 @@ func (r *Postgres) DeleteIntent(ctx context.Context, digest []byte) error { // LocationStore ============================================================== func (r *Postgres) PutLocation(ctx context.Context, loc BlobLocation) error { + // The FEE wrap columns are nullable: an unencrypted blob writes them NULL + // (nil bytea / nullString / nullInt64). The upsert overwrites the whole row + // state, so a rotation re-wrap is a read-modify-write of the full record. _, err := r.pool.Exec(ctx, - `INSERT INTO ingot.blob_locations (space, digest, provider, url, size) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO ingot.blob_locations + (space, digest, provider, url, size, + region_wrapped_cek, region_key_version, tenant_recipient_kid, + base_nonce, chunk_size, protected_header) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (space, digest) DO UPDATE - SET provider = EXCLUDED.provider, url = EXCLUDED.url, size = EXCLUDED.size`, - loc.Space, loc.Digest, loc.Provider, loc.URL, loc.Size) + SET provider = EXCLUDED.provider, url = EXCLUDED.url, size = EXCLUDED.size, + region_wrapped_cek = EXCLUDED.region_wrapped_cek, + region_key_version = EXCLUDED.region_key_version, + tenant_recipient_kid = EXCLUDED.tenant_recipient_kid, + base_nonce = EXCLUDED.base_nonce, + chunk_size = EXCLUDED.chunk_size, + protected_header = EXCLUDED.protected_header`, + loc.Space, loc.Digest, loc.Provider, loc.URL, loc.Size, + loc.RegionWrappedCEK, nullString(loc.RegionKeyVersion), nullString(loc.TenantRecipientKID), + loc.BaseNonce, nullInt64(loc.ChunkSize), loc.ProtectedHeader) if err != nil { return fmt.Errorf("registry: put location: %w", err) } @@ -157,15 +171,31 @@ func (r *Postgres) PutLocation(ctx context.Context, loc BlobLocation) error { func (r *Postgres) GetLocation(ctx context.Context, space string, digest []byte) (*BlobLocation, error) { loc := &BlobLocation{Space: space, Digest: digest} + var regionKeyVersion, tenantRecipientKID *string + var chunkSize *int64 err := r.pool.QueryRow(ctx, - `SELECT provider, url, size FROM ingot.blob_locations WHERE space = $1 AND digest = $2`, - space, digest).Scan(&loc.Provider, &loc.URL, &loc.Size) + `SELECT provider, url, size, + region_wrapped_cek, region_key_version, tenant_recipient_kid, + base_nonce, chunk_size, protected_header + FROM ingot.blob_locations WHERE space = $1 AND digest = $2`, + space, digest).Scan(&loc.Provider, &loc.URL, &loc.Size, + &loc.RegionWrappedCEK, ®ionKeyVersion, &tenantRecipientKID, + &loc.BaseNonce, &chunkSize, &loc.ProtectedHeader) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, fmt.Errorf("registry: get location: %w", err) } + if regionKeyVersion != nil { + loc.RegionKeyVersion = *regionKeyVersion + } + if tenantRecipientKID != nil { + loc.TenantRecipientKID = *tenantRecipientKID + } + if chunkSize != nil { + loc.ChunkSize = *chunkSize + } return loc, nil } @@ -310,6 +340,16 @@ func nullString(s string) *string { return &s } +// nullInt64 maps 0 to a SQL NULL so an optional bigint column stays NULL rather +// than storing 0. Used for blob_locations.chunk_size, which is 0 (absent) for an +// unencrypted blob and always positive for a FEE envelope. +func nullInt64(n int64) *int64 { + if n == 0 { + return nil + } + return &n +} + func marshalMetadata(m map[string]string) ([]byte, error) { if len(m) == 0 { return nil, nil From 3f445a1084a6768f3f82942c63a004f36a5510d3 Mon Sep 17 00:00:00 2001 From: Petra Jaros Date: Thu, 2 Jul 2026 13:59:13 -0400 Subject: [PATCH 2/8] refactor: Tighten up migration comment --- migrations/sql/00004_blob_encryption.sql | 47 +++++++----------------- 1 file changed, 13 insertions(+), 34 deletions(-) diff --git a/migrations/sql/00004_blob_encryption.sql b/migrations/sql/00004_blob_encryption.sql index bf44ea5..26d7dbe 100644 --- a/migrations/sql/00004_blob_encryption.sql +++ b/migrations/sql/00004_blob_encryption.sql @@ -1,40 +1,19 @@ -- +goose Up -- FEE (FilOne encryption envelope) per-blob wrap material, added to --- ingot.blob_locations. When Ingot encrypts an object's body, each body blob --- is stored as an independent COSE/STREAM ciphertext envelope; a (range) GET --- must be able to decrypt any byte span of that envelope WITHOUT first fetching --- and parsing its header. These columns cache exactly the inputs the read --- path's aesstream decryptor needs, so a read unwraps the CEK (under the region --- KEK) and goes straight to a body-range fetch — no envelope-header round-trip. --- See FIL-480; the aesstream.Config inputs are FIL-569 / FIL-472 / FIL-487. +-- ingot.blob_locations. When Ingot encrypts an object's body, each body blob is +-- stored as an independent COSE/STREAM ciphertext envelope; a range GET must be +-- able to decrypt any byte span of that envelope WITHOUT first fetching and +-- parsing its header. These columns cache exactly the inputs the read path's +-- aesstream decryptor needs, so a read unwraps the CEK (under the region KEK) +-- and goes straight to a body-range fetch — no envelope-header round-trip. -- --- WHY blob_locations rather than a new table. blob_locations is keyed by --- (space, digest) — exactly the granularity FEE context lives at. A fresh CEK --- is generated per encryption event, so every ciphertext digest is unique to --- one encryption (never shared across objects, even for identical plaintext). --- The wrap material is therefore a 1:1 fact about the row, just like the --- existing provider/url/size columns. This broadens blob_locations from "where --- the bytes are" toward "the full per-blob record" — a rename (e.g. --- blob_records) is worth discussing but out of scope here (see the FIL-480 --- reviewer note). --- --- Object-level values (plaintext size, ETag, object identity) are NOT stored --- here: they live in the content-addressed ObjectManifest / MST leaf, computed --- before encryption in the PUT pipeline, and an overwrite already swaps the MST --- leaf to a new manifest CID atomically — so no plaintext_* columns and no --- generation counter are needed. No region column either: one Ingot instance is --- one region, so region_key_version alone names which region KEK version wrapped --- the CEK. --- --- All columns are nullable. An unencrypted blob (the appliance topology today) --- carries a location row with these columns NULL. Raw CEK bytes are never --- stored — only the CEK wrapped under the region KEK (region_wrapped_cek) plus --- the key-version / recipient identifiers needed to unwrap it. Per-blob --- crypto-shred is nulling these columns (or deleting the row) — no separate --- mechanism. Re-wrap under a rotated region key updates region_wrapped_cek and --- region_key_version in place. Because region_key_version / tenant_recipient_kid --- are opaque identifiers, no schema change is needed if the region-key or Hilt --- wrap-key cardinality decisions (FIL-572 / FIL-574) later go multi-key. +-- All columns are nullable. An unencrypted blob carries a location row with +-- these columns NULL. Raw CEK bytes are never stored — only the CEK wrapped +-- under the region KEK (region_wrapped_cek) plus the key-version / recipient +-- identifiers needed to unwrap it. Per-blob crypto-shred is nulling these +-- columns (or deleting the row) — no separate mechanism. Re-wrap under a +-- rotated region key updates region_wrapped_cek and region_key_version in +-- place. ALTER TABLE ingot.blob_locations ADD COLUMN region_wrapped_cek bytea, -- CEK wrapped under the region KEK (A256KW) ADD COLUMN region_key_version text, -- opaque id of the region KEK version used (rotation re-wrap) From 9c91922fc3c3809ed9a49952bfed80d8e64d4430 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 18:05:44 +0000 Subject: [PATCH 3/8] fix(registry): enforce all-or-nothing FEE invariant in PutLocation (FIL-480) Addresses a PR review note: BlobLocation documents the FEE wrap fields as all-or-nothing, but PutLocation accepted a partial set (e.g. a wrapped CEK with no nonce, or an empty-but-non-nil byte slice stored as a non-NULL empty bytea), silently persisting a row the decrypt path cannot use. - BlobLocation.ValidateFEE enforces the invariant: either all six wrap fields are present (non-empty, chunk_size > 0) or none; a partial set returns ErrPartialFEE. Both PutLocation implementations (Postgres + inmem) call it. - nullBytes maps empty/nil byte slices to SQL NULL so an absent FEE column never lands as a non-NULL empty bytea. - tests: partial sets rejected (and leave no row) in the inmem and live-PG suites; the fully-absent (unencrypted) row is still accepted. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XSxwh7XfPtFQtyeoDR65hC --- inmem/stores.go | 4 +++ inmem/stores_test.go | 36 +++++++++++++++++++++++++++ registry/postgres_live_test.go | 16 ++++++++++++ registry/stores.go | 45 ++++++++++++++++++++++++++++++---- registry/stores_postgres.go | 25 +++++++++++++++---- 5 files changed, 116 insertions(+), 10 deletions(-) diff --git a/inmem/stores.go b/inmem/stores.go index 5ee4cde..dbc05c8 100644 --- a/inmem/stores.go +++ b/inmem/stores.go @@ -119,6 +119,10 @@ func (m *MemStore) DeleteIntent(_ context.Context, digest []byte) error { // LocationStore ============================================================== func (m *MemStore) PutLocation(_ context.Context, loc registry.BlobLocation) error { + // Match the Postgres store: reject a partial FEE set (see ValidateFEE). + if err := loc.ValidateFEE(); err != nil { + return err + } m.mu.Lock() defer m.mu.Unlock() m.locations[locKey{loc.Space, string(loc.Digest)}] = cloneLocation(loc) diff --git a/inmem/stores_test.go b/inmem/stores_test.go index 30409e6..67ef642 100644 --- a/inmem/stores_test.go +++ b/inmem/stores_test.go @@ -2,6 +2,7 @@ package inmem import ( "context" + "errors" "sync" "sync/atomic" "testing" @@ -321,6 +322,41 @@ func TestLocations_FEEWrapMaterial(t *testing.T) { } } +// TestLocations_PartialFEE_Rejected verifies the all-or-nothing FEE invariant: +// a location with some but not all wrap material is rejected with ErrPartialFEE +// and never persisted, while a fully-absent (unencrypted) row is accepted. +func TestLocations_PartialFEE_Rejected(t *testing.T) { + ctx := context.Background() + m := NewMemStore() + base := registry.BlobLocation{Space: "s", Digest: []byte("d"), Provider: "did:piri", URL: "u", Size: 10} + + partials := map[string]func(*registry.BlobLocation){ + "only wrapped CEK": func(l *registry.BlobLocation) { l.RegionWrappedCEK = []byte("cek") }, + "only key version": func(l *registry.BlobLocation) { l.RegionKeyVersion = "v1" }, + "only chunk size": func(l *registry.BlobLocation) { l.ChunkSize = 4096 }, + "missing protected": func(l *registry.BlobLocation) { + l.RegionWrappedCEK, l.RegionKeyVersion, l.TenantRecipientKID = []byte("cek"), "v1", "kid" + l.BaseNonce, l.ChunkSize = []byte("nonce07"), 4096 // ProtectedHeader left empty + }, + } + for name, mutate := range partials { + loc := base + mutate(&loc) + if err := m.PutLocation(ctx, loc); !errors.Is(err, registry.ErrPartialFEE) { + t.Fatalf("PutLocation(%s) err = %v, want ErrPartialFEE", name, err) + } + // Nothing should have been stored. + if _, err := m.GetLocation(ctx, "s", []byte("d")); !errors.Is(err, registry.ErrNotFound) { + t.Fatalf("partial FEE (%s) leaked a row", name) + } + } + + // Fully absent (unencrypted) is accepted. + if err := m.PutLocation(ctx, base); err != nil { + t.Fatalf("PutLocation(unencrypted): %v", err) + } +} + // helpers func mustAdd(t *testing.T, m *MemStore, c registry.BlobClaim) { diff --git a/registry/postgres_live_test.go b/registry/postgres_live_test.go index 93bba39..b3bb13a 100644 --- a/registry/postgres_live_test.go +++ b/registry/postgres_live_test.go @@ -3,6 +3,7 @@ package registry_test import ( "bytes" "context" + "errors" "os" "testing" "time" @@ -194,6 +195,21 @@ func TestPostgresStores_Live(t *testing.T) { } }) + t.Run("location partial FEE rejected", func(t *testing.T) { + // A partial wrap set is rejected before it reaches SQL and leaves no row. + d := []byte{0x77} + partial := registry.BlobLocation{ + Space: "s", Digest: d, Provider: "did:piri", URL: "u", Size: 10, + RegionWrappedCEK: []byte{0x01}, // the rest deliberately absent + } + if err := r.PutLocation(ctx, partial); !errors.Is(err, registry.ErrPartialFEE) { + t.Fatalf("PutLocation(partial) = %v, want ErrPartialFEE", err) + } + if _, err := r.GetLocation(ctx, "s", d); !errors.Is(err, registry.ErrNotFound) { + t.Fatalf("partial FEE leaked a row: %v", err) + } + }) + t.Run("multipart session parts latch metadata", func(t *testing.T) { const id = "upl-1" meta := map[string]string{"x-amz-meta-foo": "bar"} diff --git a/registry/stores.go b/registry/stores.go index 31467dd..0fecc76 100644 --- a/registry/stores.go +++ b/registry/stores.go @@ -1,6 +1,10 @@ package registry -import "context" +import ( + "context" + "errors" + "fmt" +) // This file defines the relational surface the upload/storage/delete // architecture relies on (docs/architecture.md §5–§7, Appendix C): @@ -67,10 +71,12 @@ type UploadIntent struct { // inputs a (range) GET's aesstream decryptor needs, so a read unwraps the CEK // and goes straight to a body-range fetch with no COSE envelope-header // round-trip. They are all-or-nothing: an unencrypted blob leaves them zero -// (stored as SQL NULL). A fresh CEK per encryption event makes every ciphertext -// digest unique to one encryption, so this wrap material is a 1:1 fact about the -// row. Raw CEK bytes are never stored — only the region-KEK-wrapped CEK and the -// identifiers needed to unwrap it. See docs/architecture.md §8 and FIL-480. +// (stored as SQL NULL), and PutLocation rejects a partial set via ValidateFEE +// so no row is ever persisted that the decrypt path cannot use. A fresh CEK per +// encryption event makes every ciphertext digest unique to one encryption, so +// this wrap material is a 1:1 fact about the row. Raw CEK bytes are never +// stored — only the region-KEK-wrapped CEK and the identifiers needed to unwrap +// it. See docs/architecture.md §8 and FIL-480. type BlobLocation struct { Space string Digest []byte @@ -103,6 +109,35 @@ type BlobLocation struct { ProtectedHeader []byte } +// ErrPartialFEE is returned by PutLocation when a BlobLocation carries some but +// not all of the FEE wrap material — a row the decrypt path could not use. +var ErrPartialFEE = errors.New("registry: partial FEE wrap material") + +// ValidateFEE enforces the all-or-nothing FEE invariant that BlobLocation +// documents: either every wrap field is present (non-empty byte slices, non-empty +// identifiers, and ChunkSize > 0) or none is. A partial set — e.g. a wrapped CEK +// with no nonce, or an empty (non-nil) byte slice standing in for real material — +// would persist a row a later GET cannot decrypt, so PutLocation rejects it. +func (loc BlobLocation) ValidateFEE() error { + present := 0 + for _, ok := range []bool{ + len(loc.RegionWrappedCEK) > 0, + loc.RegionKeyVersion != "", + loc.TenantRecipientKID != "", + len(loc.BaseNonce) > 0, + loc.ChunkSize > 0, + len(loc.ProtectedHeader) > 0, + } { + if ok { + present++ + } + } + if present != 0 && present != 6 { + return fmt.Errorf("%w: %d of 6 fields set", ErrPartialFEE, present) + } + return nil +} + // MultipartSession is one row of ingot.multipart_sessions. type MultipartSession struct { UploadID string diff --git a/registry/stores_postgres.go b/registry/stores_postgres.go index 592ae94..c897c72 100644 --- a/registry/stores_postgres.go +++ b/registry/stores_postgres.go @@ -143,9 +143,14 @@ func (r *Postgres) DeleteIntent(ctx context.Context, digest []byte) error { // LocationStore ============================================================== func (r *Postgres) PutLocation(ctx context.Context, loc BlobLocation) error { - // The FEE wrap columns are nullable: an unencrypted blob writes them NULL - // (nil bytea / nullString / nullInt64). The upsert overwrites the whole row - // state, so a rotation re-wrap is a read-modify-write of the full record. + // Reject a partial FEE set before it reaches storage (see ValidateFEE). + if err := loc.ValidateFEE(); err != nil { + return err + } + // The FEE columns are nullable: an unencrypted blob writes them NULL (nullBytes + // / nullString / nullInt64 map empty/zero to NULL, so an empty-but-non-nil + // slice never lands as a non-NULL empty bytea). The upsert overwrites the whole + // row state, so a rotation re-wrap is a read-modify-write of the full record. _, err := r.pool.Exec(ctx, `INSERT INTO ingot.blob_locations (space, digest, provider, url, size, @@ -161,8 +166,8 @@ func (r *Postgres) PutLocation(ctx context.Context, loc BlobLocation) error { chunk_size = EXCLUDED.chunk_size, protected_header = EXCLUDED.protected_header`, loc.Space, loc.Digest, loc.Provider, loc.URL, loc.Size, - loc.RegionWrappedCEK, nullString(loc.RegionKeyVersion), nullString(loc.TenantRecipientKID), - loc.BaseNonce, nullInt64(loc.ChunkSize), loc.ProtectedHeader) + nullBytes(loc.RegionWrappedCEK), nullString(loc.RegionKeyVersion), nullString(loc.TenantRecipientKID), + nullBytes(loc.BaseNonce), nullInt64(loc.ChunkSize), nullBytes(loc.ProtectedHeader)) if err != nil { return fmt.Errorf("registry: put location: %w", err) } @@ -350,6 +355,16 @@ func nullInt64(n int64) *int64 { return &n } +// nullBytes maps an empty (nil or zero-length) slice to a SQL NULL so an +// optional bytea column stays NULL rather than storing a non-NULL empty value. +// Used for the FEE wrap columns, whose absence must read back as nil. +func nullBytes(b []byte) []byte { + if len(b) == 0 { + return nil + } + return b +} + func marshalMetadata(m map[string]string) ([]byte, error) { if len(m) == 0 { return nil, nil From 3db7e44c9e8ce1bc14c1dfb84e1520b9e4d01a38 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 18:13:11 +0000 Subject: [PATCH 4/8] style(inmem): gofmt test map alignment (FIL-480) go-check flagged inmem/stores_test.go as not gofmt-ed: the TestLocations_ PartialFEE_Rejected map keys were aligned against a multi-line entry. Run gofmt -w. No behavior change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XSxwh7XfPtFQtyeoDR65hC --- inmem/stores_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/inmem/stores_test.go b/inmem/stores_test.go index 67ef642..7ad1729 100644 --- a/inmem/stores_test.go +++ b/inmem/stores_test.go @@ -331,9 +331,9 @@ func TestLocations_PartialFEE_Rejected(t *testing.T) { base := registry.BlobLocation{Space: "s", Digest: []byte("d"), Provider: "did:piri", URL: "u", Size: 10} partials := map[string]func(*registry.BlobLocation){ - "only wrapped CEK": func(l *registry.BlobLocation) { l.RegionWrappedCEK = []byte("cek") }, - "only key version": func(l *registry.BlobLocation) { l.RegionKeyVersion = "v1" }, - "only chunk size": func(l *registry.BlobLocation) { l.ChunkSize = 4096 }, + "only wrapped CEK": func(l *registry.BlobLocation) { l.RegionWrappedCEK = []byte("cek") }, + "only key version": func(l *registry.BlobLocation) { l.RegionKeyVersion = "v1" }, + "only chunk size": func(l *registry.BlobLocation) { l.ChunkSize = 4096 }, "missing protected": func(l *registry.BlobLocation) { l.RegionWrappedCEK, l.RegionKeyVersion, l.TenantRecipientKID = []byte("cek"), "v1", "kid" l.BaseNonce, l.ChunkSize = []byte("nonce07"), 4096 // ProtectedHeader left empty From b864480ceeb1f421054d1347e9bad74361247d0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 17 Aug 2026 15:43:12 +0200 Subject: [PATCH 5/8] feat(registry): store FEE params in their own table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the FIL-480 draft rejected extending blob_locations with the per-blob FEE wrap material. blob_locations is a reconstructible cache of the indexing-service contract — every row can be re-derived from the indexer or the accept receipt, and the table disappears when the topology moves to a real indexer. A wrapped CEK is not reconstructible: lose the row and the ciphertext is unreadable forever. Key material therefore does not belong in the one table whose design allows it to be rebuilt, or truncated (the live test already does). blob_encryption_params holds it instead, with no foreign key to blob_locations: the two have independent lifecycles, and an FK would force location-before-params write ordering. Because nothing cascades, DeleteLocation no longer shreds — a caller removing a blob must delete from both tables. Two fixes to the material itself, both raised in review: - Add header_len. The stored blob is envelope||ciphertext and nothing recorded where the envelope ends, so a read could not locate byte 0 of the ciphertext without decoding the header, leaving the "no header round-trip" goal out of reach. - Store the whole COSE Enc_structure as aad instead of the protected header. The structure's context string differs between a COSE_Encrypt and a COSE_Encrypt0, which a bare row cannot record, so the protected header alone is not enough to rebuild the AAD. The header stays recoverable from the Enc_structure as element 1. Every column is NOT NULL, so the all-or-nothing invariant is structural rather than a nullable-column check: the existence of a row is what marks a blob as encrypted. BlobEncryptionParams.Validate rejects an incomplete set before SQL, replacing ValidateFEE/ErrPartialFEE. Migration 00013 is reshaped in place — it has not shipped anywhere. Assisted-by: Claude:claude-fable-5 Signed-off-by: Miroslav Bajtoš --- inmem/store.go | 7 +- inmem/stores.go | 73 +++++-- inmem/stores_test.go | 243 +++++++++++++++-------- migrations/sql/00013_blob_encryption.sql | 80 +++++--- migrations/up_live_test.go | 19 +- registry/postgres_live_test.go | 125 +++++++----- registry/stores.go | 139 ++++++++----- registry/stores_postgres.go | 136 +++++++------ 8 files changed, 521 insertions(+), 301 deletions(-) diff --git a/inmem/store.go b/inmem/store.go index ade9c2c..a76fdaa 100644 --- a/inmem/store.go +++ b/inmem/store.go @@ -59,6 +59,7 @@ type MemStore struct { blobRefs map[claimKey]registry.BlobClaim intents map[string]registry.UploadIntent // keyed by string(digest) locations map[locKey]registry.BlobLocation // keyed by (space, digest) + encParams map[locKey]registry.BlobEncryptionParams // keyed by (space, digest) inclusions map[locKey]registry.BlobInclusion // keyed by (space, digest) parks map[string]registry.BlobPark // keyed by string(digest) sessions map[string]registry.MultipartSession // keyed by uploadID @@ -66,8 +67,9 @@ type MemStore struct { gcCands map[string]struct{} // keyed by string(cid) } -// claimKey / locKey are the composite map keys for the blob_refs and -// blob_locations tables (digest bytes carried as a string for comparability). +// claimKey / locKey are the composite map keys for the blob_refs and the +// (space, digest)-keyed tables — blob_locations, blob_encryption_params and +// shard_inclusions (digest bytes carried as a string for comparability). type claimKey struct { digest, bucket, objectKey, versionID string } @@ -86,6 +88,7 @@ func NewMemStore() *MemStore { blobRefs: map[claimKey]registry.BlobClaim{}, intents: map[string]registry.UploadIntent{}, locations: map[locKey]registry.BlobLocation{}, + encParams: map[locKey]registry.BlobEncryptionParams{}, inclusions: map[locKey]registry.BlobInclusion{}, parks: map[string]registry.BlobPark{}, sessions: map[string]registry.MultipartSession{}, diff --git a/inmem/stores.go b/inmem/stores.go index f70c2cb..3702f73 100644 --- a/inmem/stores.go +++ b/inmem/stores.go @@ -11,18 +11,19 @@ import ( ) // In-memory implementations of the architecture's relational stores -// (registry.BlobRefStore / IntentStore / LocationStore / MultipartStore / -// GCStore), mirroring the Postgres tables so the in-process suite and -// standalone mode exercise the same write/read/delete code paths. +// (registry.BlobRefStore / IntentStore / LocationStore / EncryptionParamsStore / +// MultipartStore / GCStore), mirroring the Postgres tables so the in-process +// suite and standalone mode exercise the same write/read/delete code paths. // Compile-time assertions: *MemStore satisfies every store interface. var ( - _ registry.BlobRefStore = (*MemStore)(nil) - _ registry.IntentStore = (*MemStore)(nil) - _ registry.LocationStore = (*MemStore)(nil) - _ registry.InclusionStore = (*MemStore)(nil) - _ registry.MultipartStore = (*MemStore)(nil) - _ registry.GCStore = (*MemStore)(nil) + _ registry.BlobRefStore = (*MemStore)(nil) + _ registry.IntentStore = (*MemStore)(nil) + _ registry.LocationStore = (*MemStore)(nil) + _ registry.EncryptionParamsStore = (*MemStore)(nil) + _ registry.InclusionStore = (*MemStore)(nil) + _ registry.MultipartStore = (*MemStore)(nil) + _ registry.GCStore = (*MemStore)(nil) ) func cloneBytes(b []byte) []byte { @@ -123,10 +124,6 @@ func (m *MemStore) DeleteIntent(_ context.Context, digest []byte) error { // LocationStore ============================================================== func (m *MemStore) PutLocation(_ context.Context, loc registry.BlobLocation) error { - // Match the Postgres store: reject a partial FEE set (see ValidateFEE). - if err := loc.ValidateFEE(); err != nil { - return err - } m.mu.Lock() defer m.mu.Unlock() m.locations[locKey{loc.Space, string(loc.Digest)}] = cloneLocation(loc) @@ -151,6 +148,37 @@ func (m *MemStore) DeleteLocation(_ context.Context, space did.DID, digest []byt return nil } +// EncryptionParamsStore ====================================================== + +func (m *MemStore) PutEncryptionParams(_ context.Context, params registry.BlobEncryptionParams) error { + // Match the Postgres store, whose columns are all NOT NULL. + if err := params.Validate(); err != nil { + return err + } + m.mu.Lock() + defer m.mu.Unlock() + m.encParams[locKey{params.Space, string(params.Digest)}] = cloneEncryptionParams(params) + return nil +} + +func (m *MemStore) GetEncryptionParams(_ context.Context, space did.DID, digest []byte) (*registry.BlobEncryptionParams, error) { + m.mu.Lock() + defer m.mu.Unlock() + params, ok := m.encParams[locKey{space, string(digest)}] + if !ok { + return nil, registry.ErrNotFound + } + cp := cloneEncryptionParams(params) + return &cp, nil +} + +func (m *MemStore) DeleteEncryptionParams(_ context.Context, space did.DID, digest []byte) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.encParams, locKey{space, string(digest)}) + return nil +} + // ParkStore ================================================================== func (m *MemStore) PutPark(_ context.Context, p registry.BlobPark) error { @@ -387,17 +415,24 @@ func cloneSession(s registry.MultipartSession) registry.MultipartSession { return s } -// cloneLocation deep-copies a BlobLocation's byte-slice fields (the digest and -// the FEE wrap material) so the stored copy and any returned copy never alias -// the caller's slices. Nil slices stay nil, preserving "unencrypted blob". +// cloneLocation deep-copies a BlobLocation's digest so the stored copy and any +// returned copy never alias the caller's slice. func cloneLocation(loc registry.BlobLocation) registry.BlobLocation { loc.Digest = cloneBytes(loc.Digest) - loc.RegionWrappedCEK = cloneBytes(loc.RegionWrappedCEK) - loc.BaseNonce = cloneBytes(loc.BaseNonce) - loc.ProtectedHeader = cloneBytes(loc.ProtectedHeader) return loc } +// cloneEncryptionParams deep-copies a BlobEncryptionParams' byte-slice fields — +// the digest and the key material — so the stored copy and any returned copy +// never alias the caller's slices. +func cloneEncryptionParams(p registry.BlobEncryptionParams) registry.BlobEncryptionParams { + p.Digest = cloneBytes(p.Digest) + p.RegionWrappedCEK = cloneBytes(p.RegionWrappedCEK) + p.BaseNonce = cloneBytes(p.BaseNonce) + p.AAD = cloneBytes(p.AAD) + return p +} + func clonePart(p registry.MultipartPart) registry.MultipartPart { p.ETagMD5 = cloneBytes(p.ETagMD5) if p.BlobDigests != nil { diff --git a/inmem/stores_test.go b/inmem/stores_test.go index 040cc92..db920cb 100644 --- a/inmem/stores_test.go +++ b/inmem/stores_test.go @@ -3,6 +3,7 @@ package inmem import ( "context" "errors" + "reflect" "sync" "sync/atomic" "testing" @@ -243,11 +244,6 @@ func TestLocations_RoundTrip(t *testing.T) { if loc.URL != "http://piri/blob" || loc.Size != 100 || loc.Provider != "did:piri:1" { t.Fatalf("GetLocation = %+v", loc) } - // An unencrypted blob carries no FEE wrap material. - if loc.RegionWrappedCEK != nil || loc.BaseNonce != nil || loc.ProtectedHeader != nil || - loc.RegionKeyVersion != "" || loc.TenantRecipientKID != "" || loc.ChunkSize != 0 { - t.Fatalf("unencrypted location carries wrap material: %+v", loc) - } if _, err := m.GetLocation(ctx, testutil.RandomDID(t), digest); err != registry.ErrNotFound { t.Fatalf("GetLocation wrong space err = %v, want ErrNotFound", err) } @@ -259,105 +255,196 @@ func TestLocations_RoundTrip(t *testing.T) { } } -// TestLocations_FEEWrapMaterial round-trips the FEE wrap columns and verifies -// that (a) the stored copy does not alias the caller's byte slices, (b) a -// returned copy does not either, and (c) a re-wrap in place (a PutLocation with -// a new RegionWrappedCEK/RegionKeyVersion) updates only the wrap material. -func TestLocations_FEEWrapMaterial(t *testing.T) { - ctx := context.Background() - m := NewMemStore() - space := testutil.RandomDID(t) - digest := []byte("enc-digest") - - enc := registry.BlobLocation{ - Space: space, Digest: digest, Provider: "did:piri:1", URL: "http://piri/enc", Size: 4096, +// feeParams returns a complete parameter set for space/digest, the shape a FEE +// envelope's row takes. +func feeParams(space did.DID, digest []byte) registry.BlobEncryptionParams { + return registry.BlobEncryptionParams{ + Space: space, + Digest: digest, RegionWrappedCEK: []byte("wrapped-cek"), RegionKeyVersion: "region-v1", TenantRecipientKID: "did:key:tenant#wrap", + HeaderLen: 212, BaseNonce: []byte("nonce07"), ChunkSize: 65536, - ProtectedHeader: []byte("cose-protected"), - } - if err := m.PutLocation(ctx, enc); err != nil { - t.Fatalf("PutLocation: %v", err) + AAD: []byte("cose-enc-structure"), } +} - // Mutating the caller's slices after Put must not corrupt the store. - enc.RegionWrappedCEK[0] = 'X' - enc.BaseNonce[0] = 'X' - enc.ProtectedHeader[0] = 'X' +func TestEncryptionParams_RoundTrip(t *testing.T) { + ctx := context.Background() + m := NewMemStore() + space := testutil.RandomDID(t) + digest := []byte("enc-digest") + want := feeParams(space, digest) - got, err := m.GetLocation(ctx, space, digest) + if err := m.PutEncryptionParams(ctx, want); err != nil { + t.Fatalf("PutEncryptionParams: %v", err) + } + got, err := m.GetEncryptionParams(ctx, space, digest) if err != nil { - t.Fatalf("GetLocation: %v", err) + t.Fatalf("GetEncryptionParams: %v", err) } - if string(got.RegionWrappedCEK) != "wrapped-cek" || string(got.BaseNonce) != "nonce07" || - string(got.ProtectedHeader) != "cose-protected" { - t.Fatalf("store aliased caller slices: %+v", got) + if !reflect.DeepEqual(*got, want) { + t.Fatalf("GetEncryptionParams = %+v, want %+v", *got, want) } - if got.RegionKeyVersion != "region-v1" || got.TenantRecipientKID != "did:key:tenant#wrap" || got.ChunkSize != 65536 { - t.Fatalf("GetLocation wrap scalars = %+v", got) +} + +// A blob with no row is a plaintext blob, which is how the read path learns not +// to decrypt. +func TestEncryptionParams_MissingIsNotFound(t *testing.T) { + ctx := context.Background() + m := NewMemStore() + + _, err := m.GetEncryptionParams(ctx, testutil.RandomDID(t), []byte("absent")) + if !errors.Is(err, registry.ErrNotFound) { + t.Fatalf("GetEncryptionParams err = %v, want ErrNotFound", err) + } +} + +func TestEncryptionParams_DeleteShreds(t *testing.T) { + ctx := context.Background() + m := NewMemStore() + space := testutil.RandomDID(t) + digest := []byte("enc-digest") + if err := m.PutEncryptionParams(ctx, feeParams(space, digest)); err != nil { + t.Fatalf("PutEncryptionParams: %v", err) + } + + if err := m.DeleteEncryptionParams(ctx, space, digest); err != nil { + t.Fatalf("DeleteEncryptionParams: %v", err) + } + if _, err := m.GetEncryptionParams(ctx, space, digest); !errors.Is(err, registry.ErrNotFound) { + t.Fatalf("GetEncryptionParams after delete err = %v, want ErrNotFound", err) } +} + +func TestEncryptionParams_DeleteIsIdempotent(t *testing.T) { + ctx := context.Background() + m := NewMemStore() + + if err := m.DeleteEncryptionParams(ctx, testutil.RandomDID(t), []byte("absent")); err != nil { + t.Fatalf("DeleteEncryptionParams(absent): %v", err) + } +} - // Mutating a returned copy must not corrupt the store either. +// The store must not alias the caller's key material, nor let a caller reach +// back into it through a returned copy. +func TestEncryptionParams_NoSliceAliasing(t *testing.T) { + ctx := context.Background() + m := NewMemStore() + space := testutil.RandomDID(t) + digest := []byte("enc-digest") + params := feeParams(space, digest) + + if err := m.PutEncryptionParams(ctx, params); err != nil { + t.Fatalf("PutEncryptionParams: %v", err) + } + params.RegionWrappedCEK[0] = 'X' + params.BaseNonce[0] = 'X' + params.AAD[0] = 'X' + + got, err := m.GetEncryptionParams(ctx, space, digest) + if err != nil { + t.Fatalf("GetEncryptionParams: %v", err) + } got.RegionWrappedCEK[0] = 'Y' - again, _ := m.GetLocation(ctx, space, digest) - if string(again.RegionWrappedCEK) != "wrapped-cek" { - t.Fatalf("store mutated through returned slice: %q", again.RegionWrappedCEK) + + again, err := m.GetEncryptionParams(ctx, space, digest) + if err != nil { + t.Fatalf("GetEncryptionParams (again): %v", err) + } + if !reflect.DeepEqual(*again, feeParams(space, digest)) { + t.Fatalf("stored params were aliased: %+v", *again) + } +} + +// A region-key rotation re-wraps in place: same blob, new CEK + key version, +// every other parameter untouched. +func TestEncryptionParams_RewrapInPlace(t *testing.T) { + ctx := context.Background() + m := NewMemStore() + space := testutil.RandomDID(t) + digest := []byte("enc-digest") + if err := m.PutEncryptionParams(ctx, feeParams(space, digest)); err != nil { + t.Fatalf("PutEncryptionParams: %v", err) } - // Re-wrap in place: new CEK + key version, same location, other fields kept. - rewrapped := *again - rewrapped.RegionWrappedCEK = []byte("wrapped-cek-v2") - rewrapped.RegionKeyVersion = "region-v2" - if err := m.PutLocation(ctx, rewrapped); err != nil { - t.Fatalf("PutLocation (re-wrap): %v", err) + want := feeParams(space, digest) + want.RegionWrappedCEK = []byte("wrapped-cek-v2") + want.RegionKeyVersion = "region-v2" + if err := m.PutEncryptionParams(ctx, want); err != nil { + t.Fatalf("PutEncryptionParams (re-wrap): %v", err) } - after, err := m.GetLocation(ctx, space, digest) + + got, err := m.GetEncryptionParams(ctx, space, digest) if err != nil { - t.Fatalf("GetLocation after re-wrap: %v", err) + t.Fatalf("GetEncryptionParams: %v", err) } - if string(after.RegionWrappedCEK) != "wrapped-cek-v2" || after.RegionKeyVersion != "region-v2" { - t.Fatalf("re-wrap did not take: %+v", after) + if !reflect.DeepEqual(*got, want) { + t.Fatalf("after re-wrap = %+v, want %+v", *got, want) } - if after.URL != "http://piri/enc" || string(after.BaseNonce) != "nonce07" || after.ChunkSize != 65536 { - t.Fatalf("re-wrap disturbed non-wrap fields: %+v", after) +} + +// Every parameter is required: a row missing any one of them could not be +// decrypted with, so PutEncryptionParams rejects it and stores nothing. +func TestEncryptionParams_IncompleteRejected(t *testing.T) { + space := testutil.RandomDID(t) + digest := []byte("enc-digest") + + cases := []struct { + name string + mutate func(*registry.BlobEncryptionParams) + }{ + {"no space", func(p *registry.BlobEncryptionParams) { p.Space = did.Undef }}, + {"no digest", func(p *registry.BlobEncryptionParams) { p.Digest = nil }}, + {"no wrapped CEK", func(p *registry.BlobEncryptionParams) { p.RegionWrappedCEK = nil }}, + {"empty wrapped CEK", func(p *registry.BlobEncryptionParams) { p.RegionWrappedCEK = []byte{} }}, + {"no key version", func(p *registry.BlobEncryptionParams) { p.RegionKeyVersion = "" }}, + {"no recipient kid", func(p *registry.BlobEncryptionParams) { p.TenantRecipientKID = "" }}, + {"no header length", func(p *registry.BlobEncryptionParams) { p.HeaderLen = 0 }}, + {"no base nonce", func(p *registry.BlobEncryptionParams) { p.BaseNonce = nil }}, + {"no chunk size", func(p *registry.BlobEncryptionParams) { p.ChunkSize = 0 }}, + {"no AAD", func(p *registry.BlobEncryptionParams) { p.AAD = nil }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + m := NewMemStore() + params := feeParams(space, digest) + tc.mutate(¶ms) + + if err := m.PutEncryptionParams(ctx, params); !errors.Is(err, registry.ErrInvalidEncryptionParams) { + t.Fatalf("PutEncryptionParams err = %v, want ErrInvalidEncryptionParams", err) + } + if _, err := m.GetEncryptionParams(ctx, space, digest); !errors.Is(err, registry.ErrNotFound) { + t.Fatalf("incomplete params leaked a row") + } + }) } } -// TestLocations_PartialFEE_Rejected verifies the all-or-nothing FEE invariant: -// a location with some but not all wrap material is rejected with ErrPartialFEE -// and never persisted, while a fully-absent (unencrypted) row is accepted. -func TestLocations_PartialFEE_Rejected(t *testing.T) { +// The two tables have independent lifecycles and no cascade between them: +// deleting a location leaves the encryption parameters in place, which is why a +// caller removing a blob must delete both. +func TestEncryptionParams_IndependentOfLocation(t *testing.T) { ctx := context.Background() m := NewMemStore() space := testutil.RandomDID(t) - base := registry.BlobLocation{Space: space, Digest: []byte("d"), Provider: "did:piri", URL: "u", Size: 10} - - partials := map[string]func(*registry.BlobLocation){ - "only wrapped CEK": func(l *registry.BlobLocation) { l.RegionWrappedCEK = []byte("cek") }, - "only key version": func(l *registry.BlobLocation) { l.RegionKeyVersion = "v1" }, - "only chunk size": func(l *registry.BlobLocation) { l.ChunkSize = 4096 }, - "missing protected": func(l *registry.BlobLocation) { - l.RegionWrappedCEK, l.RegionKeyVersion, l.TenantRecipientKID = []byte("cek"), "v1", "kid" - l.BaseNonce, l.ChunkSize = []byte("nonce07"), 4096 // ProtectedHeader left empty - }, - } - for name, mutate := range partials { - loc := base - mutate(&loc) - if err := m.PutLocation(ctx, loc); !errors.Is(err, registry.ErrPartialFEE) { - t.Fatalf("PutLocation(%s) err = %v, want ErrPartialFEE", name, err) - } - // Nothing should have been stored. - if _, err := m.GetLocation(ctx, space, []byte("d")); !errors.Is(err, registry.ErrNotFound) { - t.Fatalf("partial FEE (%s) leaked a row", name) - } - } - - // Fully absent (unencrypted) is accepted. - if err := m.PutLocation(ctx, base); err != nil { - t.Fatalf("PutLocation(unencrypted): %v", err) + digest := []byte("enc-digest") + if err := m.PutLocation(ctx, registry.BlobLocation{Space: space, Digest: digest, Provider: "did:piri:1", URL: "http://piri/enc", Size: 4096}); err != nil { + t.Fatalf("PutLocation: %v", err) + } + if err := m.PutEncryptionParams(ctx, feeParams(space, digest)); err != nil { + t.Fatalf("PutEncryptionParams: %v", err) + } + + if err := m.DeleteLocation(ctx, space, digest); err != nil { + t.Fatalf("DeleteLocation: %v", err) + } + + if _, err := m.GetEncryptionParams(ctx, space, digest); err != nil { + t.Fatalf("DeleteLocation shredded the encryption params: %v", err) } } diff --git a/migrations/sql/00013_blob_encryption.sql b/migrations/sql/00013_blob_encryption.sql index 26d7dbe..b60e4b3 100644 --- a/migrations/sql/00013_blob_encryption.sql +++ b/migrations/sql/00013_blob_encryption.sql @@ -1,32 +1,56 @@ -- +goose Up --- FEE (FilOne encryption envelope) per-blob wrap material, added to --- ingot.blob_locations. When Ingot encrypts an object's body, each body blob is --- stored as an independent COSE/STREAM ciphertext envelope; a range GET must be --- able to decrypt any byte span of that envelope WITHOUT first fetching and --- parsing its header. These columns cache exactly the inputs the read path's --- aesstream decryptor needs, so a read unwraps the CEK (under the region KEK) --- and goes straight to a body-range fetch — no envelope-header round-trip. +-- FEE (FilOne encryption envelope) per-blob encryption parameters. When Ingot +-- encrypts an object's body, each body blob is stored as an independent +-- COSE/STREAM ciphertext envelope; a range GET must be able to decrypt any byte +-- span of that envelope WITHOUT first fetching and parsing its header. A row +-- here caches exactly the inputs the read path's decryptor needs, so a read +-- unwraps the CEK (under the region KEK) and goes straight to a body-range +-- fetch — no envelope-header round-trip. -- --- All columns are nullable. An unencrypted blob carries a location row with --- these columns NULL. Raw CEK bytes are never stored — only the CEK wrapped --- under the region KEK (region_wrapped_cek) plus the key-version / recipient --- identifiers needed to unwrap it. Per-blob crypto-shred is nulling these --- columns (or deleting the row) — no separate mechanism. Re-wrap under a --- rotated region key updates region_wrapped_cek and region_key_version in --- place. -ALTER TABLE ingot.blob_locations - ADD COLUMN region_wrapped_cek bytea, -- CEK wrapped under the region KEK (A256KW) - ADD COLUMN region_key_version text, -- opaque id of the region KEK version used (rotation re-wrap) - ADD COLUMN tenant_recipient_kid text, -- opaque id of the Hilt wrap key (insurance-recovery unwrap) - ADD COLUMN base_nonce bytea, -- COSE iv: the STREAM nonce seed for this blob's ciphertext - ADD COLUMN chunk_size bigint, -- FEE chunk size from the COSE protected header - ADD COLUMN protected_header bytea; -- raw COSE protected header bytes (Enc_structure/AAD reconstruction) +-- The existence of a row is what marks a blob as encrypted, so every column is +-- NOT NULL: there is no such thing as a half-populated parameter set the +-- decrypt path could not use. An unencrypted blob simply has no row. +-- +-- Deliberately a separate table from ingot.blob_locations, and deliberately +-- WITHOUT a foreign key to it. blob_locations is a reconstructible cache of the +-- indexing-service contract — every row can be re-derived from the indexer or +-- the accept receipt, and the table goes away when the topology moves to a real +-- indexer. A wrapped CEK is not reconstructible: lose the row and the +-- ciphertext is permanently unreadable. The two therefore have independent +-- lifecycles, and an FK would additionally force location-before-parameters +-- write ordering. +-- +-- aad holds the whole COSE Enc_structure rather than just the protected header, +-- because the structure's context string differs between a COSE_Encrypt and a +-- COSE_Encrypt0 and a bare row cannot record which form was used. The protected +-- header stays recoverable from it as element 1. +-- +-- Raw CEK bytes are never stored — only the CEK wrapped under the region KEK +-- (region_wrapped_cek) plus the opaque key-version / recipient identifiers +-- needed to unwrap it. Per-blob crypto-shred is deleting the row; because there +-- is no cascade, a caller removing a blob must delete here as well as from +-- blob_locations. Re-wrap under a rotated region key is an upsert that replaces +-- region_wrapped_cek and region_key_version in place. +CREATE TABLE ingot.blob_encryption_params ( + space text NOT NULL, + digest bytea NOT NULL, -- ciphertext blob multihash, as in blob_locations + region_wrapped_cek bytea NOT NULL -- CEK wrapped under the region KEK (A256KW) + CHECK (octet_length(region_wrapped_cek) > 0), + region_key_version text NOT NULL -- opaque id of the region KEK version used (rotation re-wrap) + CHECK (region_key_version <> ''), + tenant_recipient_kid text NOT NULL -- opaque id of the Hilt wrap key (insurance-recovery unwrap) + CHECK (tenant_recipient_kid <> ''), + header_len bigint NOT NULL -- encoded envelope length; the ciphertext starts at this offset + CHECK (header_len > 0), + base_nonce bytea NOT NULL -- COSE iv: the STREAM nonce seed for this blob's ciphertext + CHECK (octet_length(base_nonce) > 0), + chunk_size bigint NOT NULL -- FEE chunk size from the COSE protected header + CHECK (chunk_size > 0), + aad bytea NOT NULL -- COSE Enc_structure, bound into every chunk's GCM tag + CHECK (octet_length(aad) > 0), + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (space, digest) +); -- +goose Down -ALTER TABLE ingot.blob_locations - DROP COLUMN protected_header, - DROP COLUMN chunk_size, - DROP COLUMN base_nonce, - DROP COLUMN tenant_recipient_kid, - DROP COLUMN region_key_version, - DROP COLUMN region_wrapped_cek; +DROP TABLE ingot.blob_encryption_params; diff --git a/migrations/up_live_test.go b/migrations/up_live_test.go index d4c8702..4a63d6f 100644 --- a/migrations/up_live_test.go +++ b/migrations/up_live_test.go @@ -42,7 +42,7 @@ func TestUp_Live(t *testing.T) { want := []string{ "buckets", "segments", "segment_op_roots", - "blob_refs", "upload_intents", "blob_locations", + "blob_refs", "upload_intents", "blob_locations", "blob_encryption_params", "multipart_sessions", "multipart_parts", "gc_candidates", } for _, tbl := range want { @@ -72,21 +72,24 @@ func TestUp_Live(t *testing.T) { } } - // The FEE wrap columns added to blob_locations (00013) exist and are nullable. + // blob_encryption_params (00013): every column exists and is NOT NULL — the + // presence of a row is what marks a blob as encrypted, so there is no such + // thing as a half-populated parameter set. for _, col := range []string{ - "region_wrapped_cek", "region_key_version", "tenant_recipient_kid", - "base_nonce", "chunk_size", "protected_header", + "space", "digest", "region_wrapped_cek", "region_key_version", + "tenant_recipient_kid", "header_len", "base_nonce", "chunk_size", "aad", + "created_at", } { var nullable string err := pool.QueryRow(ctx, `SELECT is_nullable FROM information_schema.columns - WHERE table_schema = 'ingot' AND table_name = 'blob_locations' AND column_name = $1`, col).Scan(&nullable) + WHERE table_schema = 'ingot' AND table_name = 'blob_encryption_params' AND column_name = $1`, col).Scan(&nullable) if err != nil { - t.Errorf("column ingot.blob_locations.%s missing after migration: %v", col, err) + t.Errorf("column ingot.blob_encryption_params.%s missing after migration: %v", col, err) continue } - if nullable != "YES" { - t.Errorf("column ingot.blob_locations.%s is_nullable = %q, want YES", col, nullable) + if nullable != "NO" { + t.Errorf("column ingot.blob_encryption_params.%s is_nullable = %q, want NO", col, nullable) } } } diff --git a/registry/postgres_live_test.go b/registry/postgres_live_test.go index 852cb29..627a35f 100644 --- a/registry/postgres_live_test.go +++ b/registry/postgres_live_test.go @@ -1,10 +1,10 @@ package registry_test import ( - "bytes" "context" "errors" "os" + "reflect" "testing" "time" @@ -52,7 +52,8 @@ func TestPostgresStores_Live(t *testing.T) { } if _, err := pool.Exec(ctx, `TRUNCATE ingot.blob_refs, ingot.upload_intents, ingot.blob_locations, - ingot.multipart_sessions, ingot.multipart_parts, ingot.gc_candidates, ingot.buckets CASCADE`); err != nil { + ingot.blob_encryption_params, ingot.multipart_sessions, ingot.multipart_parts, + ingot.gc_candidates, ingot.buckets CASCADE`); err != nil { t.Fatalf("truncate: %v", err) } @@ -138,7 +139,6 @@ func TestPostgresStores_Live(t *testing.T) { }) t.Run("location round trip", func(t *testing.T) { - // Unencrypted blob: the FEE wrap columns store as NULL and read back zero. space := testutil.RandomDID(t) if err := r.PutLocation(ctx, registry.BlobLocation{Space: space, Digest: digest, Provider: "did:piri", URL: "http://piri/b", Size: 100}); err != nil { t.Fatalf("PutLocation: %v", err) @@ -147,10 +147,6 @@ func TestPostgresStores_Live(t *testing.T) { if err != nil || loc.URL != "http://piri/b" || loc.Size != 100 { t.Fatalf("GetLocation = %+v, err %v", loc, err) } - if loc.RegionWrappedCEK != nil || loc.BaseNonce != nil || loc.ProtectedHeader != nil || - loc.RegionKeyVersion != "" || loc.TenantRecipientKID != "" || loc.ChunkSize != 0 { - t.Fatalf("unencrypted location read back wrap material (NULL round-trip): %+v", loc) - } if err := r.DeleteLocation(ctx, space, digest); err != nil { t.Fatalf("DeleteLocation: %v", err) } @@ -159,66 +155,103 @@ func TestPostgresStores_Live(t *testing.T) { } }) - t.Run("location FEE wrap material", func(t *testing.T) { - // Binary bytea (with an embedded NUL) exercises real byte round-trips. - space := testutil.RandomDID(t) - encDigest := []byte{0x00, 0x01, 0x02, 0xff} - enc := registry.BlobLocation{ - Space: space, Digest: encDigest, Provider: "did:piri", URL: "http://piri/enc", Size: 4096, + // liveFEEParams is a complete parameter set whose bytea values carry + // embedded NULs and high bytes, to exercise real byte round-trips. + liveFEEParams := func(space did.DID, d []byte) registry.BlobEncryptionParams { + return registry.BlobEncryptionParams{ + Space: space, + Digest: d, RegionWrappedCEK: []byte{0x00, 0xde, 0xad, 0xbe, 0xef}, RegionKeyVersion: "region-v1", TenantRecipientKID: "did:key:tenant#wrap", + HeaderLen: 212, BaseNonce: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}, ChunkSize: 65536, - ProtectedHeader: []byte{0xa1, 0x00, 0x18, 0x20}, + AAD: []byte{0xa1, 0x00, 0x18, 0x20}, } - if err := r.PutLocation(ctx, enc); err != nil { - t.Fatalf("PutLocation (encrypted): %v", err) + } + + t.Run("encryption params round trip", func(t *testing.T) { + space := testutil.RandomDID(t) + encDigest := []byte{0x00, 0x01, 0x02, 0xff} + want := liveFEEParams(space, encDigest) + if err := r.PutEncryptionParams(ctx, want); err != nil { + t.Fatalf("PutEncryptionParams: %v", err) } - got, err := r.GetLocation(ctx, space, encDigest) + got, err := r.GetEncryptionParams(ctx, space, encDigest) if err != nil { - t.Fatalf("GetLocation (encrypted): %v", err) + t.Fatalf("GetEncryptionParams: %v", err) } - if !bytes.Equal(got.RegionWrappedCEK, enc.RegionWrappedCEK) || - !bytes.Equal(got.BaseNonce, enc.BaseNonce) || - !bytes.Equal(got.ProtectedHeader, enc.ProtectedHeader) { - t.Fatalf("bytea wrap material round-trip mismatch: %+v", got) - } - if got.RegionKeyVersion != "region-v1" || got.TenantRecipientKID != "did:key:tenant#wrap" || got.ChunkSize != 65536 { - t.Fatalf("wrap scalar round-trip mismatch: %+v", got) + if !reflect.DeepEqual(*got, want) { + t.Fatalf("GetEncryptionParams = %+v, want %+v", *got, want) } + }) - // Re-wrap in place: a PutLocation upsert swaps the CEK + key version. - got.RegionWrappedCEK = []byte{0x11, 0x22, 0x33} - got.RegionKeyVersion = "region-v2" - if err := r.PutLocation(ctx, *got); err != nil { - t.Fatalf("PutLocation (re-wrap): %v", err) + t.Run("encryption params re-wrap in place", func(t *testing.T) { + space := testutil.RandomDID(t) + encDigest := []byte{0x03, 0x04} + if err := r.PutEncryptionParams(ctx, liveFEEParams(space, encDigest)); err != nil { + t.Fatalf("PutEncryptionParams: %v", err) } - after, err := r.GetLocation(ctx, space, encDigest) - if err != nil || !bytes.Equal(after.RegionWrappedCEK, []byte{0x11, 0x22, 0x33}) || after.RegionKeyVersion != "region-v2" { - t.Fatalf("re-wrap round-trip = %+v, err %v", after, err) + want := liveFEEParams(space, encDigest) + want.RegionWrappedCEK = []byte{0x11, 0x22, 0x33} + want.RegionKeyVersion = "region-v2" + if err := r.PutEncryptionParams(ctx, want); err != nil { + t.Fatalf("PutEncryptionParams (re-wrap): %v", err) } - if after.ChunkSize != 65536 || !bytes.Equal(after.BaseNonce, enc.BaseNonce) { - t.Fatalf("re-wrap disturbed non-wrap fields: %+v", after) + got, err := r.GetEncryptionParams(ctx, space, encDigest) + if err != nil { + t.Fatalf("GetEncryptionParams: %v", err) } - if err := r.DeleteLocation(ctx, space, encDigest); err != nil { - t.Fatalf("DeleteLocation: %v", err) + if !reflect.DeepEqual(*got, want) { + t.Fatalf("after re-wrap = %+v, want %+v", *got, want) + } + }) + + t.Run("encryption params delete shreds", func(t *testing.T) { + space := testutil.RandomDID(t) + encDigest := []byte{0x05, 0x06} + if err := r.PutEncryptionParams(ctx, liveFEEParams(space, encDigest)); err != nil { + t.Fatalf("PutEncryptionParams: %v", err) + } + if err := r.DeleteEncryptionParams(ctx, space, encDigest); err != nil { + t.Fatalf("DeleteEncryptionParams: %v", err) + } + if _, err := r.GetEncryptionParams(ctx, space, encDigest); !errors.Is(err, registry.ErrNotFound) { + t.Fatalf("GetEncryptionParams after delete = %v, want ErrNotFound", err) } }) - t.Run("location partial FEE rejected", func(t *testing.T) { - // A partial wrap set is rejected before it reaches SQL and leaves no row. + t.Run("incomplete encryption params rejected", func(t *testing.T) { + // Rejected in Go, so the NOT NULL constraints are never reached. space := testutil.RandomDID(t) d := []byte{0x77} - partial := registry.BlobLocation{ - Space: space, Digest: d, Provider: "did:piri", URL: "u", Size: 10, - RegionWrappedCEK: []byte{0x01}, // the rest deliberately absent + partial := liveFEEParams(space, d) + partial.AAD = nil + if err := r.PutEncryptionParams(ctx, partial); !errors.Is(err, registry.ErrInvalidEncryptionParams) { + t.Fatalf("PutEncryptionParams(partial) = %v, want ErrInvalidEncryptionParams", err) + } + if _, err := r.GetEncryptionParams(ctx, space, d); !errors.Is(err, registry.ErrNotFound) { + t.Fatalf("incomplete params leaked a row: %v", err) + } + }) + + t.Run("encryption params independent of location", func(t *testing.T) { + // No foreign key and no cascade: the params outlive their location row, + // so a caller removing a blob must delete from both tables. + space := testutil.RandomDID(t) + encDigest := []byte{0x08, 0x09} + if err := r.PutEncryptionParams(ctx, liveFEEParams(space, encDigest)); err != nil { + t.Fatalf("PutEncryptionParams: %v", err) } - if err := r.PutLocation(ctx, partial); !errors.Is(err, registry.ErrPartialFEE) { - t.Fatalf("PutLocation(partial) = %v, want ErrPartialFEE", err) + if err := r.PutLocation(ctx, registry.BlobLocation{Space: space, Digest: encDigest, Provider: "did:piri", URL: "http://piri/enc", Size: 4096}); err != nil { + t.Fatalf("PutLocation: %v", err) + } + if err := r.DeleteLocation(ctx, space, encDigest); err != nil { + t.Fatalf("DeleteLocation: %v", err) } - if _, err := r.GetLocation(ctx, space, d); !errors.Is(err, registry.ErrNotFound) { - t.Fatalf("partial FEE leaked a row: %v", err) + if _, err := r.GetEncryptionParams(ctx, space, encDigest); err != nil { + t.Fatalf("DeleteLocation shredded the encryption params: %v", err) } }) diff --git a/registry/stores.go b/registry/stores.go index 83babd8..0fd1ebe 100644 --- a/registry/stores.go +++ b/registry/stores.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/fil-forge/ucantone/did" @@ -13,6 +14,7 @@ import ( // architecture relies on (docs/architecture.md §5–§7, Appendix C): // the reverse reference index (blob_refs), the local-store index // (upload_intents), the local blob-location table (blob_locations), the +// per-blob FEE encryption parameters (blob_encryption_params), the // multipart session/part tables, and the gc_candidates log. The stores // are split into focused interfaces so a caller can depend on only what // it needs; *Postgres and *inmem.MemStore satisfy all of them. @@ -69,78 +71,94 @@ type UploadIntent struct { Bucket string } -// BlobLocation is one row of ingot.blob_locations: the full per-blob record -// keyed by (Space, Digest) — where the blob can be retrieved from (captured at -// accept) and, when the blob is an encrypted FEE envelope, the wrap material -// the read path needs to decrypt it. -// -// The FEE fields are populated only for encrypted blobs; they are the cached -// inputs a (range) GET's aesstream decryptor needs, so a read unwraps the CEK -// and goes straight to a body-range fetch with no COSE envelope-header -// round-trip. They are all-or-nothing: an unencrypted blob leaves them zero -// (stored as SQL NULL), and PutLocation rejects a partial set via ValidateFEE -// so no row is ever persisted that the decrypt path cannot use. A fresh CEK per -// encryption event makes every ciphertext digest unique to one encryption, so -// this wrap material is a 1:1 fact about the row. Raw CEK bytes are never -// stored — only the region-KEK-wrapped CEK and the identifiers needed to unwrap -// it. See docs/architecture.md §8 and FIL-480. +// BlobLocation is one row of ingot.blob_locations: where a blob can be +// retrieved from, captured at accept. Keyed by (Space, Digest). type BlobLocation struct { Space did.DID Digest []byte Provider string URL string Size int64 +} + +// BlobEncryptionParams is one row of ingot.blob_encryption_params: the FEE +// (FilOne encryption envelope) parameters a read needs to decrypt an encrypted +// blob, keyed by (Space, Digest) like the blob's location. +// +// An encrypted body blob is an independent COSE/STREAM ciphertext envelope. +// These are the cached inputs a (range) GET's decryptor needs, so a read +// unwraps the CEK and goes straight to a body-range fetch with no COSE +// envelope-header round-trip. Existence of the row is what marks a blob as +// encrypted: every field is required, and an unencrypted blob simply has no row +// (see EncryptionParamsStore). A fresh CEK per encryption event makes every +// ciphertext digest unique to one encryption, so these parameters are a 1:1 +// fact about the blob. Raw CEK bytes are never stored — only the +// region-KEK-wrapped CEK and the identifiers needed to unwrap it. See +// docs/architecture.md §8 and FIL-480. +type BlobEncryptionParams struct { + Space did.DID + Digest []byte // RegionWrappedCEK is the content-encryption key wrapped under the region - // KEK (A256KW). Nil for an unencrypted blob; its presence marks the blob as - // an encrypted FEE envelope. Never the raw CEK. + // KEK (A256KW). Never the raw CEK. RegionWrappedCEK []byte // RegionKeyVersion identifies which version of the region KEK wrapped the // CEK, so a rotation can re-wrap in place. Opaque, so it is agnostic to the - // region-key cardinality decision (FIL-572). Empty for an unencrypted blob. + // region-key cardinality decision (FIL-572). RegionKeyVersion string // TenantRecipientKID identifies the Hilt wrap key used for insurance-recovery // unwrap. Opaque, so it is agnostic to the tenant-vs-bucket granularity - // decision (FIL-574). Empty for an unencrypted blob. + // decision (FIL-574). TenantRecipientKID string + // HeaderLen is the encoded length of the blob's COSE envelope, and so the + // offset at which its ciphertext begins. Without it a read could not locate + // byte 0 of the ciphertext without decoding the header. + HeaderLen int64 // BaseNonce is the COSE iv: the STREAM nonce seed for this blob's ciphertext. - // Nil for an unencrypted blob. BaseNonce []byte // ChunkSize is the FEE chunk size written into the COSE protected header, // cached so the read path need not fetch and decode the envelope header. - // Zero for an unencrypted blob. ChunkSize int64 - // ProtectedHeader is the raw COSE protected header bytes, cached to - // reconstruct the Enc_structure (AAD) without an envelope round-trip. Nil for - // an unencrypted blob. - ProtectedHeader []byte + // AAD is the envelope's COSE Enc_structure — the additional authenticated + // data bound into every chunk's GCM tag. Stored whole rather than as the + // protected header because the Enc_structure's context string differs + // between a COSE_Encrypt and a COSE_Encrypt0, which a bare row cannot + // record. The protected header remains recoverable from it as element 1. + AAD []byte } -// ErrPartialFEE is returned by PutLocation when a BlobLocation carries some but -// not all of the FEE wrap material — a row the decrypt path could not use. -var ErrPartialFEE = errors.New("registry: partial FEE wrap material") +// ErrInvalidEncryptionParams is returned by PutEncryptionParams when a +// BlobEncryptionParams is missing a field — a row the decrypt path could not +// use. +var ErrInvalidEncryptionParams = errors.New("registry: invalid blob encryption params") -// ValidateFEE enforces the all-or-nothing FEE invariant that BlobLocation -// documents: either every wrap field is present (non-empty byte slices, non-empty -// identifiers, and ChunkSize > 0) or none is. A partial set — e.g. a wrapped CEK -// with no nonce, or an empty (non-nil) byte slice standing in for real material — -// would persist a row a later GET cannot decrypt, so PutLocation rejects it. -func (loc BlobLocation) ValidateFEE() error { - present := 0 - for _, ok := range []bool{ - len(loc.RegionWrappedCEK) > 0, - loc.RegionKeyVersion != "", - loc.TenantRecipientKID != "", - len(loc.BaseNonce) > 0, - loc.ChunkSize > 0, - len(loc.ProtectedHeader) > 0, +// Validate enforces that every encryption parameter is present: non-empty byte +// slices and identifiers, and positive HeaderLen/ChunkSize. A partial set — e.g. +// a wrapped CEK with no nonce, or an empty (non-nil) byte slice standing in for +// real material — would persist a row a later GET cannot decrypt, so +// PutEncryptionParams rejects it before touching the store. +func (p BlobEncryptionParams) Validate() error { + var missing []string + for _, f := range []struct { + name string + present bool + }{ + {"space", p.Space.String() != ""}, + {"digest", len(p.Digest) > 0}, + {"region_wrapped_cek", len(p.RegionWrappedCEK) > 0}, + {"region_key_version", p.RegionKeyVersion != ""}, + {"tenant_recipient_kid", p.TenantRecipientKID != ""}, + {"header_len", p.HeaderLen > 0}, + {"base_nonce", len(p.BaseNonce) > 0}, + {"chunk_size", p.ChunkSize > 0}, + {"aad", len(p.AAD) > 0}, } { - if ok { - present++ + if !f.present { + missing = append(missing, f.name) } } - if present != 0 && present != 6 { - return fmt.Errorf("%w: %d of 6 fields set", ErrPartialFEE, present) + if len(missing) > 0 { + return fmt.Errorf("%w: missing %s", ErrInvalidEncryptionParams, strings.Join(missing, ", ")) } return nil } @@ -237,20 +255,33 @@ type IntentStore interface { DeleteIntent(ctx context.Context, digest []byte) error } -// LocationStore is the local blob-location table (§8, appliance topology): the -// per-blob record keyed by (space, digest), captured at accept and resolved on -// read in place of the indexing-service. Besides the provider/URL/size that -// locate the bytes, a row also carries the FEE wrap material for an encrypted -// blob (see BlobLocation) — PutLocation writes it, GetLocation reads it back. -// Per-blob crypto-shred is nulling that material (write a row with the FEE -// fields zero) or DeleteLocation; a rotation re-wrap is a PutLocation with a new -// RegionWrappedCEK/RegionKeyVersion. +// LocationStore is the local blob-location table (§8, appliance topology): +// the (space, digest) → provider/URL mapping captured at accept, resolved +// on read in place of the indexing-service. type LocationStore interface { PutLocation(ctx context.Context, loc BlobLocation) error GetLocation(ctx context.Context, space did.DID, digest []byte) (*BlobLocation, error) DeleteLocation(ctx context.Context, space did.DID, digest []byte) error } +// EncryptionParamsStore is the per-blob FEE encryption-parameter table +// (blob_encryption_params): what a read needs to decrypt an encrypted blob +// without fetching its envelope header. A blob has a row only when it is +// encrypted, so GetEncryptionParams returning ErrNotFound is the answer "this +// blob is stored as plaintext". +// +// It is deliberately separate from LocationStore, with no foreign key between +// the tables: a location is reconstructible from the indexer or the accept +// receipt, a wrapped CEK is not. Put is an upsert, so a rotation re-wrap +// replaces the material in place. Delete is the per-blob crypto-shred and is +// idempotent — and because nothing cascades, DeleteLocation does NOT shred: a +// caller removing a blob must call both. +type EncryptionParamsStore interface { + PutEncryptionParams(ctx context.Context, params BlobEncryptionParams) error + GetEncryptionParams(ctx context.Context, space did.DID, digest []byte) (*BlobEncryptionParams, error) + DeleteEncryptionParams(ctx context.Context, space did.DID, digest []byte) error +} + // BlobPark is one row of ingot.blob_parks: the persistable state of a blob // that is durable on its provider but not yet accepted (multipart's deferred // conclude, §7.2). AddTask/AcceptTask are the /blob/add and diff --git a/registry/stores_postgres.go b/registry/stores_postgres.go index 7e3e083..5dd391b 100644 --- a/registry/stores_postgres.go +++ b/registry/stores_postgres.go @@ -14,12 +14,13 @@ import ( // Compile-time assertions: *Postgres satisfies every store interface. var ( - _ BlobRefStore = (*Postgres)(nil) - _ IntentStore = (*Postgres)(nil) - _ LocationStore = (*Postgres)(nil) - _ InclusionStore = (*Postgres)(nil) - _ MultipartStore = (*Postgres)(nil) - _ GCStore = (*Postgres)(nil) + _ BlobRefStore = (*Postgres)(nil) + _ IntentStore = (*Postgres)(nil) + _ LocationStore = (*Postgres)(nil) + _ EncryptionParamsStore = (*Postgres)(nil) + _ InclusionStore = (*Postgres)(nil) + _ MultipartStore = (*Postgres)(nil) + _ GCStore = (*Postgres)(nil) ) // BlobRefStore =============================================================== @@ -146,31 +147,12 @@ func (r *Postgres) DeleteIntent(ctx context.Context, digest []byte) error { // LocationStore ============================================================== func (r *Postgres) PutLocation(ctx context.Context, loc BlobLocation) error { - // Reject a partial FEE set before it reaches storage (see ValidateFEE). - if err := loc.ValidateFEE(); err != nil { - return err - } - // The FEE columns are nullable: an unencrypted blob writes them NULL (nullBytes - // / nullString / nullInt64 map empty/zero to NULL, so an empty-but-non-nil - // slice never lands as a non-NULL empty bytea). The upsert overwrites the whole - // row state, so a rotation re-wrap is a read-modify-write of the full record. _, err := r.pool.Exec(ctx, - `INSERT INTO ingot.blob_locations - (space, digest, provider, url, size, - region_wrapped_cek, region_key_version, tenant_recipient_kid, - base_nonce, chunk_size, protected_header) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + `INSERT INTO ingot.blob_locations (space, digest, provider, url, size) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (space, digest) DO UPDATE - SET provider = EXCLUDED.provider, url = EXCLUDED.url, size = EXCLUDED.size, - region_wrapped_cek = EXCLUDED.region_wrapped_cek, - region_key_version = EXCLUDED.region_key_version, - tenant_recipient_kid = EXCLUDED.tenant_recipient_kid, - base_nonce = EXCLUDED.base_nonce, - chunk_size = EXCLUDED.chunk_size, - protected_header = EXCLUDED.protected_header`, - loc.Space, loc.Digest, loc.Provider, loc.URL, loc.Size, - nullBytes(loc.RegionWrappedCEK), nullString(loc.RegionKeyVersion), nullString(loc.TenantRecipientKID), - nullBytes(loc.BaseNonce), nullInt64(loc.ChunkSize), nullBytes(loc.ProtectedHeader)) + SET provider = EXCLUDED.provider, url = EXCLUDED.url, size = EXCLUDED.size`, + loc.Space, loc.Digest, loc.Provider, loc.URL, loc.Size) if err != nil { return fmt.Errorf("registry: put location: %w", err) } @@ -179,31 +161,16 @@ func (r *Postgres) PutLocation(ctx context.Context, loc BlobLocation) error { func (r *Postgres) GetLocation(ctx context.Context, space did.DID, digest []byte) (*BlobLocation, error) { loc := &BlobLocation{Space: space, Digest: digest} - var regionKeyVersion, tenantRecipientKID *string - var chunkSize *int64 err := r.pool.QueryRow(ctx, - `SELECT provider, url, size, - region_wrapped_cek, region_key_version, tenant_recipient_kid, - base_nonce, chunk_size, protected_header + `SELECT provider, url, size FROM ingot.blob_locations WHERE space = $1 AND digest = $2`, - space, digest).Scan(&loc.Provider, &loc.URL, &loc.Size, - &loc.RegionWrappedCEK, ®ionKeyVersion, &tenantRecipientKID, - &loc.BaseNonce, &chunkSize, &loc.ProtectedHeader) + space, digest).Scan(&loc.Provider, &loc.URL, &loc.Size) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, fmt.Errorf("registry: get location: %w", err) } - if regionKeyVersion != nil { - loc.RegionKeyVersion = *regionKeyVersion - } - if tenantRecipientKID != nil { - loc.TenantRecipientKID = *tenantRecipientKID - } - if chunkSize != nil { - loc.ChunkSize = *chunkSize - } return loc, nil } @@ -216,6 +183,63 @@ func (r *Postgres) DeleteLocation(ctx context.Context, space did.DID, digest []b return nil } +// EncryptionParamsStore ====================================================== + +func (r *Postgres) PutEncryptionParams(ctx context.Context, params BlobEncryptionParams) error { + // Every column is NOT NULL, so reject an incomplete set with a named error + // rather than a constraint violation (see BlobEncryptionParams.Validate). + if err := params.Validate(); err != nil { + return err + } + // Upsert: a rotation re-wrap replaces the material for a blob already stored. + _, err := r.pool.Exec(ctx, + `INSERT INTO ingot.blob_encryption_params + (space, digest, region_wrapped_cek, region_key_version, tenant_recipient_kid, + header_len, base_nonce, chunk_size, aad) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (space, digest) DO UPDATE + SET region_wrapped_cek = EXCLUDED.region_wrapped_cek, + region_key_version = EXCLUDED.region_key_version, + tenant_recipient_kid = EXCLUDED.tenant_recipient_kid, + header_len = EXCLUDED.header_len, + base_nonce = EXCLUDED.base_nonce, + chunk_size = EXCLUDED.chunk_size, + aad = EXCLUDED.aad`, + params.Space, params.Digest, params.RegionWrappedCEK, params.RegionKeyVersion, + params.TenantRecipientKID, params.HeaderLen, params.BaseNonce, params.ChunkSize, params.AAD) + if err != nil { + return fmt.Errorf("registry: put encryption params: %w", err) + } + return nil +} + +func (r *Postgres) GetEncryptionParams(ctx context.Context, space did.DID, digest []byte) (*BlobEncryptionParams, error) { + params := &BlobEncryptionParams{Space: space, Digest: digest} + err := r.pool.QueryRow(ctx, + `SELECT region_wrapped_cek, region_key_version, tenant_recipient_kid, + header_len, base_nonce, chunk_size, aad + FROM ingot.blob_encryption_params WHERE space = $1 AND digest = $2`, + space, digest).Scan(¶ms.RegionWrappedCEK, ¶ms.RegionKeyVersion, + ¶ms.TenantRecipientKID, ¶ms.HeaderLen, ¶ms.BaseNonce, + ¶ms.ChunkSize, ¶ms.AAD) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("registry: get encryption params: %w", err) + } + return params, nil +} + +func (r *Postgres) DeleteEncryptionParams(ctx context.Context, space did.DID, digest []byte) error { + _, err := r.pool.Exec(ctx, + `DELETE FROM ingot.blob_encryption_params WHERE space = $1 AND digest = $2`, space, digest) + if err != nil { + return fmt.Errorf("registry: delete encryption params: %w", err) + } + return nil +} + // ParkStore ================================================================== func (r *Postgres) PutPark(ctx context.Context, p BlobPark) error { @@ -532,26 +556,6 @@ func nullString(s string) *string { return &s } -// nullInt64 maps 0 to a SQL NULL so an optional bigint column stays NULL rather -// than storing 0. Used for blob_locations.chunk_size, which is 0 (absent) for an -// unencrypted blob and always positive for a FEE envelope. -func nullInt64(n int64) *int64 { - if n == 0 { - return nil - } - return &n -} - -// nullBytes maps an empty (nil or zero-length) slice to a SQL NULL so an -// optional bytea column stays NULL rather than storing a non-NULL empty value. -// Used for the FEE wrap columns, whose absence must read back as nil. -func nullBytes(b []byte) []byte { - if len(b) == 0 { - return nil - } - return b -} - func marshalMetadata(m map[string]string) ([]byte, error) { if len(m) == 0 { return nil, nil From 893e930a6af63952f30fa9e8cc947514bd12ea7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 17 Aug 2026 16:29:21 +0200 Subject: [PATCH 6/8] feat(registry): add a dedicated re-wrap method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A region-key rotation replaces the wrapped CEK and its key version and nothing else, but the only way to write that was PutEncryptionParams, which rewrites every column. The ciphertext is unchanged across a rotation, so a caller that got the nonce, chunk size, AAD or header length wrong on the re-supplied set would corrupt a decryptable row. RewrapEncryptionParams takes only the two parameters that change: - Postgres issues a plain UPDATE of the two columns. Zero rows affected means the blob has no row — not encrypted, or already shredded — so it returns ErrNotFound instead of reporting success. - ValidateRewrap rejects an empty CEK or key version, so a rotation cannot blank out the material the decrypt path needs. PutEncryptionParams keeps its upsert semantics for re-encryption, which does replace the whole set. Assisted-by: Claude:claude-opus-5 Signed-off-by: Miroslav Bajtoš --- inmem/stores.go | 17 ++++++++ inmem/stores_test.go | 76 +++++++++++++++++++++++++++++++++- registry/postgres_live_test.go | 32 +++++++++++++- registry/stores.go | 34 +++++++++++++-- registry/stores_postgres.go | 20 +++++++++ 5 files changed, 171 insertions(+), 8 deletions(-) diff --git a/inmem/stores.go b/inmem/stores.go index 3702f73..8309439 100644 --- a/inmem/stores.go +++ b/inmem/stores.go @@ -172,6 +172,23 @@ func (m *MemStore) GetEncryptionParams(_ context.Context, space did.DID, digest return &cp, nil } +func (m *MemStore) RewrapEncryptionParams(_ context.Context, space did.DID, digest, wrappedCEK []byte, keyVersion string) error { + if err := registry.ValidateRewrap(wrappedCEK, keyVersion); err != nil { + return err + } + m.mu.Lock() + defer m.mu.Unlock() + key := locKey{space, string(digest)} + params, ok := m.encParams[key] + if !ok { + return registry.ErrNotFound + } + params.RegionWrappedCEK = cloneBytes(wrappedCEK) + params.RegionKeyVersion = keyVersion + m.encParams[key] = params + return nil +} + func (m *MemStore) DeleteEncryptionParams(_ context.Context, space did.DID, digest []byte) error { m.mu.Lock() defer m.mu.Unlock() diff --git a/inmem/stores_test.go b/inmem/stores_test.go index db920cb..4138e48 100644 --- a/inmem/stores_test.go +++ b/inmem/stores_test.go @@ -373,8 +373,8 @@ func TestEncryptionParams_RewrapInPlace(t *testing.T) { want := feeParams(space, digest) want.RegionWrappedCEK = []byte("wrapped-cek-v2") want.RegionKeyVersion = "region-v2" - if err := m.PutEncryptionParams(ctx, want); err != nil { - t.Fatalf("PutEncryptionParams (re-wrap): %v", err) + if err := m.RewrapEncryptionParams(ctx, space, digest, want.RegionWrappedCEK, want.RegionKeyVersion); err != nil { + t.Fatalf("RewrapEncryptionParams: %v", err) } got, err := m.GetEncryptionParams(ctx, space, digest) @@ -386,6 +386,78 @@ func TestEncryptionParams_RewrapInPlace(t *testing.T) { } } +// Nothing to re-wrap means the blob is not encrypted (or was already shredded), +// which a rotation must hear about rather than take for success. +func TestEncryptionParams_RewrapMissingIsNotFound(t *testing.T) { + ctx := context.Background() + m := NewMemStore() + + err := m.RewrapEncryptionParams(ctx, testutil.RandomDID(t), []byte("absent"), []byte("wrapped-cek-v2"), "region-v2") + if !errors.Is(err, registry.ErrNotFound) { + t.Fatalf("RewrapEncryptionParams(absent) err = %v, want ErrNotFound", err) + } +} + +// A re-wrap may not blank out the key material the decrypt path needs. +func TestEncryptionParams_RewrapIncompleteRejected(t *testing.T) { + space := testutil.RandomDID(t) + digest := []byte("enc-digest") + + cases := map[string]struct { + wrappedCEK []byte + keyVersion string + }{ + "no wrapped CEK": {nil, "region-v2"}, + "empty wrapped CEK": {[]byte{}, "region-v2"}, + "no key version": {[]byte("wrapped-cek-v2"), ""}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + m := NewMemStore() + if err := m.PutEncryptionParams(ctx, feeParams(space, digest)); err != nil { + t.Fatalf("PutEncryptionParams: %v", err) + } + + err := m.RewrapEncryptionParams(ctx, space, digest, tc.wrappedCEK, tc.keyVersion) + if !errors.Is(err, registry.ErrInvalidEncryptionParams) { + t.Fatalf("RewrapEncryptionParams err = %v, want ErrInvalidEncryptionParams", err) + } + got, getErr := m.GetEncryptionParams(ctx, space, digest) + if getErr != nil { + t.Fatalf("GetEncryptionParams: %v", getErr) + } + if !reflect.DeepEqual(*got, feeParams(space, digest)) { + t.Fatalf("rejected re-wrap altered the row: %+v", *got) + } + }) + } +} + +// The re-wrap path must not alias the caller's key material either. +func TestEncryptionParams_RewrapNoSliceAliasing(t *testing.T) { + ctx := context.Background() + m := NewMemStore() + space := testutil.RandomDID(t) + digest := []byte("enc-digest") + if err := m.PutEncryptionParams(ctx, feeParams(space, digest)); err != nil { + t.Fatalf("PutEncryptionParams: %v", err) + } + wrappedCEK := []byte("wrapped-cek-v2") + if err := m.RewrapEncryptionParams(ctx, space, digest, wrappedCEK, "region-v2"); err != nil { + t.Fatalf("RewrapEncryptionParams: %v", err) + } + wrappedCEK[0] = 'X' + + got, err := m.GetEncryptionParams(ctx, space, digest) + if err != nil { + t.Fatalf("GetEncryptionParams: %v", err) + } + if !reflect.DeepEqual(got.RegionWrappedCEK, []byte("wrapped-cek-v2")) { + t.Fatalf("re-wrapped CEK was aliased: %q", got.RegionWrappedCEK) + } +} + // Every parameter is required: a row missing any one of them could not be // decrypted with, so PutEncryptionParams rejects it and stores nothing. func TestEncryptionParams_IncompleteRejected(t *testing.T) { diff --git a/registry/postgres_live_test.go b/registry/postgres_live_test.go index 627a35f..dad766a 100644 --- a/registry/postgres_live_test.go +++ b/registry/postgres_live_test.go @@ -196,8 +196,8 @@ func TestPostgresStores_Live(t *testing.T) { want := liveFEEParams(space, encDigest) want.RegionWrappedCEK = []byte{0x11, 0x22, 0x33} want.RegionKeyVersion = "region-v2" - if err := r.PutEncryptionParams(ctx, want); err != nil { - t.Fatalf("PutEncryptionParams (re-wrap): %v", err) + if err := r.RewrapEncryptionParams(ctx, space, encDigest, want.RegionWrappedCEK, want.RegionKeyVersion); err != nil { + t.Fatalf("RewrapEncryptionParams: %v", err) } got, err := r.GetEncryptionParams(ctx, space, encDigest) if err != nil { @@ -208,6 +208,34 @@ func TestPostgresStores_Live(t *testing.T) { } }) + t.Run("re-wrap of an absent row is not found", func(t *testing.T) { + // The UPDATE matches nothing, which RowsAffected turns into ErrNotFound. + space := testutil.RandomDID(t) + err := r.RewrapEncryptionParams(ctx, space, []byte{0x0a, 0x0b}, []byte{0x11}, "region-v2") + if !errors.Is(err, registry.ErrNotFound) { + t.Fatalf("RewrapEncryptionParams(absent) = %v, want ErrNotFound", err) + } + }) + + t.Run("incomplete re-wrap rejected", func(t *testing.T) { + space := testutil.RandomDID(t) + encDigest := []byte{0x0c, 0x0d} + want := liveFEEParams(space, encDigest) + if err := r.PutEncryptionParams(ctx, want); err != nil { + t.Fatalf("PutEncryptionParams: %v", err) + } + if err := r.RewrapEncryptionParams(ctx, space, encDigest, nil, "region-v2"); !errors.Is(err, registry.ErrInvalidEncryptionParams) { + t.Fatalf("RewrapEncryptionParams(no CEK) = %v, want ErrInvalidEncryptionParams", err) + } + got, err := r.GetEncryptionParams(ctx, space, encDigest) + if err != nil { + t.Fatalf("GetEncryptionParams: %v", err) + } + if !reflect.DeepEqual(*got, want) { + t.Fatalf("rejected re-wrap altered the row: %+v", *got) + } + }) + t.Run("encryption params delete shreds", func(t *testing.T) { space := testutil.RandomDID(t) encDigest := []byte{0x05, 0x06} diff --git a/registry/stores.go b/registry/stores.go index 0fd1ebe..04da7a6 100644 --- a/registry/stores.go +++ b/registry/stores.go @@ -163,6 +163,23 @@ func (p BlobEncryptionParams) Validate() error { return nil } +// ValidateRewrap enforces the two parameters a re-wrap replaces, so a rotation +// cannot blank out the material the decrypt path needs. Every +// EncryptionParamsStore implementation calls it from RewrapEncryptionParams. +func ValidateRewrap(wrappedCEK []byte, keyVersion string) error { + var missing []string + if len(wrappedCEK) == 0 { + missing = append(missing, "region_wrapped_cek") + } + if keyVersion == "" { + missing = append(missing, "region_key_version") + } + if len(missing) > 0 { + return fmt.Errorf("%w: missing %s", ErrInvalidEncryptionParams, strings.Join(missing, ", ")) + } + return nil +} + // BlobInclusion is one row of ingot.shard_inclusions: block Digest lives at // the inclusive byte range [RangeStart, RangeEnd] inside the shard CAR whose // own location is the (Space, ShardDigest) row of blob_locations. It is the @@ -272,14 +289,23 @@ type LocationStore interface { // // It is deliberately separate from LocationStore, with no foreign key between // the tables: a location is reconstructible from the indexer or the accept -// receipt, a wrapped CEK is not. Put is an upsert, so a rotation re-wrap -// replaces the material in place. Delete is the per-blob crypto-shred and is -// idempotent — and because nothing cascades, DeleteLocation does NOT shred: a -// caller removing a blob must call both. +// receipt, a wrapped CEK is not. Put is an upsert, so re-encrypting a blob +// replaces its whole parameter set; a region-key rotation instead calls +// RewrapEncryptionParams, which touches only the key material. Delete is the +// per-blob crypto-shred and is idempotent — and because nothing cascades, +// DeleteLocation does NOT shred: a caller removing a blob must call both. type EncryptionParamsStore interface { PutEncryptionParams(ctx context.Context, params BlobEncryptionParams) error GetEncryptionParams(ctx context.Context, space did.DID, digest []byte) (*BlobEncryptionParams, error) DeleteEncryptionParams(ctx context.Context, space did.DID, digest []byte) error + // RewrapEncryptionParams replaces the wrapped CEK and its key version for an + // already-encrypted blob, leaving every other parameter untouched — the write + // a region-key rotation performs. The ciphertext is unchanged, so the nonce, + // chunk size, AAD and header length must survive the rotation, and a rotation + // that rewrote them would corrupt the row. Returns ErrNotFound when the blob + // has no row (nothing to re-wrap) and ErrInvalidEncryptionParams when either + // argument is empty. + RewrapEncryptionParams(ctx context.Context, space did.DID, digest, wrappedCEK []byte, keyVersion string) error } // BlobPark is one row of ingot.blob_parks: the persistable state of a blob diff --git a/registry/stores_postgres.go b/registry/stores_postgres.go index 5dd391b..e0f1396 100644 --- a/registry/stores_postgres.go +++ b/registry/stores_postgres.go @@ -231,6 +231,26 @@ func (r *Postgres) GetEncryptionParams(ctx context.Context, space did.DID, diges return params, nil } +func (r *Postgres) RewrapEncryptionParams(ctx context.Context, space did.DID, digest, wrappedCEK []byte, keyVersion string) error { + if err := ValidateRewrap(wrappedCEK, keyVersion); err != nil { + return err + } + tag, err := r.pool.Exec(ctx, + `UPDATE ingot.blob_encryption_params + SET region_wrapped_cek = $3, region_key_version = $4 + WHERE space = $1 AND digest = $2`, + space, digest, wrappedCEK, keyVersion) + if err != nil { + return fmt.Errorf("registry: rewrap encryption params: %w", err) + } + // An UPDATE of no rows is a rotation aimed at a blob that is not encrypted (or + // was already shredded); surface it rather than reporting success. + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + func (r *Postgres) DeleteEncryptionParams(ctx context.Context, space did.DID, digest []byte) error { _, err := r.pool.Exec(ctx, `DELETE FROM ingot.blob_encryption_params WHERE space = $1 AND digest = $2`, space, digest) From d666f5f912e548696fdb232d10f6e751b6429137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 18 Aug 2026 19:32:04 +0200 Subject: [PATCH 7/8] stop storing RegionWrappedCEK and RegionKeyVersion in DB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We will store keys in OpenBao. Signed-off-by: Miroslav Bajtoš --- inmem/stores.go | 23 +---- inmem/stores_test.go | 111 +---------------------- migrations/sql/00013_blob_encryption.sql | 25 ++--- migrations/up_live_test.go | 5 +- registry/postgres_live_test.go | 53 +---------- registry/stores.go | 57 +++--------- registry/stores_postgres.go | 43 ++------- 7 files changed, 38 insertions(+), 279 deletions(-) diff --git a/inmem/stores.go b/inmem/stores.go index 8309439..c2ff62d 100644 --- a/inmem/stores.go +++ b/inmem/stores.go @@ -172,23 +172,6 @@ func (m *MemStore) GetEncryptionParams(_ context.Context, space did.DID, digest return &cp, nil } -func (m *MemStore) RewrapEncryptionParams(_ context.Context, space did.DID, digest, wrappedCEK []byte, keyVersion string) error { - if err := registry.ValidateRewrap(wrappedCEK, keyVersion); err != nil { - return err - } - m.mu.Lock() - defer m.mu.Unlock() - key := locKey{space, string(digest)} - params, ok := m.encParams[key] - if !ok { - return registry.ErrNotFound - } - params.RegionWrappedCEK = cloneBytes(wrappedCEK) - params.RegionKeyVersion = keyVersion - m.encParams[key] = params - return nil -} - func (m *MemStore) DeleteEncryptionParams(_ context.Context, space did.DID, digest []byte) error { m.mu.Lock() defer m.mu.Unlock() @@ -439,12 +422,10 @@ func cloneLocation(loc registry.BlobLocation) registry.BlobLocation { return loc } -// cloneEncryptionParams deep-copies a BlobEncryptionParams' byte-slice fields — -// the digest and the key material — so the stored copy and any returned copy -// never alias the caller's slices. +// cloneEncryptionParams deep-copies a BlobEncryptionParams' byte-slice fields +// so the stored copy and any returned copy never alias the caller's slices. func cloneEncryptionParams(p registry.BlobEncryptionParams) registry.BlobEncryptionParams { p.Digest = cloneBytes(p.Digest) - p.RegionWrappedCEK = cloneBytes(p.RegionWrappedCEK) p.BaseNonce = cloneBytes(p.BaseNonce) p.AAD = cloneBytes(p.AAD) return p diff --git a/inmem/stores_test.go b/inmem/stores_test.go index 4138e48..49c9d5e 100644 --- a/inmem/stores_test.go +++ b/inmem/stores_test.go @@ -261,8 +261,6 @@ func feeParams(space did.DID, digest []byte) registry.BlobEncryptionParams { return registry.BlobEncryptionParams{ Space: space, Digest: digest, - RegionWrappedCEK: []byte("wrapped-cek"), - RegionKeyVersion: "region-v1", TenantRecipientKID: "did:key:tenant#wrap", HeaderLen: 212, BaseNonce: []byte("nonce07"), @@ -302,7 +300,7 @@ func TestEncryptionParams_MissingIsNotFound(t *testing.T) { } } -func TestEncryptionParams_DeleteShreds(t *testing.T) { +func TestEncryptionParams_DeleteRemovesRow(t *testing.T) { ctx := context.Background() m := NewMemStore() space := testutil.RandomDID(t) @@ -328,7 +326,7 @@ func TestEncryptionParams_DeleteIsIdempotent(t *testing.T) { } } -// The store must not alias the caller's key material, nor let a caller reach +// The store must not alias the caller's byte slices, nor let a caller reach // back into it through a returned copy. func TestEncryptionParams_NoSliceAliasing(t *testing.T) { ctx := context.Background() @@ -340,7 +338,6 @@ func TestEncryptionParams_NoSliceAliasing(t *testing.T) { if err := m.PutEncryptionParams(ctx, params); err != nil { t.Fatalf("PutEncryptionParams: %v", err) } - params.RegionWrappedCEK[0] = 'X' params.BaseNonce[0] = 'X' params.AAD[0] = 'X' @@ -348,7 +345,7 @@ func TestEncryptionParams_NoSliceAliasing(t *testing.T) { if err != nil { t.Fatalf("GetEncryptionParams: %v", err) } - got.RegionWrappedCEK[0] = 'Y' + got.BaseNonce[0] = 'Y' again, err := m.GetEncryptionParams(ctx, space, digest) if err != nil { @@ -359,105 +356,6 @@ func TestEncryptionParams_NoSliceAliasing(t *testing.T) { } } -// A region-key rotation re-wraps in place: same blob, new CEK + key version, -// every other parameter untouched. -func TestEncryptionParams_RewrapInPlace(t *testing.T) { - ctx := context.Background() - m := NewMemStore() - space := testutil.RandomDID(t) - digest := []byte("enc-digest") - if err := m.PutEncryptionParams(ctx, feeParams(space, digest)); err != nil { - t.Fatalf("PutEncryptionParams: %v", err) - } - - want := feeParams(space, digest) - want.RegionWrappedCEK = []byte("wrapped-cek-v2") - want.RegionKeyVersion = "region-v2" - if err := m.RewrapEncryptionParams(ctx, space, digest, want.RegionWrappedCEK, want.RegionKeyVersion); err != nil { - t.Fatalf("RewrapEncryptionParams: %v", err) - } - - got, err := m.GetEncryptionParams(ctx, space, digest) - if err != nil { - t.Fatalf("GetEncryptionParams: %v", err) - } - if !reflect.DeepEqual(*got, want) { - t.Fatalf("after re-wrap = %+v, want %+v", *got, want) - } -} - -// Nothing to re-wrap means the blob is not encrypted (or was already shredded), -// which a rotation must hear about rather than take for success. -func TestEncryptionParams_RewrapMissingIsNotFound(t *testing.T) { - ctx := context.Background() - m := NewMemStore() - - err := m.RewrapEncryptionParams(ctx, testutil.RandomDID(t), []byte("absent"), []byte("wrapped-cek-v2"), "region-v2") - if !errors.Is(err, registry.ErrNotFound) { - t.Fatalf("RewrapEncryptionParams(absent) err = %v, want ErrNotFound", err) - } -} - -// A re-wrap may not blank out the key material the decrypt path needs. -func TestEncryptionParams_RewrapIncompleteRejected(t *testing.T) { - space := testutil.RandomDID(t) - digest := []byte("enc-digest") - - cases := map[string]struct { - wrappedCEK []byte - keyVersion string - }{ - "no wrapped CEK": {nil, "region-v2"}, - "empty wrapped CEK": {[]byte{}, "region-v2"}, - "no key version": {[]byte("wrapped-cek-v2"), ""}, - } - for name, tc := range cases { - t.Run(name, func(t *testing.T) { - ctx := context.Background() - m := NewMemStore() - if err := m.PutEncryptionParams(ctx, feeParams(space, digest)); err != nil { - t.Fatalf("PutEncryptionParams: %v", err) - } - - err := m.RewrapEncryptionParams(ctx, space, digest, tc.wrappedCEK, tc.keyVersion) - if !errors.Is(err, registry.ErrInvalidEncryptionParams) { - t.Fatalf("RewrapEncryptionParams err = %v, want ErrInvalidEncryptionParams", err) - } - got, getErr := m.GetEncryptionParams(ctx, space, digest) - if getErr != nil { - t.Fatalf("GetEncryptionParams: %v", getErr) - } - if !reflect.DeepEqual(*got, feeParams(space, digest)) { - t.Fatalf("rejected re-wrap altered the row: %+v", *got) - } - }) - } -} - -// The re-wrap path must not alias the caller's key material either. -func TestEncryptionParams_RewrapNoSliceAliasing(t *testing.T) { - ctx := context.Background() - m := NewMemStore() - space := testutil.RandomDID(t) - digest := []byte("enc-digest") - if err := m.PutEncryptionParams(ctx, feeParams(space, digest)); err != nil { - t.Fatalf("PutEncryptionParams: %v", err) - } - wrappedCEK := []byte("wrapped-cek-v2") - if err := m.RewrapEncryptionParams(ctx, space, digest, wrappedCEK, "region-v2"); err != nil { - t.Fatalf("RewrapEncryptionParams: %v", err) - } - wrappedCEK[0] = 'X' - - got, err := m.GetEncryptionParams(ctx, space, digest) - if err != nil { - t.Fatalf("GetEncryptionParams: %v", err) - } - if !reflect.DeepEqual(got.RegionWrappedCEK, []byte("wrapped-cek-v2")) { - t.Fatalf("re-wrapped CEK was aliased: %q", got.RegionWrappedCEK) - } -} - // Every parameter is required: a row missing any one of them could not be // decrypted with, so PutEncryptionParams rejects it and stores nothing. func TestEncryptionParams_IncompleteRejected(t *testing.T) { @@ -470,9 +368,6 @@ func TestEncryptionParams_IncompleteRejected(t *testing.T) { }{ {"no space", func(p *registry.BlobEncryptionParams) { p.Space = did.Undef }}, {"no digest", func(p *registry.BlobEncryptionParams) { p.Digest = nil }}, - {"no wrapped CEK", func(p *registry.BlobEncryptionParams) { p.RegionWrappedCEK = nil }}, - {"empty wrapped CEK", func(p *registry.BlobEncryptionParams) { p.RegionWrappedCEK = []byte{} }}, - {"no key version", func(p *registry.BlobEncryptionParams) { p.RegionKeyVersion = "" }}, {"no recipient kid", func(p *registry.BlobEncryptionParams) { p.TenantRecipientKID = "" }}, {"no header length", func(p *registry.BlobEncryptionParams) { p.HeaderLen = 0 }}, {"no base nonce", func(p *registry.BlobEncryptionParams) { p.BaseNonce = nil }}, diff --git a/migrations/sql/00013_blob_encryption.sql b/migrations/sql/00013_blob_encryption.sql index b60e4b3..27f2565 100644 --- a/migrations/sql/00013_blob_encryption.sql +++ b/migrations/sql/00013_blob_encryption.sql @@ -4,8 +4,8 @@ -- COSE/STREAM ciphertext envelope; a range GET must be able to decrypt any byte -- span of that envelope WITHOUT first fetching and parsing its header. A row -- here caches exactly the inputs the read path's decryptor needs, so a read --- unwraps the CEK (under the region KEK) and goes straight to a body-range --- fetch — no envelope-header round-trip. +-- unwraps the CEK (held in OpenBao, under the region KEK) and goes straight to +-- a body-range fetch — no envelope-header round-trip. -- -- The existence of a row is what marks a blob as encrypted, so every column is -- NOT NULL: there is no such thing as a half-populated parameter set the @@ -15,29 +15,22 @@ -- WITHOUT a foreign key to it. blob_locations is a reconstructible cache of the -- indexing-service contract — every row can be re-derived from the indexer or -- the accept receipt, and the table goes away when the topology moves to a real --- indexer. A wrapped CEK is not reconstructible: lose the row and the --- ciphertext is permanently unreadable. The two therefore have independent --- lifecycles, and an FK would additionally force location-before-parameters --- write ordering. +-- indexer. A row here is instead the marker that a blob is encrypted, so the +-- two have independent lifecycles, and an FK would additionally force +-- location-before-parameters write ordering. -- -- aad holds the whole COSE Enc_structure rather than just the protected header, -- because the structure's context string differs between a COSE_Encrypt and a -- COSE_Encrypt0 and a bare row cannot record which form was used. The protected -- header stays recoverable from it as element 1. -- --- Raw CEK bytes are never stored — only the CEK wrapped under the region KEK --- (region_wrapped_cek) plus the opaque key-version / recipient identifiers --- needed to unwrap it. Per-blob crypto-shred is deleting the row; because there --- is no cascade, a caller removing a blob must delete here as well as from --- blob_locations. Re-wrap under a rotated region key is an upsert that replaces --- region_wrapped_cek and region_key_version in place. +-- No key material is stored here: the region-KEK-wrapped CEK and its key +-- version live in OpenBao. Per-blob crypto-shred is deleting the key there; +-- deleting this row only drops the cached decrypt parameters. Because there is no cascade, a caller removing a +-- blob must delete here as well as from blob_locations. CREATE TABLE ingot.blob_encryption_params ( space text NOT NULL, digest bytea NOT NULL, -- ciphertext blob multihash, as in blob_locations - region_wrapped_cek bytea NOT NULL -- CEK wrapped under the region KEK (A256KW) - CHECK (octet_length(region_wrapped_cek) > 0), - region_key_version text NOT NULL -- opaque id of the region KEK version used (rotation re-wrap) - CHECK (region_key_version <> ''), tenant_recipient_kid text NOT NULL -- opaque id of the Hilt wrap key (insurance-recovery unwrap) CHECK (tenant_recipient_kid <> ''), header_len bigint NOT NULL -- encoded envelope length; the ciphertext starts at this offset diff --git a/migrations/up_live_test.go b/migrations/up_live_test.go index 4a63d6f..c220d9d 100644 --- a/migrations/up_live_test.go +++ b/migrations/up_live_test.go @@ -76,9 +76,8 @@ func TestUp_Live(t *testing.T) { // presence of a row is what marks a blob as encrypted, so there is no such // thing as a half-populated parameter set. for _, col := range []string{ - "space", "digest", "region_wrapped_cek", "region_key_version", - "tenant_recipient_kid", "header_len", "base_nonce", "chunk_size", "aad", - "created_at", + "space", "digest", "tenant_recipient_kid", "header_len", "base_nonce", + "chunk_size", "aad", "created_at", } { var nullable string err := pool.QueryRow(ctx, diff --git a/registry/postgres_live_test.go b/registry/postgres_live_test.go index 94428ff..283d850 100644 --- a/registry/postgres_live_test.go +++ b/registry/postgres_live_test.go @@ -164,8 +164,6 @@ func TestPostgresStores_Live(t *testing.T) { return registry.BlobEncryptionParams{ Space: space, Digest: d, - RegionWrappedCEK: []byte{0x00, 0xde, 0xad, 0xbe, 0xef}, - RegionKeyVersion: "region-v1", TenantRecipientKID: "did:key:tenant#wrap", HeaderLen: 212, BaseNonce: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}, @@ -190,56 +188,7 @@ func TestPostgresStores_Live(t *testing.T) { } }) - t.Run("encryption params re-wrap in place", func(t *testing.T) { - space := testutil.RandomDID(t) - encDigest := []byte{0x03, 0x04} - if err := r.PutEncryptionParams(ctx, liveFEEParams(space, encDigest)); err != nil { - t.Fatalf("PutEncryptionParams: %v", err) - } - want := liveFEEParams(space, encDigest) - want.RegionWrappedCEK = []byte{0x11, 0x22, 0x33} - want.RegionKeyVersion = "region-v2" - if err := r.RewrapEncryptionParams(ctx, space, encDigest, want.RegionWrappedCEK, want.RegionKeyVersion); err != nil { - t.Fatalf("RewrapEncryptionParams: %v", err) - } - got, err := r.GetEncryptionParams(ctx, space, encDigest) - if err != nil { - t.Fatalf("GetEncryptionParams: %v", err) - } - if !reflect.DeepEqual(*got, want) { - t.Fatalf("after re-wrap = %+v, want %+v", *got, want) - } - }) - - t.Run("re-wrap of an absent row is not found", func(t *testing.T) { - // The UPDATE matches nothing, which RowsAffected turns into ErrNotFound. - space := testutil.RandomDID(t) - err := r.RewrapEncryptionParams(ctx, space, []byte{0x0a, 0x0b}, []byte{0x11}, "region-v2") - if !errors.Is(err, registry.ErrNotFound) { - t.Fatalf("RewrapEncryptionParams(absent) = %v, want ErrNotFound", err) - } - }) - - t.Run("incomplete re-wrap rejected", func(t *testing.T) { - space := testutil.RandomDID(t) - encDigest := []byte{0x0c, 0x0d} - want := liveFEEParams(space, encDigest) - if err := r.PutEncryptionParams(ctx, want); err != nil { - t.Fatalf("PutEncryptionParams: %v", err) - } - if err := r.RewrapEncryptionParams(ctx, space, encDigest, nil, "region-v2"); !errors.Is(err, registry.ErrInvalidEncryptionParams) { - t.Fatalf("RewrapEncryptionParams(no CEK) = %v, want ErrInvalidEncryptionParams", err) - } - got, err := r.GetEncryptionParams(ctx, space, encDigest) - if err != nil { - t.Fatalf("GetEncryptionParams: %v", err) - } - if !reflect.DeepEqual(*got, want) { - t.Fatalf("rejected re-wrap altered the row: %+v", *got) - } - }) - - t.Run("encryption params delete shreds", func(t *testing.T) { + t.Run("encryption params delete removes the row", func(t *testing.T) { space := testutil.RandomDID(t) encDigest := []byte{0x05, 0x06} if err := r.PutEncryptionParams(ctx, liveFEEParams(space, encDigest)); err != nil { diff --git a/registry/stores.go b/registry/stores.go index 04da7a6..b10cc7f 100644 --- a/registry/stores.go +++ b/registry/stores.go @@ -92,20 +92,12 @@ type BlobLocation struct { // encrypted: every field is required, and an unencrypted blob simply has no row // (see EncryptionParamsStore). A fresh CEK per encryption event makes every // ciphertext digest unique to one encryption, so these parameters are a 1:1 -// fact about the blob. Raw CEK bytes are never stored — only the -// region-KEK-wrapped CEK and the identifiers needed to unwrap it. See -// docs/architecture.md §8 and FIL-480. +// fact about the blob. No key material is stored here: the wrapped CEK and its +// region-KEK version live in OpenBao. See docs/architecture.md §8 and FIL-480. type BlobEncryptionParams struct { Space did.DID Digest []byte - // RegionWrappedCEK is the content-encryption key wrapped under the region - // KEK (A256KW). Never the raw CEK. - RegionWrappedCEK []byte - // RegionKeyVersion identifies which version of the region KEK wrapped the - // CEK, so a rotation can re-wrap in place. Opaque, so it is agnostic to the - // region-key cardinality decision (FIL-572). - RegionKeyVersion string // TenantRecipientKID identifies the Hilt wrap key used for insurance-recovery // unwrap. Opaque, so it is agnostic to the tenant-vs-bucket granularity // decision (FIL-574). @@ -134,8 +126,8 @@ var ErrInvalidEncryptionParams = errors.New("registry: invalid blob encryption p // Validate enforces that every encryption parameter is present: non-empty byte // slices and identifiers, and positive HeaderLen/ChunkSize. A partial set — e.g. -// a wrapped CEK with no nonce, or an empty (non-nil) byte slice standing in for -// real material — would persist a row a later GET cannot decrypt, so +// an AAD with no nonce, or an empty (non-nil) byte slice standing in for a real +// value — would persist a row a later GET cannot decrypt, so // PutEncryptionParams rejects it before touching the store. func (p BlobEncryptionParams) Validate() error { var missing []string @@ -145,8 +137,6 @@ func (p BlobEncryptionParams) Validate() error { }{ {"space", p.Space.String() != ""}, {"digest", len(p.Digest) > 0}, - {"region_wrapped_cek", len(p.RegionWrappedCEK) > 0}, - {"region_key_version", p.RegionKeyVersion != ""}, {"tenant_recipient_kid", p.TenantRecipientKID != ""}, {"header_len", p.HeaderLen > 0}, {"base_nonce", len(p.BaseNonce) > 0}, @@ -163,23 +153,6 @@ func (p BlobEncryptionParams) Validate() error { return nil } -// ValidateRewrap enforces the two parameters a re-wrap replaces, so a rotation -// cannot blank out the material the decrypt path needs. Every -// EncryptionParamsStore implementation calls it from RewrapEncryptionParams. -func ValidateRewrap(wrappedCEK []byte, keyVersion string) error { - var missing []string - if len(wrappedCEK) == 0 { - missing = append(missing, "region_wrapped_cek") - } - if keyVersion == "" { - missing = append(missing, "region_key_version") - } - if len(missing) > 0 { - return fmt.Errorf("%w: missing %s", ErrInvalidEncryptionParams, strings.Join(missing, ", ")) - } - return nil -} - // BlobInclusion is one row of ingot.shard_inclusions: block Digest lives at // the inclusive byte range [RangeStart, RangeEnd] inside the shard CAR whose // own location is the (Space, ShardDigest) row of blob_locations. It is the @@ -288,24 +261,18 @@ type LocationStore interface { // blob is stored as plaintext". // // It is deliberately separate from LocationStore, with no foreign key between -// the tables: a location is reconstructible from the indexer or the accept -// receipt, a wrapped CEK is not. Put is an upsert, so re-encrypting a blob -// replaces its whole parameter set; a region-key rotation instead calls -// RewrapEncryptionParams, which touches only the key material. Delete is the -// per-blob crypto-shred and is idempotent — and because nothing cascades, -// DeleteLocation does NOT shred: a caller removing a blob must call both. +// the tables: the row's existence is what marks a blob as encrypted, so its +// lifecycle is independent of the reconstructible location cache. No key +// material lives here — the wrapped CEK sits in OpenBao, so per-blob +// crypto-shred means deleting the key there; Delete only drops the cached +// decrypt parameters. Put is an upsert, so re-encrypting a blob replaces its +// whole parameter set. Delete is idempotent — and because nothing cascades, +// DeleteLocation does NOT touch this table: a caller removing a blob must +// call both. type EncryptionParamsStore interface { PutEncryptionParams(ctx context.Context, params BlobEncryptionParams) error GetEncryptionParams(ctx context.Context, space did.DID, digest []byte) (*BlobEncryptionParams, error) DeleteEncryptionParams(ctx context.Context, space did.DID, digest []byte) error - // RewrapEncryptionParams replaces the wrapped CEK and its key version for an - // already-encrypted blob, leaving every other parameter untouched — the write - // a region-key rotation performs. The ciphertext is unchanged, so the nonce, - // chunk size, AAD and header length must survive the rotation, and a rotation - // that rewrote them would corrupt the row. Returns ErrNotFound when the blob - // has no row (nothing to re-wrap) and ErrInvalidEncryptionParams when either - // argument is empty. - RewrapEncryptionParams(ctx context.Context, space did.DID, digest, wrappedCEK []byte, keyVersion string) error } // BlobPark is one row of ingot.blob_parks: the persistable state of a blob diff --git a/registry/stores_postgres.go b/registry/stores_postgres.go index e0f1396..a388bfd 100644 --- a/registry/stores_postgres.go +++ b/registry/stores_postgres.go @@ -191,22 +191,19 @@ func (r *Postgres) PutEncryptionParams(ctx context.Context, params BlobEncryptio if err := params.Validate(); err != nil { return err } - // Upsert: a rotation re-wrap replaces the material for a blob already stored. + // Upsert: a re-encryption replaces the parameter set for a blob already stored. _, err := r.pool.Exec(ctx, `INSERT INTO ingot.blob_encryption_params - (space, digest, region_wrapped_cek, region_key_version, tenant_recipient_kid, - header_len, base_nonce, chunk_size, aad) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + (space, digest, tenant_recipient_kid, header_len, base_nonce, chunk_size, aad) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (space, digest) DO UPDATE - SET region_wrapped_cek = EXCLUDED.region_wrapped_cek, - region_key_version = EXCLUDED.region_key_version, - tenant_recipient_kid = EXCLUDED.tenant_recipient_kid, + SET tenant_recipient_kid = EXCLUDED.tenant_recipient_kid, header_len = EXCLUDED.header_len, base_nonce = EXCLUDED.base_nonce, chunk_size = EXCLUDED.chunk_size, aad = EXCLUDED.aad`, - params.Space, params.Digest, params.RegionWrappedCEK, params.RegionKeyVersion, - params.TenantRecipientKID, params.HeaderLen, params.BaseNonce, params.ChunkSize, params.AAD) + params.Space, params.Digest, params.TenantRecipientKID, + params.HeaderLen, params.BaseNonce, params.ChunkSize, params.AAD) if err != nil { return fmt.Errorf("registry: put encryption params: %w", err) } @@ -216,12 +213,10 @@ func (r *Postgres) PutEncryptionParams(ctx context.Context, params BlobEncryptio func (r *Postgres) GetEncryptionParams(ctx context.Context, space did.DID, digest []byte) (*BlobEncryptionParams, error) { params := &BlobEncryptionParams{Space: space, Digest: digest} err := r.pool.QueryRow(ctx, - `SELECT region_wrapped_cek, region_key_version, tenant_recipient_kid, - header_len, base_nonce, chunk_size, aad + `SELECT tenant_recipient_kid, header_len, base_nonce, chunk_size, aad FROM ingot.blob_encryption_params WHERE space = $1 AND digest = $2`, - space, digest).Scan(¶ms.RegionWrappedCEK, ¶ms.RegionKeyVersion, - ¶ms.TenantRecipientKID, ¶ms.HeaderLen, ¶ms.BaseNonce, - ¶ms.ChunkSize, ¶ms.AAD) + space, digest).Scan(¶ms.TenantRecipientKID, ¶ms.HeaderLen, + ¶ms.BaseNonce, ¶ms.ChunkSize, ¶ms.AAD) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } @@ -231,26 +226,6 @@ func (r *Postgres) GetEncryptionParams(ctx context.Context, space did.DID, diges return params, nil } -func (r *Postgres) RewrapEncryptionParams(ctx context.Context, space did.DID, digest, wrappedCEK []byte, keyVersion string) error { - if err := ValidateRewrap(wrappedCEK, keyVersion); err != nil { - return err - } - tag, err := r.pool.Exec(ctx, - `UPDATE ingot.blob_encryption_params - SET region_wrapped_cek = $3, region_key_version = $4 - WHERE space = $1 AND digest = $2`, - space, digest, wrappedCEK, keyVersion) - if err != nil { - return fmt.Errorf("registry: rewrap encryption params: %w", err) - } - // An UPDATE of no rows is a rotation aimed at a blob that is not encrypted (or - // was already shredded); surface it rather than reporting success. - if tag.RowsAffected() == 0 { - return ErrNotFound - } - return nil -} - func (r *Postgres) DeleteEncryptionParams(ctx context.Context, space did.DID, digest []byte) error { _, err := r.pool.Exec(ctx, `DELETE FROM ingot.blob_encryption_params WHERE space = $1 AND digest = $2`, space, digest) From f08520e3d017560db460a288c3a509bb00f69e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 24 Aug 2026 11:58:48 +0200 Subject: [PATCH 8/8] refactor(registry): trim FEE params to the read path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review on #15. Drop tenant_recipient_kid. The table caches only what a range GET needs to skip the header fetch, and that read unwraps via the region KEK. The insurance-recovery unwrap is rare, out-of-band, and reads the envelope header anyway, where the COSE recipients already live. Drop BlobEncryptionParams.Validate. Every column is NOT NULL with a CHECK, so a half-populated set was being rejected twice. Also: FEE expands to Filecoin Encryption Envelope, not FilOne; use bytes.Clone in place of the local cloneBytes helper; untrack the worktree's .claude symlink; and drop the Linear ticket ids from the doc comments. Assisted-by: Claude:claude-opus-5 Signed-off-by: Miroslav Bajtoš --- .claude | 1 - inmem/stores.go | 55 ++++++++++-------------- inmem/stores_test.go | 48 +++------------------ migrations/sql/00014_blob_encryption.sql | 16 ++++--- migrations/up_live_test.go | 4 +- registry/postgres_live_test.go | 20 ++++----- registry/stores.go | 54 ++++------------------- registry/stores_postgres.go | 30 ++++++------- 8 files changed, 71 insertions(+), 157 deletions(-) delete mode 120000 .claude diff --git a/.claude b/.claude deleted file mode 120000 index fd7910e..0000000 --- a/.claude +++ /dev/null @@ -1 +0,0 @@ -/Users/bajtos/src/fil-forge/ingot/.claude \ No newline at end of file diff --git a/inmem/stores.go b/inmem/stores.go index 816ae6a..51f13ff 100644 --- a/inmem/stores.go +++ b/inmem/stores.go @@ -27,20 +27,13 @@ var ( _ registry.RevocationCursorStore = (*MemStore)(nil) ) -func cloneBytes(b []byte) []byte { - if b == nil { - return nil - } - return append([]byte(nil), b...) -} - // BlobRefStore =============================================================== func (m *MemStore) AddBlobClaim(_ context.Context, c registry.BlobClaim) error { m.mu.Lock() defer m.mu.Unlock() k := claimKey{string(c.Digest), c.Bucket, c.ObjectKey, c.VersionID} cp := c - cp.Digest = cloneBytes(c.Digest) + cp.Digest = bytes.Clone(c.Digest) m.blobRefs[k] = cp return nil } @@ -71,7 +64,7 @@ func (m *MemStore) PutIntent(_ context.Context, in registry.UploadIntent) error m.mu.Lock() defer m.mu.Unlock() cp := in - cp.Digest = cloneBytes(in.Digest) + cp.Digest = bytes.Clone(in.Digest) m.intents[string(in.Digest)] = cp return nil } @@ -96,7 +89,7 @@ func (m *MemStore) GetIntent(_ context.Context, digest []byte) (*registry.Upload return nil, registry.ErrNotFound } cp := in - cp.Digest = cloneBytes(in.Digest) + cp.Digest = bytes.Clone(in.Digest) return &cp, nil } @@ -109,7 +102,7 @@ func (m *MemStore) ListIntentsByState(_ context.Context, state string) ([]regist continue } cp := in - cp.Digest = cloneBytes(in.Digest) + cp.Digest = bytes.Clone(in.Digest) out = append(out, cp) } return out, nil @@ -152,10 +145,6 @@ func (m *MemStore) DeleteLocation(_ context.Context, space did.DID, digest []byt // EncryptionParamsStore ====================================================== func (m *MemStore) PutEncryptionParams(_ context.Context, params registry.BlobEncryptionParams) error { - // Match the Postgres store, whose columns are all NOT NULL. - if err := params.Validate(); err != nil { - return err - } m.mu.Lock() defer m.mu.Unlock() m.encParams[locKey{params.Space, string(params.Digest)}] = cloneEncryptionParams(params) @@ -186,10 +175,10 @@ func (m *MemStore) PutPark(_ context.Context, p registry.BlobPark) error { m.mu.Lock() defer m.mu.Unlock() cp := p - cp.Digest = cloneBytes(p.Digest) - cp.AddTask = cloneBytes(p.AddTask) - cp.AcceptTask = cloneBytes(p.AcceptTask) - cp.PutInvocation = cloneBytes(p.PutInvocation) + cp.Digest = bytes.Clone(p.Digest) + cp.AddTask = bytes.Clone(p.AddTask) + cp.AcceptTask = bytes.Clone(p.AcceptTask) + cp.PutInvocation = bytes.Clone(p.PutInvocation) m.parks[string(p.Digest)] = cp return nil } @@ -202,10 +191,10 @@ func (m *MemStore) GetPark(_ context.Context, digest []byte) (*registry.BlobPark return nil, registry.ErrNotFound } cp := park - cp.Digest = cloneBytes(park.Digest) - cp.AddTask = cloneBytes(park.AddTask) - cp.AcceptTask = cloneBytes(park.AcceptTask) - cp.PutInvocation = cloneBytes(park.PutInvocation) + cp.Digest = bytes.Clone(park.Digest) + cp.AddTask = bytes.Clone(park.AddTask) + cp.AcceptTask = bytes.Clone(park.AcceptTask) + cp.PutInvocation = bytes.Clone(park.PutInvocation) return &cp, nil } @@ -223,8 +212,8 @@ func (m *MemStore) PutInclusions(_ context.Context, incs []registry.BlobInclusio defer m.mu.Unlock() for _, inc := range incs { cp := inc - cp.Digest = cloneBytes(inc.Digest) - cp.ShardDigest = cloneBytes(inc.ShardDigest) + cp.Digest = bytes.Clone(inc.Digest) + cp.ShardDigest = bytes.Clone(inc.ShardDigest) m.inclusions[locKey{inc.Space, string(inc.Digest)}] = cp } return nil @@ -238,8 +227,8 @@ func (m *MemStore) GetInclusion(_ context.Context, space did.DID, digest []byte) return nil, registry.ErrNotFound } cp := inc - cp.Digest = cloneBytes(inc.Digest) - cp.ShardDigest = cloneBytes(inc.ShardDigest) + cp.Digest = bytes.Clone(inc.Digest) + cp.ShardDigest = bytes.Clone(inc.ShardDigest) return &cp, nil } @@ -438,25 +427,25 @@ func cloneSession(s registry.MultipartSession) registry.MultipartSession { // cloneLocation deep-copies a BlobLocation's digest so the stored copy and any // returned copy never alias the caller's slice. func cloneLocation(loc registry.BlobLocation) registry.BlobLocation { - loc.Digest = cloneBytes(loc.Digest) + loc.Digest = bytes.Clone(loc.Digest) return loc } // cloneEncryptionParams deep-copies a BlobEncryptionParams' byte-slice fields // so the stored copy and any returned copy never alias the caller's slices. func cloneEncryptionParams(p registry.BlobEncryptionParams) registry.BlobEncryptionParams { - p.Digest = cloneBytes(p.Digest) - p.BaseNonce = cloneBytes(p.BaseNonce) - p.AAD = cloneBytes(p.AAD) + p.Digest = bytes.Clone(p.Digest) + p.BaseNonce = bytes.Clone(p.BaseNonce) + p.AAD = bytes.Clone(p.AAD) return p } func clonePart(p registry.MultipartPart) registry.MultipartPart { - p.ETagMD5 = cloneBytes(p.ETagMD5) + p.ETagMD5 = bytes.Clone(p.ETagMD5) if p.BlobDigests != nil { ds := make([][]byte, len(p.BlobDigests)) for i, d := range p.BlobDigests { - ds[i] = cloneBytes(d) + ds[i] = bytes.Clone(d) } p.BlobDigests = ds } diff --git a/inmem/stores_test.go b/inmem/stores_test.go index aa92a8c..4f281b0 100644 --- a/inmem/stores_test.go +++ b/inmem/stores_test.go @@ -260,13 +260,12 @@ func TestLocations_RoundTrip(t *testing.T) { // envelope's row takes. func feeParams(space did.DID, digest []byte) registry.BlobEncryptionParams { return registry.BlobEncryptionParams{ - Space: space, - Digest: digest, - TenantRecipientKID: "did:key:tenant#wrap", - HeaderLen: 212, - BaseNonce: []byte("nonce07"), - ChunkSize: 65536, - AAD: []byte("cose-enc-structure"), + Space: space, + Digest: digest, + HeaderLen: 212, + BaseNonce: []byte("nonce07"), + ChunkSize: 65536, + AAD: []byte("cose-enc-structure"), } } @@ -357,41 +356,6 @@ func TestEncryptionParams_NoSliceAliasing(t *testing.T) { } } -// Every parameter is required: a row missing any one of them could not be -// decrypted with, so PutEncryptionParams rejects it and stores nothing. -func TestEncryptionParams_IncompleteRejected(t *testing.T) { - space := testutil.RandomDID(t) - digest := []byte("enc-digest") - - cases := []struct { - name string - mutate func(*registry.BlobEncryptionParams) - }{ - {"no space", func(p *registry.BlobEncryptionParams) { p.Space = did.Undef }}, - {"no digest", func(p *registry.BlobEncryptionParams) { p.Digest = nil }}, - {"no recipient kid", func(p *registry.BlobEncryptionParams) { p.TenantRecipientKID = "" }}, - {"no header length", func(p *registry.BlobEncryptionParams) { p.HeaderLen = 0 }}, - {"no base nonce", func(p *registry.BlobEncryptionParams) { p.BaseNonce = nil }}, - {"no chunk size", func(p *registry.BlobEncryptionParams) { p.ChunkSize = 0 }}, - {"no AAD", func(p *registry.BlobEncryptionParams) { p.AAD = nil }}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - ctx := context.Background() - m := NewMemStore() - params := feeParams(space, digest) - tc.mutate(¶ms) - - if err := m.PutEncryptionParams(ctx, params); !errors.Is(err, registry.ErrInvalidEncryptionParams) { - t.Fatalf("PutEncryptionParams err = %v, want ErrInvalidEncryptionParams", err) - } - if _, err := m.GetEncryptionParams(ctx, space, digest); !errors.Is(err, registry.ErrNotFound) { - t.Fatalf("incomplete params leaked a row") - } - }) - } -} - // The two tables have independent lifecycles and no cascade between them: // deleting a location leaves the encryption parameters in place, which is why a // caller removing a blob must delete both. diff --git a/migrations/sql/00014_blob_encryption.sql b/migrations/sql/00014_blob_encryption.sql index 27f2565..9cdda4b 100644 --- a/migrations/sql/00014_blob_encryption.sql +++ b/migrations/sql/00014_blob_encryption.sql @@ -1,5 +1,5 @@ -- +goose Up --- FEE (FilOne encryption envelope) per-blob encryption parameters. When Ingot +-- FEE (Filecoin Encryption Envelope) per-blob encryption parameters. When Ingot -- encrypts an object's body, each body blob is stored as an independent -- COSE/STREAM ciphertext envelope; a range GET must be able to decrypt any byte -- span of that envelope WITHOUT first fetching and parsing its header. A row @@ -24,15 +24,17 @@ -- COSE_Encrypt0 and a bare row cannot record which form was used. The protected -- header stays recoverable from it as element 1. -- --- No key material is stored here: the region-KEK-wrapped CEK and its key --- version live in OpenBao. Per-blob crypto-shred is deleting the key there; --- deleting this row only drops the cached decrypt parameters. Because there is no cascade, a caller removing a --- blob must delete here as well as from blob_locations. +-- Only what the region-KEK read path needs is cached. The COSE recipients, +-- including the tenant wrap key the insurance-recovery unwrap uses, stay in the +-- envelope header: that path is rare and out-of-band, and it reads the header +-- anyway. No key material is stored here either — the region-KEK-wrapped CEK +-- and its key version live in OpenBao. Per-blob crypto-shred is deleting the +-- key there; deleting this row only drops the cached decrypt parameters. +-- Because there is no cascade, a caller removing a blob must delete here as +-- well as from blob_locations. CREATE TABLE ingot.blob_encryption_params ( space text NOT NULL, digest bytea NOT NULL, -- ciphertext blob multihash, as in blob_locations - tenant_recipient_kid text NOT NULL -- opaque id of the Hilt wrap key (insurance-recovery unwrap) - CHECK (tenant_recipient_kid <> ''), header_len bigint NOT NULL -- encoded envelope length; the ciphertext starts at this offset CHECK (header_len > 0), base_nonce bytea NOT NULL -- COSE iv: the STREAM nonce seed for this blob's ciphertext diff --git a/migrations/up_live_test.go b/migrations/up_live_test.go index 5a43691..6f2e08f 100644 --- a/migrations/up_live_test.go +++ b/migrations/up_live_test.go @@ -76,8 +76,8 @@ func TestUp_Live(t *testing.T) { // presence of a row is what marks a blob as encrypted, so there is no such // thing as a half-populated parameter set. for _, col := range []string{ - "space", "digest", "tenant_recipient_kid", "header_len", "base_nonce", - "chunk_size", "aad", "created_at", + "space", "digest", "header_len", "base_nonce", "chunk_size", "aad", + "created_at", } { var nullable string err := pool.QueryRow(ctx, diff --git a/registry/postgres_live_test.go b/registry/postgres_live_test.go index 80baf75..6ea2fbf 100644 --- a/registry/postgres_live_test.go +++ b/registry/postgres_live_test.go @@ -162,13 +162,12 @@ func TestPostgresStores_Live(t *testing.T) { // embedded NULs and high bytes, to exercise real byte round-trips. liveFEEParams := func(space did.DID, d []byte) registry.BlobEncryptionParams { return registry.BlobEncryptionParams{ - Space: space, - Digest: d, - TenantRecipientKID: "did:key:tenant#wrap", - HeaderLen: 212, - BaseNonce: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}, - ChunkSize: 65536, - AAD: []byte{0xa1, 0x00, 0x18, 0x20}, + Space: space, + Digest: d, + HeaderLen: 212, + BaseNonce: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}, + ChunkSize: 65536, + AAD: []byte{0xa1, 0x00, 0x18, 0x20}, } } @@ -203,13 +202,14 @@ func TestPostgresStores_Live(t *testing.T) { }) t.Run("incomplete encryption params rejected", func(t *testing.T) { - // Rejected in Go, so the NOT NULL constraints are never reached. + // The column constraints are the invariant: a half-populated set never + // reaches the table. space := testutil.RandomDID(t) d := []byte{0x77} partial := liveFEEParams(space, d) partial.AAD = nil - if err := r.PutEncryptionParams(ctx, partial); !errors.Is(err, registry.ErrInvalidEncryptionParams) { - t.Fatalf("PutEncryptionParams(partial) = %v, want ErrInvalidEncryptionParams", err) + if err := r.PutEncryptionParams(ctx, partial); err == nil { + t.Fatal("PutEncryptionParams(partial) = nil, want a constraint error") } if _, err := r.GetEncryptionParams(ctx, space, d); !errors.Is(err, registry.ErrNotFound) { t.Fatalf("incomplete params leaked a row: %v", err) diff --git a/registry/stores.go b/registry/stores.go index 60a76cd..c7db0f4 100644 --- a/registry/stores.go +++ b/registry/stores.go @@ -2,9 +2,6 @@ package registry import ( "context" - "errors" - "fmt" - "strings" "time" "github.com/fil-forge/ucantone/did" @@ -83,8 +80,8 @@ type BlobLocation struct { } // BlobEncryptionParams is one row of ingot.blob_encryption_params: the FEE -// (FilOne encryption envelope) parameters a read needs to decrypt an encrypted -// blob, keyed by (Space, Digest) like the blob's location. +// (Filecoin Encryption Envelope) parameters a read needs to decrypt an +// encrypted blob, keyed by (Space, Digest) like the blob's location. // // An encrypted body blob is an independent COSE/STREAM ciphertext envelope. // These are the cached inputs a (range) GET's decryptor needs, so a read @@ -93,16 +90,17 @@ type BlobLocation struct { // encrypted: every field is required, and an unencrypted blob simply has no row // (see EncryptionParamsStore). A fresh CEK per encryption event makes every // ciphertext digest unique to one encryption, so these parameters are a 1:1 -// fact about the blob. No key material is stored here: the wrapped CEK and its -// region-KEK version live in OpenBao. See docs/architecture.md §8 and FIL-480. +// fact about the blob. +// +// Only what the region-KEK read path needs is cached. The COSE recipients, +// including the tenant wrap key the insurance-recovery unwrap uses, stay in the +// envelope header: that path is rare and out-of-band, and it reads the header +// anyway. No key material is stored here either — the wrapped CEK and its +// region-KEK version live in OpenBao. See docs/architecture.md §8. type BlobEncryptionParams struct { Space did.DID Digest []byte - // TenantRecipientKID identifies the Hilt wrap key used for insurance-recovery - // unwrap. Opaque, so it is agnostic to the tenant-vs-bucket granularity - // decision (FIL-574). - TenantRecipientKID string // HeaderLen is the encoded length of the blob's COSE envelope, and so the // offset at which its ciphertext begins. Without it a read could not locate // byte 0 of the ciphertext without decoding the header. @@ -120,40 +118,6 @@ type BlobEncryptionParams struct { AAD []byte } -// ErrInvalidEncryptionParams is returned by PutEncryptionParams when a -// BlobEncryptionParams is missing a field — a row the decrypt path could not -// use. -var ErrInvalidEncryptionParams = errors.New("registry: invalid blob encryption params") - -// Validate enforces that every encryption parameter is present: non-empty byte -// slices and identifiers, and positive HeaderLen/ChunkSize. A partial set — e.g. -// an AAD with no nonce, or an empty (non-nil) byte slice standing in for a real -// value — would persist a row a later GET cannot decrypt, so -// PutEncryptionParams rejects it before touching the store. -func (p BlobEncryptionParams) Validate() error { - var missing []string - for _, f := range []struct { - name string - present bool - }{ - {"space", p.Space.String() != ""}, - {"digest", len(p.Digest) > 0}, - {"tenant_recipient_kid", p.TenantRecipientKID != ""}, - {"header_len", p.HeaderLen > 0}, - {"base_nonce", len(p.BaseNonce) > 0}, - {"chunk_size", p.ChunkSize > 0}, - {"aad", len(p.AAD) > 0}, - } { - if !f.present { - missing = append(missing, f.name) - } - } - if len(missing) > 0 { - return fmt.Errorf("%w: missing %s", ErrInvalidEncryptionParams, strings.Join(missing, ", ")) - } - return nil -} - // BlobInclusion is one row of ingot.shard_inclusions: block Digest lives at // the inclusive byte range [RangeStart, RangeEnd] inside the shard CAR whose // own location is the (Space, ShardDigest) row of blob_locations. It is the diff --git a/registry/stores_postgres.go b/registry/stores_postgres.go index d7969a2..e3b7634 100644 --- a/registry/stores_postgres.go +++ b/registry/stores_postgres.go @@ -188,23 +188,19 @@ func (r *Postgres) DeleteLocation(ctx context.Context, space did.DID, digest []b // EncryptionParamsStore ====================================================== func (r *Postgres) PutEncryptionParams(ctx context.Context, params BlobEncryptionParams) error { - // Every column is NOT NULL, so reject an incomplete set with a named error - // rather than a constraint violation (see BlobEncryptionParams.Validate). - if err := params.Validate(); err != nil { - return err - } - // Upsert: a re-encryption replaces the parameter set for a blob already stored. + // Upsert: a re-encryption replaces the parameter set for a blob already + // stored. Every column is NOT NULL with a CHECK, so an incomplete set is + // rejected by the constraint rather than by a second check here. _, err := r.pool.Exec(ctx, `INSERT INTO ingot.blob_encryption_params - (space, digest, tenant_recipient_kid, header_len, base_nonce, chunk_size, aad) - VALUES ($1, $2, $3, $4, $5, $6, $7) + (space, digest, header_len, base_nonce, chunk_size, aad) + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (space, digest) DO UPDATE - SET tenant_recipient_kid = EXCLUDED.tenant_recipient_kid, - header_len = EXCLUDED.header_len, - base_nonce = EXCLUDED.base_nonce, - chunk_size = EXCLUDED.chunk_size, - aad = EXCLUDED.aad`, - params.Space, params.Digest, params.TenantRecipientKID, + SET header_len = EXCLUDED.header_len, + base_nonce = EXCLUDED.base_nonce, + chunk_size = EXCLUDED.chunk_size, + aad = EXCLUDED.aad`, + params.Space, params.Digest, params.HeaderLen, params.BaseNonce, params.ChunkSize, params.AAD) if err != nil { return fmt.Errorf("registry: put encryption params: %w", err) @@ -215,10 +211,10 @@ func (r *Postgres) PutEncryptionParams(ctx context.Context, params BlobEncryptio func (r *Postgres) GetEncryptionParams(ctx context.Context, space did.DID, digest []byte) (*BlobEncryptionParams, error) { params := &BlobEncryptionParams{Space: space, Digest: digest} err := r.pool.QueryRow(ctx, - `SELECT tenant_recipient_kid, header_len, base_nonce, chunk_size, aad + `SELECT header_len, base_nonce, chunk_size, aad FROM ingot.blob_encryption_params WHERE space = $1 AND digest = $2`, - space, digest).Scan(¶ms.TenantRecipientKID, ¶ms.HeaderLen, - ¶ms.BaseNonce, ¶ms.ChunkSize, ¶ms.AAD) + space, digest).Scan(¶ms.HeaderLen, ¶ms.BaseNonce, + ¶ms.ChunkSize, ¶ms.AAD) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound }