Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions inmem/stores.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
55 changes: 49 additions & 6 deletions inmem/stores_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}
}

Expand Down Expand Up @@ -338,13 +340,15 @@ 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'

got, err := m.GetEncryptionParams(ctx, space, digest)
if err != nil {
t.Fatalf("GetEncryptionParams: %v", err)
}
got.RegionWrappedCEK[0] = 'Y'
got.BaseNonce[0] = 'Y'

again, err := m.GetEncryptionParams(ctx, space, digest)
Expand All @@ -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.
Expand Down
20 changes: 13 additions & 7 deletions migrations/sql/00014_blob_encryption.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions migrations/up_live_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
74 changes: 62 additions & 12 deletions registry/postgres_live_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}
}

Expand Down Expand Up @@ -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")
}
})

Expand Down
36 changes: 25 additions & 11 deletions registry/stores.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
40 changes: 30 additions & 10 deletions registry/stores_postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(&params.HeaderLen, &params.BaseNonce,
&params.ChunkSize, &params.AAD)
space, digest).Scan(&params.RegionWrappedCEK, &params.RegionKeyVersion,
&params.HeaderLen, &params.BaseNonce, &params.ChunkSize, &params.AAD)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
Expand All @@ -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 {
Expand Down
Loading