OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / test / record-verify.test.mjs

200 lines · javascript ref:
// record fetch + verify — against a REAL signed repo, not mocks.
// Run: node test/record-verify.test.mjs
//
// Every CAR here is built by atproto-repo's own createRepo with a real secp256k1 signature, then MUTATED
// to produce each negative case. That is the point: a verifier tested only against fixtures it also
// generated proves nothing. The four checks each get a case that should FAIL, because a verifier that
// cannot reject is not a verifier.
import { createRepo } from 'atproto-repo/lib/repo.js'
import { keyFromPrivate } from 'atproto-identity/lib/atproto-identity.js'
import {
  createFetchRecord, createVerifyRecord, createIdentityResolver,
  extractIdentity, toDidKey, recordKey
} from '../lib/record-fetch.js'

let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log('  ok -', m); pass++ }

// Deterministic keys — a fixed scalar makes failures reproducible.
const alice = keyFromPrivate(Buffer.alloc(32, 7))
const mallory = keyFromPrivate(Buffer.alloc(32, 9))
const ALICE_DID = 'did:plc:alice000000000000000000'
const PDS = 'https://pds.alice.test'

const REC = { collection: 'app.bsky.feed.post', rkey: '3kdef' }
const pointer = (over = {}) => ({ did: ALICE_DID, ...REC, rev: '3lbbb', ...over })

const repo = await createRepo({
  did: ALICE_DID,
  rev: '3lbbb',
  privateKey: alice.privateKey,
  records: [
    { collection: 'app.bsky.feed.post', rkey: '3kdef', record: { $type: 'app.bsky.feed.post', text: 'hello', createdAt: '2026-07-30T00:00:00Z' } },
    { collection: 'app.bsky.feed.post', rkey: '3kaaa', record: { $type: 'app.bsky.feed.post', text: 'other', createdAt: '2026-07-30T00:00:00Z' } }
  ]
})

const plcData = (didKey = alice.didKey, endpoint = PDS) => ({
  did: ALICE_DID,
  verificationMethods: { atproto: didKey },
  services: { atproto_pds: { endpoint } }
})

const carResponse = (bytes) => ({
  ok: true,
  arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
})

const resolverFor = (data) => createIdentityResolver({ plc: { getData: async () => data } })

// ── identity extraction ──────────────────────────────────────────────────────────────────────────
ok(toDidKey('zQ3shokFTS3brHcDQrn') === 'did:key:zQ3shokFTS3brHcDQrn', 'a bare publicKeyMultibase is normalised to a did:key')
ok(toDidKey('did:key:zQ3s') === 'did:key:zQ3s', 'an already-prefixed did:key is left alone')
ok(toDidKey(null) === null, 'a missing key is null, not a crash')

{
  const id = extractIdentity(plcData())
  ok(id !== null && id.pds === PDS, 'the PDS endpoint comes out of the DID document')
  ok(id.pubkey.length === 33, 'and the signing key is decoded to a 33-byte compressed point')
  ok(extractIdentity({ did: ALICE_DID, services: {} }) === null, 'a doc with no verificationMethod is unusable')
  ok(extractIdentity({ did: ALICE_DID, verificationMethods: { atproto: alice.didKey }, services: {} }) === null, 'a doc with no PDS is unusable')
  ok(extractIdentity(plcData(PDS + '/')) === null, 'a non-did:key verification method is refused rather than guessed at')
}
{
  // P-256 is legal atproto but our verifyCommit is secp256k1-only. Rejecting is right; mis-verifying
  // would surface as "invalid signature" and send someone hunting the wrong bug.
  const p256 = 'did:key:zDnaerDaTF5BXEavCrfRZEk316dpbLsfPDZ3WJ5hRTPFU2169'
  ok(extractIdentity(plcData(p256)) === null, 'a P-256 repo is reported unsupported, NOT mis-verified as a bad signature')
}

// ── resolver: scope + caching ────────────────────────────────────────────────────────────────────
{
  let calls = 0
  const r = createIdentityResolver({ plc: { getData: async () => { calls++; return plcData() } } })
  ok((await r(ALICE_DID)) !== null && calls === 1, 'a did:plc resolves')
  await r(ALICE_DID)
  ok(calls === 1, 'and is cached — a burst of pointers for one author must not hammer the PLC directory')
  ok((await r('did:web:example.com')) === null && calls === 1, 'did:web is UNSUPPORTED and fails closed — those records are simply not retained yet')
  ok((await r('not-a-did')) === null, 'garbage resolves to null')
}
{
  let calls = 0
  const r = createIdentityResolver({ plc: { getData: async () => { calls++; throw new Error('502') } } })
  ok((await r(ALICE_DID)) === null, 'a directory failure fails soft')
  await r(ALICE_DID)
  ok(calls === 1, 'and negatives are cached briefly so a dead DID cannot drive a fetch storm through the drain')
}

// ── fetchRecord ──────────────────────────────────────────────────────────────────────────────────
const fetchOk = createFetchRecord({
  doFetch: async () => carResponse(repo.carBytes),
  resolveIdentity: resolverFor(plcData())
})

