Skip to content

Commit 3fdaca1

Browse files
authored
fix: restore wrapped CEK and region key version (#98)
OpenBao transit custodies the KEK and encrypts/decrypts on request but never stores ciphertexts, so the wrapped CEK must live in Ingot's DB and be sent to transit/decrypt on GET, per amendment RFC - "stores the ciphertext and key version in the FIL-480 columns"
1 parent fc5be24 commit 3fdaca1

7 files changed

Lines changed: 196 additions & 48 deletions

File tree

inmem/stores.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,20 @@ func (m *MemStore) DeleteEncryptionParams(_ context.Context, space did.DID, dige
169169
return nil
170170
}
171171

172+
func (m *MemStore) RewrapEncryptionParams(_ context.Context, space did.DID, digest, wrappedCEK []byte, keyVersion string) error {
173+
m.mu.Lock()
174+
defer m.mu.Unlock()
175+
key := locKey{space, string(digest)}
176+
params, ok := m.encParams[key]
177+
if !ok {
178+
return registry.ErrNotFound
179+
}
180+
params.RegionWrappedCEK = bytes.Clone(wrappedCEK)
181+
params.RegionKeyVersion = keyVersion
182+
m.encParams[key] = params
183+
return nil
184+
}
185+
172186
// ParkStore ==================================================================
173187

174188
func (m *MemStore) PutPark(_ context.Context, p registry.BlobPark) error {
@@ -435,6 +449,7 @@ func cloneLocation(loc registry.BlobLocation) registry.BlobLocation {
435449
// so the stored copy and any returned copy never alias the caller's slices.
436450
func cloneEncryptionParams(p registry.BlobEncryptionParams) registry.BlobEncryptionParams {
437451
p.Digest = bytes.Clone(p.Digest)
452+
p.RegionWrappedCEK = bytes.Clone(p.RegionWrappedCEK)
438453
p.BaseNonce = bytes.Clone(p.BaseNonce)
439454
p.AAD = bytes.Clone(p.AAD)
440455
return p

inmem/stores_test.go

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -260,12 +260,14 @@ func TestLocations_RoundTrip(t *testing.T) {
260260
// envelope's row takes.
261261
func feeParams(space did.DID, digest []byte) registry.BlobEncryptionParams {
262262
return registry.BlobEncryptionParams{
263-
Space: space,
264-
Digest: digest,
265-
HeaderLen: 212,
266-
BaseNonce: []byte("nonce07"),
267-
ChunkSize: 65536,
268-
AAD: []byte("cose-enc-structure"),
263+
Space: space,
264+
Digest: digest,
265+
RegionWrappedCEK: []byte("wrapped-cek-bytes"),
266+
RegionKeyVersion: "region-kek-v1",
267+
HeaderLen: 212,
268+
BaseNonce: []byte("nonce07"),
269+
ChunkSize: 65536,
270+
AAD: []byte("cose-enc-structure"),
269271
}
270272
}
271273

@@ -338,13 +340,15 @@ func TestEncryptionParams_NoSliceAliasing(t *testing.T) {
338340
if err := m.PutEncryptionParams(ctx, params); err != nil {
339341
t.Fatalf("PutEncryptionParams: %v", err)
340342
}
343+
params.RegionWrappedCEK[0] = 'X'
341344
params.BaseNonce[0] = 'X'
342345
params.AAD[0] = 'X'
343346

344347
got, err := m.GetEncryptionParams(ctx, space, digest)
345348
if err != nil {
346349
t.Fatalf("GetEncryptionParams: %v", err)
347350
}
351+
got.RegionWrappedCEK[0] = 'Y'
348352
got.BaseNonce[0] = 'Y'
349353

350354
again, err := m.GetEncryptionParams(ctx, space, digest)
@@ -356,6 +360,45 @@ func TestEncryptionParams_NoSliceAliasing(t *testing.T) {
356360
}
357361
}
358362

363+
// A rotation re-wrap replaces only the wrapped CEK and its key version; every
364+
// other parameter describes the unchanged ciphertext and must survive.
365+
func TestEncryptionParams_RewrapInPlace(t *testing.T) {
366+
ctx := context.Background()
367+
m := NewMemStore()
368+
space := testutil.RandomDID(t)
369+
digest := []byte("enc-digest")
370+
if err := m.PutEncryptionParams(ctx, feeParams(space, digest)); err != nil {
371+
t.Fatalf("PutEncryptionParams: %v", err)
372+
}
373+
374+
newCEK := []byte("re-wrapped-cek-bytes")
375+
if err := m.RewrapEncryptionParams(ctx, space, digest, newCEK, "region-kek-v2"); err != nil {
376+
t.Fatalf("RewrapEncryptionParams: %v", err)
377+
}
378+
newCEK[0] = 'X' // the store must not alias the caller's slice
379+
380+
got, err := m.GetEncryptionParams(ctx, space, digest)
381+
if err != nil {
382+
t.Fatalf("GetEncryptionParams: %v", err)
383+
}
384+
want := feeParams(space, digest)
385+
want.RegionWrappedCEK = []byte("re-wrapped-cek-bytes")
386+
want.RegionKeyVersion = "region-kek-v2"
387+
if !reflect.DeepEqual(*got, want) {
388+
t.Fatalf("after rewrap = %+v, want %+v", *got, want)
389+
}
390+
}
391+
392+
func TestEncryptionParams_RewrapMissingIsNotFound(t *testing.T) {
393+
ctx := context.Background()
394+
m := NewMemStore()
395+
396+
err := m.RewrapEncryptionParams(ctx, testutil.RandomDID(t), []byte("absent"), []byte("cek"), "v1")
397+
if !errors.Is(err, registry.ErrNotFound) {
398+
t.Fatalf("RewrapEncryptionParams err = %v, want ErrNotFound", err)
399+
}
400+
}
401+
359402
// The two tables have independent lifecycles and no cascade between them:
360403
// deleting a location leaves the encryption parameters in place, which is why a
361404
// caller removing a blob must delete both.

migrations/sql/00014_blob_encryption.sql

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
-- encrypts an object's body, each body blob is stored as an independent
44
-- COSE/STREAM ciphertext envelope; a range GET must be able to decrypt any byte
55
-- span of that envelope WITHOUT first fetching and parsing its header. A row
6-
-- here caches exactly the inputs the read path's decryptor needs, so a read
7-
-- unwraps the CEK (held in OpenBao, under the region KEK) and goes straight to
8-
-- a body-range fetch — no envelope-header round-trip.
6+
-- here holds exactly the inputs the read path's decryptor needs, so a read
7+
-- unwraps the CEK (via the region's secrets manager, which custodies the region
8+
-- KEK) and goes straight to a body-range fetch — no envelope-header round-trip.
99
--
1010
-- The existence of a row is what marks a blob as encrypted, so every column is
1111
-- NOT NULL: there is no such thing as a half-populated parameter set the
@@ -24,17 +24,23 @@
2424
-- COSE_Encrypt0 and a bare row cannot record which form was used. The protected
2525
-- header stays recoverable from it as element 1.
2626
--
27-
-- Only what the region-KEK read path needs is cached. The COSE recipients,
27+
-- Only what the region-KEK read path needs is stored. The COSE recipients,
2828
-- including the tenant wrap key the insurance-recovery unwrap uses, stay in the
2929
-- envelope header: that path is rare and out-of-band, and it reads the header
30-
-- anyway. No key material is stored here either — the region-KEK-wrapped CEK
31-
-- and its key version live in OpenBao. Per-blob crypto-shred is deleting the
32-
-- key there; deleting this row only drops the cached decrypt parameters.
30+
-- anyway. Raw key bytes never touch this table: region_wrapped_cek is
31+
-- ciphertext, unwrappable only by the region KEK, which lives in the region's
32+
-- secrets manager and never leaves it. Deleting a row is therefore the per-blob
33+
-- crypto-shred — without the wrapped CEK the region has no path to the
34+
-- plaintext (only the tenant recipient in the envelope survives, by design).
3335
-- Because there is no cascade, a caller removing a blob must delete here as
3436
-- well as from blob_locations.
3537
CREATE TABLE ingot.blob_encryption_params (
3638
space text NOT NULL,
3739
digest bytea NOT NULL, -- ciphertext blob multihash, as in blob_locations
40+
region_wrapped_cek bytea NOT NULL -- CEK wrapped by the region key provider (e.g. transit ciphertext); never the raw CEK
41+
CHECK (octet_length(region_wrapped_cek) > 0),
42+
region_key_version text NOT NULL -- opaque id of the region KEK version that produced the wrap
43+
CHECK (region_key_version <> ''),
3844
header_len bigint NOT NULL -- encoded envelope length; the ciphertext starts at this offset
3945
CHECK (header_len > 0),
4046
base_nonce bytea NOT NULL -- COSE iv: the STREAM nonce seed for this blob's ciphertext

migrations/up_live_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ func TestUp_Live(t *testing.T) {
7676
// presence of a row is what marks a blob as encrypted, so there is no such
7777
// thing as a half-populated parameter set.
7878
for _, col := range []string{
79-
"space", "digest", "header_len", "base_nonce", "chunk_size", "aad",
80-
"created_at",
79+
"space", "digest", "region_wrapped_cek", "region_key_version",
80+
"header_len", "base_nonce", "chunk_size", "aad", "created_at",
8181
} {
8282
var nullable string
8383
err := pool.QueryRow(ctx,

registry/postgres_live_test.go

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -162,12 +162,14 @@ func TestPostgresStores_Live(t *testing.T) {
162162
// embedded NULs and high bytes, to exercise real byte round-trips.
163163
liveFEEParams := func(space did.DID, d []byte) registry.BlobEncryptionParams {
164164
return registry.BlobEncryptionParams{
165-
Space: space,
166-
Digest: d,
167-
HeaderLen: 212,
168-
BaseNonce: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07},
169-
ChunkSize: 65536,
170-
AAD: []byte{0xa1, 0x00, 0x18, 0x20},
165+
Space: space,
166+
Digest: d,
167+
RegionWrappedCEK: []byte{0xde, 0xad, 0x00, 0xbe, 0xef, 0xff},
168+
RegionKeyVersion: "region-kek-v1",
169+
HeaderLen: 212,
170+
BaseNonce: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07},
171+
ChunkSize: 65536,
172+
AAD: []byte{0xa1, 0x00, 0x18, 0x20},
171173
}
172174
}
173175

@@ -201,18 +203,66 @@ func TestPostgresStores_Live(t *testing.T) {
201203
}
202204
})
203205

206+
t.Run("encryption params rewrap in place", func(t *testing.T) {
207+
// A rotation replaces only the wrapped CEK and its key version; the
208+
// parameters describing the unchanged ciphertext must survive.
209+
space := testutil.RandomDID(t)
210+
encDigest := []byte{0x0a, 0x0b}
211+
if err := r.PutEncryptionParams(ctx, liveFEEParams(space, encDigest)); err != nil {
212+
t.Fatalf("PutEncryptionParams: %v", err)
213+
}
214+
if err := r.RewrapEncryptionParams(ctx, space, encDigest, []byte{0xca, 0xfe, 0x00, 0x01}, "region-kek-v2"); err != nil {
215+
t.Fatalf("RewrapEncryptionParams: %v", err)
216+
}
217+
got, err := r.GetEncryptionParams(ctx, space, encDigest)
218+
if err != nil {
219+
t.Fatalf("GetEncryptionParams: %v", err)
220+
}
221+
want := liveFEEParams(space, encDigest)
222+
want.RegionWrappedCEK = []byte{0xca, 0xfe, 0x00, 0x01}
223+
want.RegionKeyVersion = "region-kek-v2"
224+
if !reflect.DeepEqual(*got, want) {
225+
t.Fatalf("after rewrap = %+v, want %+v", *got, want)
226+
}
227+
})
228+
229+
t.Run("encryption params rewrap of a missing row is ErrNotFound", func(t *testing.T) {
230+
space := testutil.RandomDID(t)
231+
err := r.RewrapEncryptionParams(ctx, space, []byte{0x0c}, []byte{0x01}, "region-kek-v2")
232+
if !errors.Is(err, registry.ErrNotFound) {
233+
t.Fatalf("RewrapEncryptionParams(absent) = %v, want ErrNotFound", err)
234+
}
235+
})
236+
204237
t.Run("incomplete encryption params rejected", func(t *testing.T) {
205238
// The column constraints are the invariant: a half-populated set never
206239
// reaches the table.
207240
space := testutil.RandomDID(t)
208241
d := []byte{0x77}
209-
partial := liveFEEParams(space, d)
210-
partial.AAD = nil
211-
if err := r.PutEncryptionParams(ctx, partial); err == nil {
212-
t.Fatal("PutEncryptionParams(partial) = nil, want a constraint error")
242+
for name, mutate := range map[string]func(*registry.BlobEncryptionParams){
243+
"nil AAD": func(p *registry.BlobEncryptionParams) { p.AAD = nil },
244+
"nil wrapped CEK": func(p *registry.BlobEncryptionParams) { p.RegionWrappedCEK = nil },
245+
"empty key version": func(p *registry.BlobEncryptionParams) { p.RegionKeyVersion = "" },
246+
} {
247+
partial := liveFEEParams(space, d)
248+
mutate(&partial)
249+
if err := r.PutEncryptionParams(ctx, partial); err == nil {
250+
t.Fatalf("PutEncryptionParams(%s) = nil, want a constraint error", name)
251+
}
252+
if _, err := r.GetEncryptionParams(ctx, space, d); !errors.Is(err, registry.ErrNotFound) {
253+
t.Fatalf("incomplete params (%s) leaked a row: %v", name, err)
254+
}
255+
}
256+
// A rewrap cannot blank out the key material either.
257+
encDigest := []byte{0x78}
258+
if err := r.PutEncryptionParams(ctx, liveFEEParams(space, encDigest)); err != nil {
259+
t.Fatalf("PutEncryptionParams: %v", err)
260+
}
261+
if err := r.RewrapEncryptionParams(ctx, space, encDigest, nil, "region-kek-v2"); err == nil {
262+
t.Fatal("RewrapEncryptionParams(nil CEK) = nil, want a constraint error")
213263
}
214-
if _, err := r.GetEncryptionParams(ctx, space, d); !errors.Is(err, registry.ErrNotFound) {
215-
t.Fatalf("incomplete params leaked a row: %v", err)
264+
if err := r.RewrapEncryptionParams(ctx, space, encDigest, []byte{0x01}, ""); err == nil {
265+
t.Fatal("RewrapEncryptionParams(empty version) = nil, want a constraint error")
216266
}
217267
})
218268

registry/stores.go

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,23 @@ type BlobLocation struct {
9292
// ciphertext digest unique to one encryption, so these parameters are a 1:1
9393
// fact about the blob.
9494
//
95-
// Only what the region-KEK read path needs is cached. The COSE recipients,
95+
// Only what the region-KEK read path needs is stored. The COSE recipients,
9696
// including the tenant wrap key the insurance-recovery unwrap uses, stay in the
9797
// envelope header: that path is rare and out-of-band, and it reads the header
98-
// anyway. No key material is stored here either — the wrapped CEK and its
99-
// region-KEK version live in OpenBao. See docs/architecture.md §8.
98+
// anyway. Raw CEK bytes are never stored — only the region-KEK-wrapped CEK and
99+
// the identifiers needed to unwrap it.
100100
type BlobEncryptionParams struct {
101101
Space did.DID
102102
Digest []byte
103103

104+
// RegionWrappedCEK is the content-encryption key wrapped by the region key
105+
// provider (e.g. secrets-manager transit ciphertext). Never the raw CEK:
106+
// unwrapping it requires the region KEK, which never leaves the provider.
107+
RegionWrappedCEK []byte
108+
// RegionKeyVersion identifies which version of the region KEK wrapped the
109+
// CEK, so a rotation can re-wrap in place. Opaque, so it is agnostic to the
110+
// region-key cardinality decision (FIL-572).
111+
RegionKeyVersion string
104112
// HeaderLen is the encoded length of the blob's COSE envelope, and so the
105113
// offset at which its ciphertext begins. Without it a read could not locate
106114
// byte 0 of the ciphertext without decoding the header.
@@ -226,18 +234,24 @@ type LocationStore interface {
226234
// blob is stored as plaintext".
227235
//
228236
// It is deliberately separate from LocationStore, with no foreign key between
229-
// the tables: the row's existence is what marks a blob as encrypted, so its
230-
// lifecycle is independent of the reconstructible location cache. No key
231-
// material lives here — the wrapped CEK sits in OpenBao, so per-blob
232-
// crypto-shred means deleting the key there; Delete only drops the cached
233-
// decrypt parameters. Put is an upsert, so re-encrypting a blob replaces its
234-
// whole parameter set. Delete is idempotent — and because nothing cascades,
235-
// DeleteLocation does NOT touch this table: a caller removing a blob must
236-
// call both.
237+
// the tables: a location is reconstructible from the indexer or the accept
238+
// receipt, a wrapped CEK is not. Put is an upsert, so re-encrypting a blob
239+
// replaces its whole parameter set; a region-key rotation instead calls
240+
// RewrapEncryptionParams, which touches only the key material. Delete is the
241+
// per-blob crypto-shred (without the wrapped CEK the region has no path to the
242+
// plaintext) and is idempotent — and because nothing cascades, DeleteLocation
243+
// does NOT shred: a caller removing a blob must call both.
237244
type EncryptionParamsStore interface {
238245
PutEncryptionParams(ctx context.Context, params BlobEncryptionParams) error
239246
GetEncryptionParams(ctx context.Context, space did.DID, digest []byte) (*BlobEncryptionParams, error)
240247
DeleteEncryptionParams(ctx context.Context, space did.DID, digest []byte) error
248+
// RewrapEncryptionParams replaces the wrapped CEK and its key version for an
249+
// already-encrypted blob, leaving every other parameter untouched — the write
250+
// a region-key rotation performs. The ciphertext is unchanged, so the nonce,
251+
// chunk size, AAD and header length must survive the rotation, and a rotation
252+
// that rewrote them would corrupt the row. Returns ErrNotFound when the blob
253+
// has no row (nothing to re-wrap).
254+
RewrapEncryptionParams(ctx context.Context, space did.DID, digest, wrappedCEK []byte, keyVersion string) error
241255
}
242256

243257
// BlobPark is one row of ingot.blob_parks: the persistable state of a blob

registry/stores_postgres.go

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -193,14 +193,17 @@ func (r *Postgres) PutEncryptionParams(ctx context.Context, params BlobEncryptio
193193
// rejected by the constraint rather than by a second check here.
194194
_, err := r.pool.Exec(ctx,
195195
`INSERT INTO ingot.blob_encryption_params
196-
(space, digest, header_len, base_nonce, chunk_size, aad)
197-
VALUES ($1, $2, $3, $4, $5, $6)
196+
(space, digest, region_wrapped_cek, region_key_version,
197+
header_len, base_nonce, chunk_size, aad)
198+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
198199
ON CONFLICT (space, digest) DO UPDATE
199-
SET header_len = EXCLUDED.header_len,
200-
base_nonce = EXCLUDED.base_nonce,
201-
chunk_size = EXCLUDED.chunk_size,
202-
aad = EXCLUDED.aad`,
203-
params.Space, params.Digest,
200+
SET region_wrapped_cek = EXCLUDED.region_wrapped_cek,
201+
region_key_version = EXCLUDED.region_key_version,
202+
header_len = EXCLUDED.header_len,
203+
base_nonce = EXCLUDED.base_nonce,
204+
chunk_size = EXCLUDED.chunk_size,
205+
aad = EXCLUDED.aad`,
206+
params.Space, params.Digest, params.RegionWrappedCEK, params.RegionKeyVersion,
204207
params.HeaderLen, params.BaseNonce, params.ChunkSize, params.AAD)
205208
if err != nil {
206209
return fmt.Errorf("registry: put encryption params: %w", err)
@@ -211,10 +214,10 @@ func (r *Postgres) PutEncryptionParams(ctx context.Context, params BlobEncryptio
211214
func (r *Postgres) GetEncryptionParams(ctx context.Context, space did.DID, digest []byte) (*BlobEncryptionParams, error) {
212215
params := &BlobEncryptionParams{Space: space, Digest: digest}
213216
err := r.pool.QueryRow(ctx,
214-
`SELECT header_len, base_nonce, chunk_size, aad
217+
`SELECT region_wrapped_cek, region_key_version, header_len, base_nonce, chunk_size, aad
215218
FROM ingot.blob_encryption_params WHERE space = $1 AND digest = $2`,
216-
space, digest).Scan(&params.HeaderLen, &params.BaseNonce,
217-
&params.ChunkSize, &params.AAD)
219+
space, digest).Scan(&params.RegionWrappedCEK, &params.RegionKeyVersion,
220+
&params.HeaderLen, &params.BaseNonce, &params.ChunkSize, &params.AAD)
218221
if errors.Is(err, pgx.ErrNoRows) {
219222
return nil, ErrNotFound
220223
}
@@ -233,6 +236,23 @@ func (r *Postgres) DeleteEncryptionParams(ctx context.Context, space did.DID, di
233236
return nil
234237
}
235238

239+
func (r *Postgres) RewrapEncryptionParams(ctx context.Context, space did.DID, digest, wrappedCEK []byte, keyVersion string) error {
240+
// The CHECK constraints reject an empty wrapped CEK or key version, so a
241+
// rotation cannot blank out the material the decrypt path needs.
242+
tag, err := r.pool.Exec(ctx,
243+
`UPDATE ingot.blob_encryption_params
244+
SET region_wrapped_cek = $3, region_key_version = $4
245+
WHERE space = $1 AND digest = $2`,
246+
space, digest, wrappedCEK, keyVersion)
247+
if err != nil {
248+
return fmt.Errorf("registry: rewrap encryption params: %w", err)
249+
}
250+
if tag.RowsAffected() == 0 {
251+
return ErrNotFound
252+
}
253+
return nil
254+
}
255+
236256
// ParkStore ==================================================================
237257

238258
func (r *Postgres) PutPark(ctx context.Context, p BlobPark) error {

0 commit comments

Comments
 (0)