diff --git a/inmem/stores.go b/inmem/stores.go index 51f13ff..573f638 100644 --- a/inmem/stores.go +++ b/inmem/stores.go @@ -169,6 +169,20 @@ func (m *MemStore) DeleteEncryptionParams(_ context.Context, space did.DID, dige return nil } +func (m *MemStore) RewrapEncryptionParams(_ context.Context, space did.DID, digest, wrappedCEK []byte, keyVersion string) error { + m.mu.Lock() + defer m.mu.Unlock() + key := locKey{space, string(digest)} + params, ok := m.encParams[key] + if !ok { + return registry.ErrNotFound + } + params.RegionWrappedCEK = bytes.Clone(wrappedCEK) + params.RegionKeyVersion = keyVersion + m.encParams[key] = params + return nil +} + // ParkStore ================================================================== func (m *MemStore) PutPark(_ context.Context, p registry.BlobPark) error { @@ -435,6 +449,7 @@ func cloneLocation(loc registry.BlobLocation) registry.BlobLocation { // so the stored copy and any returned copy never alias the caller's slices. func cloneEncryptionParams(p registry.BlobEncryptionParams) registry.BlobEncryptionParams { p.Digest = bytes.Clone(p.Digest) + p.RegionWrappedCEK = bytes.Clone(p.RegionWrappedCEK) p.BaseNonce = bytes.Clone(p.BaseNonce) p.AAD = bytes.Clone(p.AAD) return p diff --git a/inmem/stores_test.go b/inmem/stores_test.go index 4f281b0..ae90681 100644 --- a/inmem/stores_test.go +++ b/inmem/stores_test.go @@ -260,12 +260,14 @@ 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, - HeaderLen: 212, - BaseNonce: []byte("nonce07"), - ChunkSize: 65536, - AAD: []byte("cose-enc-structure"), + Space: space, + Digest: digest, + RegionWrappedCEK: []byte("wrapped-cek-bytes"), + RegionKeyVersion: "region-kek-v1", + HeaderLen: 212, + BaseNonce: []byte("nonce07"), + ChunkSize: 65536, + AAD: []byte("cose-enc-structure"), } } @@ -338,6 +340,7 @@ 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' @@ -345,6 +348,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) @@ -356,6 +360,45 @@ func TestEncryptionParams_NoSliceAliasing(t *testing.T) { } } +// A rotation re-wrap replaces only the wrapped CEK and its key version; every +// other parameter describes the unchanged ciphertext and must survive. +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) + } + + newCEK := []byte("re-wrapped-cek-bytes") + if err := m.RewrapEncryptionParams(ctx, space, digest, newCEK, "region-kek-v2"); err != nil { + t.Fatalf("RewrapEncryptionParams: %v", err) + } + newCEK[0] = 'X' // the store must not alias the caller's slice + + got, err := m.GetEncryptionParams(ctx, space, digest) + if err != nil { + t.Fatalf("GetEncryptionParams: %v", err) + } + want := feeParams(space, digest) + want.RegionWrappedCEK = []byte("re-wrapped-cek-bytes") + want.RegionKeyVersion = "region-kek-v2" + if !reflect.DeepEqual(*got, want) { + t.Fatalf("after rewrap = %+v, want %+v", *got, want) + } +} + +func TestEncryptionParams_RewrapMissingIsNotFound(t *testing.T) { + ctx := context.Background() + m := NewMemStore() + + err := m.RewrapEncryptionParams(ctx, testutil.RandomDID(t), []byte("absent"), []byte("cek"), "v1") + if !errors.Is(err, registry.ErrNotFound) { + t.Fatalf("RewrapEncryptionParams err = %v, want ErrNotFound", err) + } +} + // 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 9cdda4b..09d2b1d 100644 --- a/migrations/sql/00014_blob_encryption.sql +++ b/migrations/sql/00014_blob_encryption.sql @@ -3,9 +3,9 @@ -- 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 (held in OpenBao, under the region KEK) and goes straight to --- a body-range fetch — no envelope-header round-trip. +-- here holds exactly the inputs the read path's decryptor needs, so a read +-- unwraps the CEK (via the region's secrets manager, which custodies 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 @@ -24,17 +24,23 @@ -- COSE_Encrypt0 and a bare row cannot record which form was used. The protected -- header stays recoverable from it as element 1. -- --- Only what the region-KEK read path needs is cached. The COSE recipients, +-- Only what the region-KEK read path needs is stored. 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. +-- anyway. Raw key bytes never touch this table: region_wrapped_cek is +-- ciphertext, unwrappable only by the region KEK, which lives in the region's +-- secrets manager and never leaves it. Deleting a row is therefore the per-blob +-- crypto-shred — without the wrapped CEK the region has no path to the +-- plaintext (only the tenant recipient in the envelope survives, by design). -- 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 by the region key provider (e.g. transit ciphertext); never the raw CEK + CHECK (octet_length(region_wrapped_cek) > 0), + region_key_version text NOT NULL -- opaque id of the region KEK version that produced the wrap + CHECK (region_key_version <> ''), 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 6f2e08f..a6a5435 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", "header_len", "base_nonce", "chunk_size", "aad", - "created_at", + "space", "digest", "region_wrapped_cek", "region_key_version", + "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 6ea2fbf..26f437c 100644 --- a/registry/postgres_live_test.go +++ b/registry/postgres_live_test.go @@ -162,12 +162,14 @@ 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, - HeaderLen: 212, - BaseNonce: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}, - ChunkSize: 65536, - AAD: []byte{0xa1, 0x00, 0x18, 0x20}, + Space: space, + Digest: d, + RegionWrappedCEK: []byte{0xde, 0xad, 0x00, 0xbe, 0xef, 0xff}, + RegionKeyVersion: "region-kek-v1", + HeaderLen: 212, + BaseNonce: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}, + ChunkSize: 65536, + AAD: []byte{0xa1, 0x00, 0x18, 0x20}, } } @@ -201,18 +203,66 @@ func TestPostgresStores_Live(t *testing.T) { } }) + t.Run("encryption params rewrap in place", func(t *testing.T) { + // A rotation replaces only the wrapped CEK and its key version; the + // parameters describing the unchanged ciphertext must survive. + space := testutil.RandomDID(t) + encDigest := []byte{0x0a, 0x0b} + if err := r.PutEncryptionParams(ctx, liveFEEParams(space, encDigest)); err != nil { + t.Fatalf("PutEncryptionParams: %v", err) + } + if err := r.RewrapEncryptionParams(ctx, space, encDigest, []byte{0xca, 0xfe, 0x00, 0x01}, "region-kek-v2"); err != nil { + t.Fatalf("RewrapEncryptionParams: %v", err) + } + got, err := r.GetEncryptionParams(ctx, space, encDigest) + if err != nil { + t.Fatalf("GetEncryptionParams: %v", err) + } + want := liveFEEParams(space, encDigest) + want.RegionWrappedCEK = []byte{0xca, 0xfe, 0x00, 0x01} + want.RegionKeyVersion = "region-kek-v2" + if !reflect.DeepEqual(*got, want) { + t.Fatalf("after rewrap = %+v, want %+v", *got, want) + } + }) + + t.Run("encryption params rewrap of a missing row is ErrNotFound", func(t *testing.T) { + space := testutil.RandomDID(t) + err := r.RewrapEncryptionParams(ctx, space, []byte{0x0c}, []byte{0x01}, "region-kek-v2") + if !errors.Is(err, registry.ErrNotFound) { + t.Fatalf("RewrapEncryptionParams(absent) = %v, want ErrNotFound", err) + } + }) + t.Run("incomplete encryption params rejected", func(t *testing.T) { // 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); err == nil { - t.Fatal("PutEncryptionParams(partial) = nil, want a constraint error") + for name, mutate := range map[string]func(*registry.BlobEncryptionParams){ + "nil AAD": func(p *registry.BlobEncryptionParams) { p.AAD = nil }, + "nil wrapped CEK": func(p *registry.BlobEncryptionParams) { p.RegionWrappedCEK = nil }, + "empty key version": func(p *registry.BlobEncryptionParams) { p.RegionKeyVersion = "" }, + } { + partial := liveFEEParams(space, d) + mutate(&partial) + if err := r.PutEncryptionParams(ctx, partial); err == nil { + t.Fatalf("PutEncryptionParams(%s) = nil, want a constraint error", name) + } + if _, err := r.GetEncryptionParams(ctx, space, d); !errors.Is(err, registry.ErrNotFound) { + t.Fatalf("incomplete params (%s) leaked a row: %v", name, err) + } + } + // A rewrap cannot blank out the key material either. + encDigest := []byte{0x78} + if err := r.PutEncryptionParams(ctx, liveFEEParams(space, encDigest)); err != nil { + t.Fatalf("PutEncryptionParams: %v", err) + } + if err := r.RewrapEncryptionParams(ctx, space, encDigest, nil, "region-kek-v2"); err == nil { + t.Fatal("RewrapEncryptionParams(nil CEK) = 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) + if err := r.RewrapEncryptionParams(ctx, space, encDigest, []byte{0x01}, ""); err == nil { + t.Fatal("RewrapEncryptionParams(empty version) = nil, want a constraint error") } }) diff --git a/registry/stores.go b/registry/stores.go index c7db0f4..010debd 100644 --- a/registry/stores.go +++ b/registry/stores.go @@ -92,15 +92,23 @@ type BlobLocation struct { // ciphertext digest unique to one encryption, so these parameters are a 1:1 // fact about the blob. // -// Only what the region-KEK read path needs is cached. The COSE recipients, +// Only what the region-KEK read path needs is stored. 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. +// anyway. Raw CEK bytes are never stored — only the region-KEK-wrapped CEK and +// the identifiers needed to unwrap it. type BlobEncryptionParams struct { Space did.DID Digest []byte + // RegionWrappedCEK is the content-encryption key wrapped by the region key + // provider (e.g. secrets-manager transit ciphertext). Never the raw CEK: + // unwrapping it requires the region KEK, which never leaves the provider. + 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 // 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. @@ -226,18 +234,24 @@ type LocationStore interface { // blob is stored as plaintext". // // It is deliberately separate from LocationStore, with no foreign key between -// 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. +// 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 (without the wrapped CEK the region has no path to the +// plaintext) 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). + 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 e3b7634..4087861 100644 --- a/registry/stores_postgres.go +++ b/registry/stores_postgres.go @@ -193,14 +193,17 @@ func (r *Postgres) PutEncryptionParams(ctx context.Context, params BlobEncryptio // rejected by the constraint rather than by a second check here. _, err := r.pool.Exec(ctx, `INSERT INTO ingot.blob_encryption_params - (space, digest, header_len, base_nonce, chunk_size, aad) - VALUES ($1, $2, $3, $4, $5, $6) + (space, digest, region_wrapped_cek, region_key_version, + header_len, base_nonce, chunk_size, aad) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (space, digest) DO UPDATE - SET header_len = EXCLUDED.header_len, - base_nonce = EXCLUDED.base_nonce, - chunk_size = EXCLUDED.chunk_size, - aad = EXCLUDED.aad`, - params.Space, params.Digest, + SET region_wrapped_cek = EXCLUDED.region_wrapped_cek, + region_key_version = EXCLUDED.region_key_version, + 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.HeaderLen, params.BaseNonce, params.ChunkSize, params.AAD) if err != nil { return fmt.Errorf("registry: put encryption params: %w", err) @@ -211,10 +214,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 header_len, base_nonce, chunk_size, aad + `SELECT region_wrapped_cek, region_key_version, header_len, base_nonce, chunk_size, aad FROM ingot.blob_encryption_params WHERE space = $1 AND digest = $2`, - space, digest).Scan(¶ms.HeaderLen, ¶ms.BaseNonce, - ¶ms.ChunkSize, ¶ms.AAD) + space, digest).Scan(¶ms.RegionWrappedCEK, ¶ms.RegionKeyVersion, + ¶ms.HeaderLen, ¶ms.BaseNonce, ¶ms.ChunkSize, ¶ms.AAD) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } @@ -233,6 +236,23 @@ func (r *Postgres) DeleteEncryptionParams(ctx context.Context, space did.DID, di return nil } +func (r *Postgres) RewrapEncryptionParams(ctx context.Context, space did.DID, digest, wrappedCEK []byte, keyVersion string) error { + // The CHECK constraints reject an empty wrapped CEK or key version, so a + // rotation cannot blank out the material the decrypt path needs. + 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) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + // ParkStore ================================================================== func (r *Postgres) PutPark(ctx context.Context, p BlobPark) error {