OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge commits / ab2299c

Serve com.atproto.sync.getRecord — the proof-carrying read we were missing
The membrane served getRepo (the WHOLE repo) and repo.getRecord (hydrated JSON: no commit, no signature, no MST proof, therefore unverifiable) but not the verifiable middle. So no client could check ONE of our records without pulling the entire repo — a hole in our federation surface, not just an internal gap. Found by pointing our own favela inbound verifier at our own PDS: 2 of 3 fetches failed with HTTP 404 from pds.7rnx.net. The records were ours. Returns a CAR holding the signed commit + the MST covering path + the record block, so a consumer verifies exactly as against any other PDS: signature over the commit, inclusion at <collection>/<rkey>, record bytes hashing to the proven CID. Measured: 4 blocks vs 30 for a 24-record repo. Per the lexicon an ABSENT record proves NON-EXISTENCE rather than 404ing — same commit + covering path, no record block. A 404 would be indistinguishable from "this repo is not served here". Proof construction lives in lib/sync-get-record.js rather than inline, because server-bare.js imports bare-http1 and so cannot be tested under node. Tests (brittle-node) cover the verify round-trip, the slice being strictly smaller than the repo, the exclusion proof, that a key OUTSIDE the requested path is not provable from the slice, and that a bad CAR throws instead of emitting a proof a consumer might trust. 5/5, 11/11. NOT deployed. Builds the proof from front.getRepo(), so cost scales with repo size for now; a targeted block read is the optimisation if it matters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ab2299cb430bc4871fbf8c96ddd6dd6c526008a3
by plankatron <fleeky@gmail.com> · 2026-07-31T13:38:11.000Z

Changed files (3) +173 -0

pds/lib/server-bare.js +29 -0
……@@ -10,6 +10,7 @@ import http from 'bare-http1'
1010import https from 'bare-https'
1111import bareWs from 'bare-ws'
1212import { tidNow } from 'atproto-store/lib/tid.js'
13import { buildRecordProofCar } from './sync-get-record.js'
1314import { proxyToAppView, isAppViewMethod } from './appview.js'
1415import { proxyToFavela, isFavelaMethod, buildMembraneDidDoc } from './favela-proxy.js'
1516
……@@ -178,6 +179,34 @@ export function createPdsServer ({ peers = null, serverDid = null, availableUser
178179 res.writeHead(200, { 'content-type': 'application/vnd.ipld.car', 'content-length': buf.length, ...CORS })
179180 return res.end(buf)
180181 }
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 }
181210 case 'com.atproto.repo.getRecord': {
182211 const front = await frontIfPeer(q.get('repo') || q.get('did') || undefined)
183212 if (!front) return sendErr(res, 404, 'RecordNotFound', 'record not found')
pds/lib/sync-get-record.js +47 -0
……@@ -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
21import { readCar, writeCar, loadCommit, getBlock } from 'atproto-repo/lib/repo.js'
22import { nodeStoreFromBlocks, findRpathProof } from 'atproto-repo/lib/mst.js'
23
24// carBytes: the full repo CAR (front.getRepo()). Returns { carBytes, found } or throws.
25export 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}
pds/test/sync-get-record.test.js +97 -0
……@@ -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.
10import test from 'brittle'
11import { createRepo, readCar, loadCommit, getBlock } from 'atproto-repo/lib/repo.js'
12import { verifyCommit } from 'atproto-repo/lib/commit.js'
13import { cidForBytesSync } from 'atproto-repo/lib/cid.js'
14import { nodeStoreFromBlocks, findRpathProof } from 'atproto-repo/lib/mst.js'
15import { keyFromPrivate } from 'atproto-identity/lib/atproto-identity.js'
16import { buildRecordProofCar } from '../lib/sync-get-record.js'
17
18const KEY = keyFromPrivate(Buffer.alloc(32, 11))
19const DID = 'did:plc:proofsubject0000000000'
20
21async 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
34test('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
53test('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
62test('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
77test('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
89test('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})