| … | … | @@ -10,6 +10,7 @@ import http from 'bare-http1' |
| 10 | 10 | import https from 'bare-https' |
| 11 | 11 | import bareWs from 'bare-ws' |
| 12 | 12 | import { tidNow } from 'atproto-store/lib/tid.js' |
| 13 | import { buildRecordProofCar } from './sync-get-record.js' | |
| 13 | 14 | import { proxyToAppView, isAppViewMethod } from './appview.js' |
| 14 | 15 | import { proxyToFavela, isFavelaMethod, buildMembraneDidDoc } from './favela-proxy.js' |
| 15 | 16 | |
| … | … | @@ -178,6 +179,34 @@ export function createPdsServer ({ peers = null, serverDid = null, availableUser |
| 178 | 179 | res.writeHead(200, { 'content-type': 'application/vnd.ipld.car', 'content-length': buf.length, ...CORS }) |
| 179 | 180 | return res.end(buf) |
| 180 | 181 | } |
| 182 | // The PROOF-CARRYING single-record read. Distinct from com.atproto.repo.getRecord below, which | |
| 183 | // returns hydrated JSON: no commit, no signature, no MST proof, so nothing about it can be | |
| 184 | // verified. This returns a CAR holding the signed commit + the MST blocks along the covering | |
| 185 | // path + the record block, which is what lets a consumer verify a record WITHOUT pulling the | |
| 186 | // whole repo. | |
| 187 | // | |
| 188 | // We were missing it entirely, and it is not a niche gap: any client doing verified inbound | |
| 189 | // ingest against us — including our own favela lens — got a 404 and could only fall back to | |
| 190 | // fetching the ENTIRE repo. Found by pointing our own verifier at our own membrane. | |
| 191 | // | |
| 192 | // Per the lexicon, the response proves existence OR NON-EXISTENCE, so an absent record still | |
| 193 | // returns 200 with the exclusion proof rather than a 404. | |
| 194 | case 'com.atproto.sync.getRecord': { | |
| 195 | const front = await frontIfPeer(q.get('did') || undefined) | |
| 196 | if (!front) return sendErr(res, 404, 'RepoNotFound', 'repo not fronted here') | |
| 197 | const collection = q.get('collection') | |
| 198 | const rkey = q.get('rkey') | |
| 199 | if (!collection || !rkey) return sendErr(res, 400, 'InvalidRequest', 'collection and rkey are required') | |
| 200 | try { | |
| 201 | const { carBytes } = await front.getRepo() | |
| 202 | const proof = await buildRecordProofCar(carBytes, collection, rkey) | |
| 203 | const out = Buffer.from(proof.carBytes) | |
| 204 | res.writeHead(200, { 'content-type': 'application/vnd.ipld.car', 'content-length': out.length, ...CORS }) | |
| 205 | return res.end(out) | |
| 206 | } catch (err) { | |
| 207 | return sendErr(res, 500, 'InternalServerError', 'proof build failed: ' + ((err && err.message) || String(err))) | |
| 208 | } | |
| 209 | } | |
| 181 | 210 | case 'com.atproto.repo.getRecord': { |
| 182 | 211 | const front = await frontIfPeer(q.get('repo') || q.get('did') || undefined) |
| 183 | 212 | if (!front) return sendErr(res, 404, 'RecordNotFound', 'record not found') |
| … | … | @@ -0,0 +1,47 @@ |
| 1 | // com.atproto.sync.getRecord — build the proof CAR for ONE record. | |
| 2 | // | |
| 3 | // WHY THIS EXISTS AT ALL. The membrane served getRepo (the whole repo) and repo.getRecord (hydrated | |
| 4 | // JSON: no commit, no signature, no MST proof, therefore unverifiable) but NOT the proof-carrying | |
| 5 | // single-record read in between. So no client could verify one of our records without pulling the | |
| 6 | // entire repo — including our own favela inbound lens, which got a 404 pointing at us. A gap in our | |
| 7 | // federation surface, found by aiming our own verifier at ourselves. | |
| 8 | // | |
| 9 | // WHAT A CONSUMER DOES WITH IT. Load the commit, check its signature against the DID document's key, | |
| 10 | // walk the covering path to <collection>/<rkey>, and confirm the record block hashes to the CID the | |
| 11 | // proof arrives at. That is only possible if we ship the commit + the path + the record — which is | |
| 12 | // exactly what this slices out of the full repo CAR. | |
| 13 | // | |
| 14 | // EXISTENCE *OR* NON-EXISTENCE. Per the lexicon this proves either. An absent record is NOT a 404: it | |
| 15 | // returns the same commit + covering path with no record block, which proves the key is genuinely not | |
| 16 | // in the tree. A 404 would be indistinguishable from "the repo is not here". | |
| 17 | // | |
| 18 | // Separated from server-bare.js so it is testable under plain node — that file imports bare-http1 and | |
| 19 | // so can only run under bare. | |
| 20 | ||
| 21 | import { readCar, writeCar, loadCommit, getBlock } from 'atproto-repo/lib/repo.js' | |
| 22 | import { nodeStoreFromBlocks, findRpathProof } from 'atproto-repo/lib/mst.js' | |
| 23 | ||
| 24 | // carBytes: the full repo CAR (front.getRepo()). Returns { carBytes, found } or throws. | |
| 25 | export async function buildRecordProofCar (repoCarBytes, collection, rkey) { | |
| 26 | const car = await readCar(repoCarBytes) | |
| 27 | const commit = loadCommit(car) | |
| 28 | const ns = nodeStoreFromBlocks(car.blocks) | |
| 29 | const { value: recordCid, proof } = findRpathProof(ns, commit.value.data, `${collection}/${rkey}`) | |
| 30 | ||
| 31 | // The signed commit is the root and the anchor for every other check. | |
| 32 | const blocks = [{ cid: commit.cid, bytes: getBlock(car, commit.cid).bytes }] | |
| 33 | ||
| 34 | // The covering path — the MST nodes a verifier walks from the signed root down to the key. Without | |
| 35 | // these the record block is unattached to anything signed. | |
| 36 | for (const cidStr of proof) { | |
| 37 | const b = car.blocks.get(cidStr) | |
| 38 | if (b) blocks.push({ cid: b.cid, bytes: b.bytes }) | |
| 39 | } | |
| 40 | ||
| 41 | if (recordCid) { | |
| 42 | const rb = car.blocks.get(recordCid.toString()) | |
| 43 | if (rb) blocks.push({ cid: rb.cid, bytes: rb.bytes }) | |
| 44 | } | |
| 45 | ||
| 46 | return { carBytes: await writeCar(commit.cid, blocks), found: !!recordCid } | |
| 47 | } |
| … | … | @@ -0,0 +1,97 @@ |
| 1 | // com.atproto.sync.getRecord — the proof-carrying single-record read the membrane was missing. | |
| 2 | // | |
| 3 | // The membrane served getRepo (whole repo) and repo.getRecord (hydrated JSON, unverifiable) but not the | |
| 4 | // verifiable middle, so no client could check ONE of our records without pulling the entire repo. Found | |
| 5 | // by pointing our own inbound verifier at our own PDS and getting a 404. | |
| 6 | // | |
| 7 | // The assertion that matters is the ROUND TRIP: a consumer must be able to verify the CAR we emit | |
| 8 | // exactly as it would verify any other PDS's — signature over the commit, MST inclusion at | |
| 9 | // <collection>/<rkey>, record bytes hashing to the proven CID. Anything less is a proof in name only. | |
| 10 | import test from 'brittle' | |
| 11 | import { createRepo, readCar, loadCommit, getBlock } from 'atproto-repo/lib/repo.js' | |
| 12 | import { verifyCommit } from 'atproto-repo/lib/commit.js' | |
| 13 | import { cidForBytesSync } from 'atproto-repo/lib/cid.js' | |
| 14 | import { nodeStoreFromBlocks, findRpathProof } from 'atproto-repo/lib/mst.js' | |
| 15 | import { keyFromPrivate } from 'atproto-identity/lib/atproto-identity.js' | |
| 16 | import { buildRecordProofCar } from '../lib/sync-get-record.js' | |
| 17 | ||
| 18 | const KEY = keyFromPrivate(Buffer.alloc(32, 11)) | |
| 19 | const DID = 'did:plc:proofsubject0000000000' | |
| 20 | ||
| 21 | async function repoWith (n = 6) { | |
| 22 | return createRepo({ | |
| 23 | did: DID, | |
| 24 | rev: '3lddd', | |
| 25 | privateKey: KEY.privateKey, | |
| 26 | records: Array.from({ length: n }, (_, i) => ({ | |
| 27 | collection: 'app.bsky.feed.post', | |
| 28 | rkey: `rk${i}`, | |
| 29 | record: { $type: 'app.bsky.feed.post', text: `post ${i}`, createdAt: '2026-07-30T00:00:00Z' } | |
| 30 | })) | |
| 31 | }) | |
| 32 | } | |
| 33 | ||
| 34 | test('the emitted CAR verifies end-to-end, exactly as a consumer would check it', async (t) => { | |
| 35 | const repo = await repoWith(6) | |
| 36 | const { carBytes, found } = await buildRecordProofCar(repo.carBytes, 'app.bsky.feed.post', 'rk3') | |
| 37 | t.ok(found, 'the record is reported present') | |
| 38 | ||
| 39 | const car = await readCar(carBytes) | |
| 40 | const commit = loadCommit(car) | |
| 41 | ||
| 42 | t.is(commit.value.did, DID, 'the signed commit is the root, so everything else hangs off something signed') | |
| 43 | t.ok(verifyCommit(commit.value, KEY.pubkey ?? KEY.publicKey), 'the commit signature verifies against the repo key') | |
| 44 | ||
| 45 | const ns = nodeStoreFromBlocks(car.blocks) | |
| 46 | const { value: recordCid } = findRpathProof(ns, commit.value.data, 'app.bsky.feed.post/rk3') | |
| 47 | t.ok(recordCid, 'the covering path is complete enough to reach the key from the signed root') | |
| 48 | ||
| 49 | const block = getBlock(car, recordCid) | |
| 50 | t.is(cidForBytesSync(block.bytes).toString(), recordCid.toString(), 'and the record bytes hash to the CID the proof arrives at') | |
| 51 | }) | |
| 52 | ||
| 53 | test('the proof is a SLICE, not the whole repo — that is the entire point', async (t) => { | |
| 54 | const repo = await repoWith(24) | |
| 55 | const { carBytes } = await buildRecordProofCar(repo.carBytes, 'app.bsky.feed.post', 'rk7') | |
| 56 | const full = await readCar(repo.carBytes) | |
| 57 | const slice = await readCar(carBytes) | |
| 58 | t.ok(slice.blocks.size < full.blocks.size, | |
| 59 | `slice ${slice.blocks.size} blocks < full ${full.blocks.size} — otherwise a consumer may as well call getRepo`) | |
| 60 | }) | |
| 61 | ||
| 62 | test('an ABSENT record proves non-existence, it does not 404', async (t) => { | |
| 63 | const repo = await repoWith(6) | |
| 64 | const { carBytes, found } = await buildRecordProofCar(repo.carBytes, 'app.bsky.feed.post', 'nosuchrkey') | |
| 65 | t.absent(found, 'reported as not found') | |
| 66 | ||
| 67 | const car = await readCar(carBytes) | |
| 68 | const commit = loadCommit(car) | |
| 69 | t.ok(verifyCommit(commit.value, KEY.pubkey ?? KEY.publicKey), | |
| 70 | 'but the signed commit is still returned — an exclusion proof is a real answer, and a 404 would be indistinguishable from "repo not here"') | |
| 71 | ||
| 72 | const ns = nodeStoreFromBlocks(car.blocks) | |
| 73 | const { value } = findRpathProof(ns, commit.value.data, 'app.bsky.feed.post/nosuchrkey') | |
| 74 | t.absent(value, 'and the path demonstrably terminates without the key') | |
| 75 | }) | |
| 76 | ||
| 77 | test('a consumer cannot be fooled by the slice: a DIFFERENT key is not provable from it', async (t) => { | |
| 78 | const repo = await repoWith(24) | |
| 79 | const { carBytes } = await buildRecordProofCar(repo.carBytes, 'app.bsky.feed.post', 'rk7') | |
| 80 | const car = await readCar(carBytes) | |
| 81 | const commit = loadCommit(car) | |
| 82 | const ns = nodeStoreFromBlocks(car.blocks) | |
| 83 | // rk19 exists in the real repo but is NOT on rk7's covering path, so it must not be resolvable here. | |
| 84 | let resolved = null | |
| 85 | try { resolved = findRpathProof(ns, commit.value.data, 'app.bsky.feed.post/rk19').value } catch { resolved = null } | |
| 86 | t.absent(resolved, 'a record outside the requested path cannot be proven from this slice') | |
| 87 | }) | |
| 88 | ||
| 89 | test('an unparseable repo CAR throws rather than emitting a bogus proof', async (t) => { | |
| 90 | let threw = false | |
| 91 | try { | |
| 92 | await buildRecordProofCar(new Uint8Array([1, 2, 3]), 'app.bsky.feed.post', 'rk0') | |
| 93 | } catch { | |
| 94 | threw = true | |
| 95 | } | |
| 96 | t.ok(threw, 'garbage in is an error, never a CAR a consumer would trust (the handler turns this into a 500)') | |
| 97 | }) |