Summary
AssetIndex::find_reference(address) returns the first live reference to a content address (BTreeMap order in index/memory.rs, ORDER BY asset_id COLLATE "C" LIMIT 1 in index/postgres.rs). GET /v1/blob/{hash} decides access from that one reference's owner_id and album_id. When the same ciphertext bytes are referenced by assets of unrelated owners, the caller is judged against whichever row sorts first, not against the album they are entitled through.
Pre-existed S-C39 for owners (a wrong 404); S-C51 (#405, PR #458) widens who is affected and adds a wrong-403 case.
Precisely what happens
Bytes X referenced by Alice's asset A (album AL) and Mallory's asset M (album ML), with M < A by asset id, so find_reference(X) returns M's row:
- Alice fetching
X: owner_id == Mallory → membership of (ML, Alice) → Never → 404 although Alice owns A.
- Bob, a current member of
AL: (ML, Bob) → Never → 404 although he is entitled.
- Carol, a current member of
AL who was once on ML's roster and removed: (ML, Carol) → Revoked → 403 error.blob.access_revoked, telling a fully entitled client to re-sync and degrade.
A former member who kept the ciphertext can create this deliberately by re-uploading the same bytes under their own album (asset ids are client-chosen UUIDv7 and sort by time, so a newer asset sorts after — but a pre-existing asset, or one with a hand-picked id, sorts first). Not a confidentiality issue (nobody is served bytes they may not have), but a plausible denial on the real album's serving.
Related, same shape
serve::resolve asks pending_for_address(owner = caller, hash) when no reference exists. A writer member's upload session is filed under the album owner (routes/upload.rs, owner_id: owner), so:
- a writer's second device asking for its own in-flight address gets
404 instead of 409 error.blob.pending_upload;
- the album owner sees
409 for a member's in-flight upload.
Proposed fix
Make the index return every live reference for an address (find_references(address) -> Vec<BlobReference>, live rows first, tombstoned as the fallback set) and fold the authority over them in serve::resolve: any Granted → serve from that reference (its state/hold decide the 410s); else any Revoked → Forbidden; else NotFound. Extend index/conformance.rs with a shared-address-across-owners case for both adapters. For the pending answer, key pending_for_address on the uploader and on the albums the caller may write to, or carry upload_user_id alongside owner_id in the lookup.
Where
capsule-server/src/index/{mod,memory,postgres,conformance}.rs
capsule-server/src/serve/{mod,authority}.rs
capsule-server/tests/blob.rs
Refs #405. Found while landing S-C51 (PR #458); recorded there under "Failure modes — shared bytes across unrelated owners".
Summary
AssetIndex::find_reference(address)returns the first live reference to a content address (BTreeMap order inindex/memory.rs,ORDER BY asset_id COLLATE "C" LIMIT 1inindex/postgres.rs).GET /v1/blob/{hash}decides access from that one reference'sowner_idandalbum_id. When the same ciphertext bytes are referenced by assets of unrelated owners, the caller is judged against whichever row sorts first, not against the album they are entitled through.Pre-existed
S-C39for owners (a wrong404);S-C51(#405, PR #458) widens who is affected and adds a wrong-403case.Precisely what happens
Bytes
Xreferenced by Alice's assetA(albumAL) and Mallory's assetM(albumML), withM < Aby asset id, sofind_reference(X)returnsM's row:X:owner_id == Mallory→ membership of(ML, Alice)→Never→404although Alice ownsA.AL:(ML, Bob)→Never→404although he is entitled.ALwho was once onML's roster and removed:(ML, Carol)→Revoked→403 error.blob.access_revoked, telling a fully entitled client to re-sync and degrade.A former member who kept the ciphertext can create this deliberately by re-uploading the same bytes under their own album (asset ids are client-chosen UUIDv7 and sort by time, so a newer asset sorts after — but a pre-existing asset, or one with a hand-picked id, sorts first). Not a confidentiality issue (nobody is served bytes they may not have), but a plausible denial on the real album's serving.
Related, same shape
serve::resolveaskspending_for_address(owner = caller, hash)when no reference exists. A writer member's upload session is filed under the album owner (routes/upload.rs,owner_id: owner), so:404instead of409 error.blob.pending_upload;409for a member's in-flight upload.Proposed fix
Make the index return every live reference for an address (
find_references(address) -> Vec<BlobReference>, live rows first, tombstoned as the fallback set) and fold the authority over them inserve::resolve: anyGranted→ serve from that reference (itsstate/holddecide the410s); else anyRevoked→Forbidden; elseNotFound. Extendindex/conformance.rswith a shared-address-across-owners case for both adapters. For the pending answer, keypending_for_addresson the uploader and on the albums the caller may write to, or carryupload_user_idalongsideowner_idin the lookup.Where
capsule-server/src/index/{mod,memory,postgres,conformance}.rscapsule-server/src/serve/{mod,authority}.rscapsule-server/tests/blob.rsRefs #405. Found while landing
S-C51(PR #458); recorded there under "Failure modes — shared bytes across unrelated owners".