{
  const got = await fetchOk(pointer())
  ok(got !== null, 'a real CAR yields a record')
  ok(got.commit.value.did === ALICE_DID, 'the signed commit rides along')
  ok(got.bytes && got.bytes.length > 0, 'and the record bytes are extracted from the proof')
}
{
  let url = null
  const f = createFetchRecord({
    doFetch: async (u) => { url = u; return carResponse(repo.carBytes) },
    resolveIdentity: resolverFor(plcData())
  })
  await f(pointer())
  ok(url.startsWith(PDS + '/xrpc/com.atproto.sync.getRecord'),
    'we call sync.getRecord — repo.getRecord returns hydrated JSON with no commit, signature or proof, so nothing about it could be verified')
  ok(url.includes('collection=app.bsky.feed.post') && url.includes('rkey=3kdef'), 'with the record addressed in the query')
}
{
  const absent = await fetchOk(pointer({ rkey: 'nosuchrkey' }))
  ok(absent === null, 'a record absent from the tree yields null — findRpathProof returns an EXCLUSION proof, which is a real answer, not an error')
}
{
  let fetched = 0
  const f = createFetchRecord({
    doFetch: async () => { fetched++; return carResponse(repo.carBytes) },
    resolveIdentity: async () => null
  })
  ok((await f(pointer())) === null && fetched === 0, 'an unresolvable identity short-circuits BEFORE any network call')
}
{
  const f = createFetchRecord({ doFetch: async () => ({ ok: false }), resolveIdentity: resolverFor(plcData()) })
  ok((await f(pointer())) === null, 'a non-ok response is null')
}
{
  const f = createFetchRecord({ doFetch: async () => carResponse(new Uint8Array(0)), resolveIdentity: resolverFor(plcData()) })
  ok((await f(pointer())) === null, 'an empty body is null')
}
{
  const f = createFetchRecord({ doFetch: async () => carResponse(new Uint8Array([1, 2, 3])), resolveIdentity: resolverFor(plcData()) })
  ok((await f(pointer())) === null, 'a malformed CAR is null, not a throw')
}

// ── verifyRecord: the happy path ─────────────────────────────────────────────────────────────────
const reasons = []
const verify = createVerifyRecord({ onFailure: (r) => reasons.push(r) })
const good = await fetchOk(pointer())

ok((await verify(good, pointer())) === true, 'a genuine record from a genuinely signed repo verifies')
ok(recordKey(REC) === 'app.bsky.feed.post/3kdef', 'the MST key is <collection>/<rkey>')

// ── verifyRecord: each check must be able to REJECT ──────────────────────────────────────────────

// 1. cross-repo substitution: a validly-signed commit for the WRONG repo.
{
  reasons.length = 0
  ok((await verify(good, pointer({ did: 'did:plc:someoneelse' }))) === false,
    'a commit whose did != the DID we asked about is REJECTED — without this check, a validly-signed commit from another repo satisfies everything else')
  ok(reasons[0] === 'did-mismatch', 'and the reason is specific')
}

// 2. wrong signing key: same bytes, an identity that claims a different key.
{
  reasons.length = 0
  const wrongKey = await createFetchRecord({
    doFetch: async () => carResponse(repo.carBytes),
    resolveIdentity: resolverFor(plcData(mallory.didKey))
  })(pointer())
  ok((await verify(wrongKey, pointer())) === false,
    'a commit that does not verify against the DID doc key is REJECTED — this is why the key must never come from the same index that offered the pointer')
  ok(reasons[0] === 'bad-signature', 'reported as a signature failure')
}

// 3. right repo, wrong record: the proof proves a DIFFERENT path than the one claimed.
{
  reasons.length = 0
  const other = await fetchOk(pointer({ rkey: '3kaaa' }))
  const swapped = { ...good, recordCid: other.recordCid, bytes: other.bytes }
  ok((await verify(swapped, pointer())) === false,
    'a record from a different path in the SAME repo is REJECTED — inclusion is re-derived in verify, so fetch cannot vouch for itself')
  ok(reasons[0] === 'not-in-repo', 'reported as not-in-repo')
}

// 4. tampered bytes: correct proof, altered content.
{
  reasons.length = 0
  const tampered = { ...good, bytes: Uint8Array.from(good.bytes) }
  tampered.bytes[tampered.bytes.length - 1] ^= 0xff
  ok((await verify(tampered, pointer())) === false,
    'bytes that do not hash to their CID are REJECTED — a valid proof paired with wrong bytes must not pass')
  ok(reasons[0] === 'cid-mismatch', 'reported as a CID mismatch')
}

// Malformed inputs must not throw.
{
  ok((await verify(null, pointer())) === false, 'a null record is false, not a crash')
  ok((await verify({}, pointer())) === false, 'a record with no commit is false')
  ok((await verify({ ...good, identity: null }, pointer())) === false, 'a record with no resolved identity cannot be verified')
}

// A newer commit rev than the link's rev is LEGITIMATE — getRecord returns the CURRENT commit.
{
  ok((await verify(good, pointer({ rev: '3laaa' }))) === true,
    'verification does not require commit.rev == pointer.rev — getRecord returns the current commit, which is legitimately newer than the one that created the link')
}

console.log(`\nRECORD-VERIFY OK: ${pass} assertions`)