Skip to content

Commit 893e930

Browse files
committed
feat(registry): add a dedicated re-wrap method
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š <oss@bajtos.net>
1 parent b864480 commit 893e930

5 files changed

Lines changed: 171 additions & 8 deletions

File tree

inmem/stores.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,23 @@ func (m *MemStore) GetEncryptionParams(_ context.Context, space did.DID, digest
172172
return &cp, nil
173173
}
174174

175+
func (m *MemStore) RewrapEncryptionParams(_ context.Context, space did.DID, digest, wrappedCEK []byte, keyVersion string) error {
176+
if err := registry.ValidateRewrap(wrappedCEK, keyVersion); err != nil {
177+
return err
178+
}
179+
m.mu.Lock()
180+
defer m.mu.Unlock()
181+
key := locKey{space, string(digest)}
182+
params, ok := m.encParams[key]
183+
if !ok {
184+
return registry.ErrNotFound
185+
}
186+
params.RegionWrappedCEK = cloneBytes(wrappedCEK)
187+
params.RegionKeyVersion = keyVersion
188+
m.encParams[key] = params
189+
return nil
190+
}
191+
175192
func (m *MemStore) DeleteEncryptionParams(_ context.Context, space did.DID, digest []byte) error {
176193
m.mu.Lock()
177194
defer m.mu.Unlock()

inmem/stores_test.go

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -373,8 +373,8 @@ func TestEncryptionParams_RewrapInPlace(t *testing.T) {
373373
want := feeParams(space, digest)
374374
want.RegionWrappedCEK = []byte("wrapped-cek-v2")
375375
want.RegionKeyVersion = "region-v2"
376-
if err := m.PutEncryptionParams(ctx, want); err != nil {
377-
t.Fatalf("PutEncryptionParams (re-wrap): %v", err)
376+
if err := m.RewrapEncryptionParams(ctx, space, digest, want.RegionWrappedCEK, want.RegionKeyVersion); err != nil {
377+
t.Fatalf("RewrapEncryptionParams: %v", err)
378378
}
379379

380380
got, err := m.GetEncryptionParams(ctx, space, digest)
@@ -386,6 +386,78 @@ func TestEncryptionParams_RewrapInPlace(t *testing.T) {
386386
}
387387
}
388388

389+
// Nothing to re-wrap means the blob is not encrypted (or was already shredded),
390+
// which a rotation must hear about rather than take for success.
391+
func TestEncryptionParams_RewrapMissingIsNotFound(t *testing.T) {
392+
ctx := context.Background()
393+
m := NewMemStore()
394+
395+
err := m.RewrapEncryptionParams(ctx, testutil.RandomDID(t), []byte("absent"), []byte("wrapped-cek-v2"), "region-v2")
396+
if !errors.Is(err, registry.ErrNotFound) {
397+
t.Fatalf("RewrapEncryptionParams(absent) err = %v, want ErrNotFound", err)
398+
}
399+
}
400+
401+
// A re-wrap may not blank out the key material the decrypt path needs.
402+
func TestEncryptionParams_RewrapIncompleteRejected(t *testing.T) {
403+
space := testutil.RandomDID(t)
404+
digest := []byte("enc-digest")
405+
406+
cases := map[string]struct {
407+
wrappedCEK []byte
408+
keyVersion string
409+
}{
410+
"no wrapped CEK": {nil, "region-v2"},
411+
"empty wrapped CEK": {[]byte{}, "region-v2"},
412+
"no key version": {[]byte("wrapped-cek-v2"), ""},
413+
}
414+
for name, tc := range cases {
415+
t.Run(name, func(t *testing.T) {
416+
ctx := context.Background()
417+
m := NewMemStore()
418+
if err := m.PutEncryptionParams(ctx, feeParams(space, digest)); err != nil {
419+
t.Fatalf("PutEncryptionParams: %v", err)
420+
}
421+
422+
err := m.RewrapEncryptionParams(ctx, space, digest, tc.wrappedCEK, tc.keyVersion)
423+
if !errors.Is(err, registry.ErrInvalidEncryptionParams) {
424+
t.Fatalf("RewrapEncryptionParams err = %v, want ErrInvalidEncryptionParams", err)
425+
}
426+
got, getErr := m.GetEncryptionParams(ctx, space, digest)
427+
if getErr != nil {
428+
t.Fatalf("GetEncryptionParams: %v", getErr)
429+
}
430+
if !reflect.DeepEqual(*got, feeParams(space, digest)) {
431+
t.Fatalf("rejected re-wrap altered the row: %+v", *got)
432+
}
433+
})
434+
}
435+
}
436+
437+
// The re-wrap path must not alias the caller's key material either.
438+
func TestEncryptionParams_RewrapNoSliceAliasing(t *testing.T) {
439+
ctx := context.Background()
440+
m := NewMemStore()
441+
space := testutil.RandomDID(t)
442+
digest := []byte("enc-digest")
443+
if err := m.PutEncryptionParams(ctx, feeParams(space, digest)); err != nil {
444+
t.Fatalf("PutEncryptionParams: %v", err)
445+
}
446+
wrappedCEK := []byte("wrapped-cek-v2")
447+
if err := m.RewrapEncryptionParams(ctx, space, digest, wrappedCEK, "region-v2"); err != nil {
448+
t.Fatalf("RewrapEncryptionParams: %v", err)
449+
}
450+
wrappedCEK[0] = 'X'
451+
452+
got, err := m.GetEncryptionParams(ctx, space, digest)
453+
if err != nil {
454+
t.Fatalf("GetEncryptionParams: %v", err)
455+
}
456+
if !reflect.DeepEqual(got.RegionWrappedCEK, []byte("wrapped-cek-v2")) {
457+
t.Fatalf("re-wrapped CEK was aliased: %q", got.RegionWrappedCEK)
458+
}
459+
}
460+
389461
// Every parameter is required: a row missing any one of them could not be
390462
// decrypted with, so PutEncryptionParams rejects it and stores nothing.
391463
func TestEncryptionParams_IncompleteRejected(t *testing.T) {

registry/postgres_live_test.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,8 @@ func TestPostgresStores_Live(t *testing.T) {
196196
want := liveFEEParams(space, encDigest)
197197
want.RegionWrappedCEK = []byte{0x11, 0x22, 0x33}
198198
want.RegionKeyVersion = "region-v2"
199-
if err := r.PutEncryptionParams(ctx, want); err != nil {
200-
t.Fatalf("PutEncryptionParams (re-wrap): %v", err)
199+
if err := r.RewrapEncryptionParams(ctx, space, encDigest, want.RegionWrappedCEK, want.RegionKeyVersion); err != nil {
200+
t.Fatalf("RewrapEncryptionParams: %v", err)
201201
}
202202
got, err := r.GetEncryptionParams(ctx, space, encDigest)
203203
if err != nil {
@@ -208,6 +208,34 @@ func TestPostgresStores_Live(t *testing.T) {
208208
}
209209
})
210210

211+
t.Run("re-wrap of an absent row is not found", func(t *testing.T) {
212+
// The UPDATE matches nothing, which RowsAffected turns into ErrNotFound.
213+
space := testutil.RandomDID(t)
214+
err := r.RewrapEncryptionParams(ctx, space, []byte{0x0a, 0x0b}, []byte{0x11}, "region-v2")
215+
if !errors.Is(err, registry.ErrNotFound) {
216+
t.Fatalf("RewrapEncryptionParams(absent) = %v, want ErrNotFound", err)
217+
}
218+
})
219+
220+
t.Run("incomplete re-wrap rejected", func(t *testing.T) {
221+
space := testutil.RandomDID(t)
222+
encDigest := []byte{0x0c, 0x0d}
223+
want := liveFEEParams(space, encDigest)
224+
if err := r.PutEncryptionParams(ctx, want); err != nil {
225+
t.Fatalf("PutEncryptionParams: %v", err)
226+
}
227+
if err := r.RewrapEncryptionParams(ctx, space, encDigest, nil, "region-v2"); !errors.Is(err, registry.ErrInvalidEncryptionParams) {
228+
t.Fatalf("RewrapEncryptionParams(no CEK) = %v, want ErrInvalidEncryptionParams", err)
229+
}
230+
got, err := r.GetEncryptionParams(ctx, space, encDigest)
231+
if err != nil {
232+
t.Fatalf("GetEncryptionParams: %v", err)
233+
}
234+
if !reflect.DeepEqual(*got, want) {
235+
t.Fatalf("rejected re-wrap altered the row: %+v", *got)
236+
}
237+
})
238+
211239
t.Run("encryption params delete shreds", func(t *testing.T) {
212240
space := testutil.RandomDID(t)
213241
encDigest := []byte{0x05, 0x06}

registry/stores.go

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,23 @@ func (p BlobEncryptionParams) Validate() error {
163163
return nil
164164
}
165165

166+
// ValidateRewrap enforces the two parameters a re-wrap replaces, so a rotation
167+
// cannot blank out the material the decrypt path needs. Every
168+
// EncryptionParamsStore implementation calls it from RewrapEncryptionParams.
169+
func ValidateRewrap(wrappedCEK []byte, keyVersion string) error {
170+
var missing []string
171+
if len(wrappedCEK) == 0 {
172+
missing = append(missing, "region_wrapped_cek")
173+
}
174+
if keyVersion == "" {
175+
missing = append(missing, "region_key_version")
176+
}
177+
if len(missing) > 0 {
178+
return fmt.Errorf("%w: missing %s", ErrInvalidEncryptionParams, strings.Join(missing, ", "))
179+
}
180+
return nil
181+
}
182+
166183
// BlobInclusion is one row of ingot.shard_inclusions: block Digest lives at
167184
// the inclusive byte range [RangeStart, RangeEnd] inside the shard CAR whose
168185
// own location is the (Space, ShardDigest) row of blob_locations. It is the
@@ -272,14 +289,23 @@ type LocationStore interface {
272289
//
273290
// It is deliberately separate from LocationStore, with no foreign key between
274291
// the tables: a location is reconstructible from the indexer or the accept
275-
// receipt, a wrapped CEK is not. Put is an upsert, so a rotation re-wrap
276-
// replaces the material in place. Delete is the per-blob crypto-shred and is
277-
// idempotent — and because nothing cascades, DeleteLocation does NOT shred: a
278-
// caller removing a blob must call both.
292+
// receipt, a wrapped CEK is not. Put is an upsert, so re-encrypting a blob
293+
// replaces its whole parameter set; a region-key rotation instead calls
294+
// RewrapEncryptionParams, which touches only the key material. Delete is the
295+
// per-blob crypto-shred and is idempotent — and because nothing cascades,
296+
// DeleteLocation does NOT shred: a caller removing a blob must call both.
279297
type EncryptionParamsStore interface {
280298
PutEncryptionParams(ctx context.Context, params BlobEncryptionParams) error
281299
GetEncryptionParams(ctx context.Context, space did.DID, digest []byte) (*BlobEncryptionParams, error)
282300
DeleteEncryptionParams(ctx context.Context, space did.DID, digest []byte) error
301+
// RewrapEncryptionParams replaces the wrapped CEK and its key version for an
302+
// already-encrypted blob, leaving every other parameter untouched — the write
303+
// a region-key rotation performs. The ciphertext is unchanged, so the nonce,
304+
// chunk size, AAD and header length must survive the rotation, and a rotation
305+
// that rewrote them would corrupt the row. Returns ErrNotFound when the blob
306+
// has no row (nothing to re-wrap) and ErrInvalidEncryptionParams when either
307+
// argument is empty.
308+
RewrapEncryptionParams(ctx context.Context, space did.DID, digest, wrappedCEK []byte, keyVersion string) error
283309
}
284310

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

registry/stores_postgres.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,26 @@ func (r *Postgres) GetEncryptionParams(ctx context.Context, space did.DID, diges
231231
return params, nil
232232
}
233233

234+
func (r *Postgres) RewrapEncryptionParams(ctx context.Context, space did.DID, digest, wrappedCEK []byte, keyVersion string) error {
235+
if err := ValidateRewrap(wrappedCEK, keyVersion); err != nil {
236+
return err
237+
}
238+
tag, err := r.pool.Exec(ctx,
239+
`UPDATE ingot.blob_encryption_params
240+
SET region_wrapped_cek = $3, region_key_version = $4
241+
WHERE space = $1 AND digest = $2`,
242+
space, digest, wrappedCEK, keyVersion)
243+
if err != nil {
244+
return fmt.Errorf("registry: rewrap encryption params: %w", err)
245+
}
246+
// An UPDATE of no rows is a rotation aimed at a blob that is not encrypted (or
247+
// was already shredded); surface it rather than reporting success.
248+
if tag.RowsAffected() == 0 {
249+
return ErrNotFound
250+
}
251+
return nil
252+
}
253+
234254
func (r *Postgres) DeleteEncryptionParams(ctx context.Context, space did.DID, digest []byte) error {
235255
_, err := r.pool.Exec(ctx,
236256
`DELETE FROM ingot.blob_encryption_params WHERE space = $1 AND digest = $2`, space, digest)

0 commit comments

Comments
 (0)