// record-fetch.js — the fetch + verify halves of DESIGN §7.6's pipeline.
//
// Spacedust/Constellation tell us a record EXISTS. This module gets its bytes and proves them. Those
// are deliberately different jobs from different sources: the index is a hint, the author's PDS is the
// authority, and the DID document is the root of trust.
//
// ⛔ THE VERIFICATION KEY NEVER COMES FROM AN INDEX. `blue.microcosm.identity.resolveMiniDoc` returns a
// convenient `{did, handle, pds, signing_key}` bundle, and it is tempting to use all four. Do not use
// its `signing_key`. If we verified against a key handed to us by the same class of service that told
// us the record exists, a compromised or hostile index could serve forged records that verify. The key
// MUST come from the DID document (PLC directory for did:plc). An index may accelerate the *endpoint*
// lookup — a wrong PDS just fails to serve, which is safe — but never the key.
//
// WHY sync.getRecord AND NOT repo.getRecord. `com.atproto.repo.getRecord` returns hydrated JSON: no
// commit, no signature, no MST proof, and not byte-exact, so nothing about it can be verified.
// `com.atproto.sync.getRecord` returns a CAR carrying the signed commit plus the MST blocks that prove
// the record's inclusion at its path. Only the second can back a retention decision.
//
// WHAT "VERIFIED" MEANS HERE — all four must hold:
// 1. the commit's `did` is the DID we asked about (no cross-repo substitution)
// 2. the commit signature validates against the DID doc's atproto key
// 3. the record CID is provably at `<collection>/<rkey>` under the commit's MST root
// 4. the record bytes hash to that CID (bytes match their own address)
//
// FAILS SOFT. Every path returns null/false rather than throwing. An unfetchable or unverifiable record
// is simply not retained; §7.6 makes that recoverable (Constellation can re-offer the same pointer).
//
// PURE + INJECTED. `doFetch`, the resolver and the clock are parameters, so every branch is testable
// with no network.
import { readCar, getBlock, loadCommit } from 'atproto-repo/lib/repo.js'
import { verifyCommit } from 'atproto-repo/lib/commit.js'
import { cidForBytesSync } from 'atproto-repo/lib/cid.js'
import { nodeStoreFromBlocks, findRpathProof } from 'atproto-repo/lib/mst.js'
import { decodeDidKey, CURVE_SECP256K1 } from 'atproto-identity/lib/did-key.js'
import { PlcDirectory } from 'atproto-identity/lib/plc-directory.js'
const IDENTITY_TTL_MS = 15 * 60 * 1000 // DID docs change rarely; a rotation is the thing this misses
// The MST key for a record. atproto keys the tree by `<collection>/<rkey>` as UTF-8 bytes.
export function recordKey ({ collection, rkey }) {
return `${collection}/${rkey}`
}
// A DID doc's atproto verification method is a did:key; resolveMiniDoc-style sources hand back the bare
// `publicKeyMultibase` body instead. Accept either, since they are the same string with/without prefix.
export function toDidKey (value) {
if (typeof value !== 'string' || value.length === 0) return null
return value.startsWith('did:key:') ? value : `did:key:${value}`
}
// Pull the atproto signing key + PDS endpoint out of a PLC `/data` response.
// Shape: { did, verificationMethods: { atproto }, services: { atproto_pds: { endpoint } } }
export function extractIdentity (data) {
if (!data || typeof data !== 'object') return null
const didKey = toDidKey(data.verificationMethods && data.verificationMethods.atproto)
const svc = data.services && data.services.atproto_pds
const pds = svc && typeof svc.endpoint === 'string' ? svc.endpoint.replace(/\/+$/, '') : null
if (didKey === null || pds === null) return null
let pubkey
try {
const decoded = decodeDidKey(didKey)
// atproto permits secp256k1 and P-256; our verifyCommit is secp256k1-only. Reject rather than
// silently mis-verify — a P-256 repo should surface as unsupported, not as "invalid signature".
if (decoded.curve !== CURVE_SECP256K1) return null
pubkey = decoded.pubkey
} catch {
return null
}
return { did: data.did, didKey, pubkey, pds }
}
// Authoritative identity resolution with a small TTL cache.
//
// did:plc only. did:web would need a `/.well-known/did.json` fetch — not wired, and returning null is
// the honest failure (records from did:web repos are simply not retained yet) rather than guessing.
export function createIdentityResolver ({ fetchImpl, plc = null, ttlMs = IDENTITY_TTL_MS, now = () => Date.now() } = {}) {
const directory = plc || new PlcDirectory({ fetchImpl })
const cache = new Map()
return async function resolveIdentity (did) {
if (typeof did !== 'string' || !did.startsWith('did:plc:')) return null
const hit = cache.get(did)
if (hit && hit.expires > now()) return hit.value
let identity = null
try {
identity = extractIdentity(await directory.getData(did))
} catch {
identity = null // network/404/malformed — fail soft, retry on the next pointer
}
// Cache negatives too, briefly, so a dead DID cannot drive a fetch storm through the drain.
cache.set(did, { value: identity, expires: now() + (identity === null ? 60_000 : ttlMs) })
return identity
}
}
// ---- fetchRecord ----------------------------------------------------------------------------------
// Build the drain's `fetchRecord` hook.
// fetchRecord({ did, collection, rkey }) -> { car, commit, recordCid, bytes, value, identity } | null
//
// Returns the parsed CAR alongside the record so `verifyRecord` does not re-parse. Nothing here is
// trusted yet — this function only OBTAINS; verification is the next step and is not optional.
export function createFetchRecord ({ doFetch, resolveIdentity, timeoutMs = 10_000 } = {}) {
if (typeof doFetch !== 'function') throw new Error('createFetchRecord requires doFetch')
if (typeof resolveIdentity !== 'function') throw new Error('createFetchRecord requires resolveIdentity')
return async function fetchRecord (pointer) {
const identity = await resolveIdentity(pointer.did)
if (identity === null) return null
const url = `${identity.pds}/xrpc/com.atproto.sync.getRecord` +
`?did=${encodeURIComponent(pointer.did)}` +
`&collection=${encodeURIComponent(pointer.collection)}` +
`&rkey=${encodeURIComponent(pointer.rkey)}`
let carBytes
try {
const res = await doFetch(url, {
method: 'GET',
headers: { accept: 'application/vnd.ipld.car' },
timeout: timeoutMs
})
if (!res || res.ok === false) return null
const buf = await res.arrayBuffer()
carBytes = new Uint8Array(buf)
} catch {
return null
}
if (!carBytes || carBytes.length === 0) return null
try {
const car = await readCar(carBytes)
const commit = loadCommit(car)
const ns = nodeStoreFromBlocks(car.blocks)
// Targeted descent, NOT a full walk: a proof CAR contains only the covering path, so walking the
// whole tree would throw on absent sibling blocks.
const { value: recordCid } = findRpathProof(ns, commit.value.data, recordKey(pointer))
if (!recordCid) return null // exclusion proof — the record is genuinely not in the repo
const block = getBlock(car, recordCid)
return {
car,
commit,
recordCid,
bytes: block.bytes,
identity
}
} catch {
return null // malformed CAR, missing block, not an MST node — all "cannot use this"
}
}
}
// ---- verifyRecord ---------------------------------------------------------------------------------
// Build the drain's `verifyRecord` hook. Pure: bytes + already-resolved identity in, boolean out. No
// store access and no I/O, which is exactly why this is the one step that could later move to a
// `bare-thread` worker behind `bare-channel` if a profile ever justifies it.
export function createVerifyRecord ({ onFailure = null } = {}) {
return async function verifyRecord (record, pointer) {
if (!record || !record.commit || !record.identity) return false
const fail = (reason) => {
if (onFailure) onFailure(reason, pointer)
return false
}
const commit = record.commit.value
// 1. The commit must be for the repo we asked about. Without this, a validly-signed commit from a
// DIFFERENT repo would satisfy every other check.
if (commit.did !== pointer.did) return fail('did-mismatch')
// 2. Signature over the commit, against the key from the DID DOCUMENT (never from an index).
let sigOk = false
try {
sigOk = verifyCommit(commit, record.identity.pubkey)
} catch {
sigOk = false
}
if (!sigOk) return fail('bad-signature')
// 3. Inclusion: the record CID must be provably at <collection>/<rkey> under the SIGNED commit's
// MST root. Re-derived here rather than trusting what fetch found, so verify stands alone.
let provenCid = null
try {
const ns = nodeStoreFromBlocks(record.car.blocks)
provenCid = findRpathProof(ns, commit.data, recordKey(pointer)).value
} catch {
return fail('proof-failed')
}
if (!provenCid || provenCid.toString() !== record.recordCid.toString()) return fail('not-in-repo')
// 4. The bytes must hash to that CID — otherwise a correct proof could be paired with wrong bytes.
let bytesOk = false
try {
bytesOk = cidForBytesSync(record.bytes).toString() === record.recordCid.toString()
} catch {
bytesOk = false
}
if (!bytesOk) return fail('cid-mismatch')
// NOTE: we deliberately do NOT require commit.rev to equal pointer.rev. getRecord returns the
// CURRENT commit, which is legitimately newer than the commit that created the link. The rev
// skip-test in link-drain.js is about what we already hold, not about proof validity.
return true
}
}