Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
1 change: 1 addition & 0 deletions .claude
7 changes: 5 additions & 2 deletions inmem/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,17 @@ 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
parts map[string]map[int]registry.MultipartPart // uploadID -> partNumber -> part
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
}
Expand All @@ -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{},
Expand Down
75 changes: 61 additions & 14 deletions inmem/stores.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -125,9 +126,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
}

Expand All @@ -138,8 +137,7 @@ func (m *MemStore) GetLocation(_ context.Context, space did.DID, digest []byte)
if !ok {
return nil, registry.ErrNotFound
}
cp := loc
cp.Digest = cloneBytes(loc.Digest)
cp := cloneLocation(loc)
return &cp, nil
}

Expand All @@ -150,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 {
Expand Down Expand Up @@ -386,6 +415,24 @@ func cloneSession(s registry.MultipartSession) registry.MultipartSession {
return s
}

// 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just use bytes.Clone?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Written by Claude.

Done, and removed the local cloneBytes helper in favour of bytes.Clone everywhere in the file.

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 {
Expand Down
195 changes: 195 additions & 0 deletions inmem/stores_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package inmem

import (
"context"
"errors"
"reflect"
"sync"
"sync/atomic"
"testing"
Expand Down Expand Up @@ -253,6 +255,199 @@ func TestLocations_RoundTrip(t *testing.T) {
}
}

// 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,
AAD: []byte("cose-enc-structure"),
}
}

func TestEncryptionParams_RoundTrip(t *testing.T) {
ctx := context.Background()
m := NewMemStore()
space := testutil.RandomDID(t)
digest := []byte("enc-digest")
want := feeParams(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("GetEncryptionParams: %v", err)
}
if !reflect.DeepEqual(*got, want) {
t.Fatalf("GetEncryptionParams = %+v, want %+v", *got, want)
}
}

// 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)
}
}

// 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, 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)
}

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)
}

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)
}
}

// 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(&params)

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.
func TestEncryptionParams_IndependentOfLocation(t *testing.T) {
ctx := context.Background()
m := NewMemStore()
space := testutil.RandomDID(t)
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)
}
}

// helpers

func mustAdd(t *testing.T, m *MemStore, c registry.BlobClaim) {
Expand Down
Loading
Loading