OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / record-fetch.js

212 lines · javascript ref:
// 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
  }
}