OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-identity / lib / plc-directory.js

177 lines · javascript ref:
// PLC Directory submit ceremony — the SUBMIT half of did:plc (the offline half lives in
// did-plc.js / atproto-identity.js and is byte-exact-proven). See the did-plc skill +
// atproto-base/docs/did-plc-contract-reference.md.
//
// ⚠️ POST /:did is a PERMANENT, irreversible append to the global PLC op-log (nullifiable only
// by a higher-authority fork within a 72h window). So this module is OFFLINE-FIRST: the op
// construction (elsewhere), the §6 validation MIRROR (checkGenesisOp / checkUpdateOp), and the
// ceremony orchestration are all pure/injectable and fully tested WITHOUT the network. The only
// I/O is the injectable `fetchImpl`; nothing here touches the live directory at import.
//
// The self-custody sequence (did-plc skill §5):
//   GET /:did/log/last → live head → opCid(head) == our prev → build full-state op → sign with a
//   CURRENT rotation key → LOCALLY re-validate (§6) → POST /:did → confirm via GET /:did/data.

import { sha256 } from '@noble/hashes/sha2'
import { secp256k1 } from '@noble/curves/secp256k1'
import { p256 } from '@noble/curves/p256'
import { encodeOp, unsignedOp, didForCreateOp, opCid } from './did-plc.js'
import { base64urlToBytes, rotateVerificationMethod } from './atproto-identity.js'
import { decodeDidKey, CURVE_P256 } from './did-key.js'

const DEFAULT_PLC = 'https://plc.directory'

function err (message) { return { ok: false, error: message } }

// ── §6 local validation mirror (pure — run BEFORE the irreversible write) ──

// Verify an atproto ECDSA signature: sig over sha256(messageBytes) against a did:key, honoring
// its curve (secp256k1 or P-256), low-S enforced (§2 — high-S and DER-encoded sigs are rejected).
// The atproto signing primitive. Returns false on any decode/verify error (never throws).
export function verifySig (messageBytes, sigBytes, didKey) {
  let dk
  try { dk = decodeDidKey(didKey) } catch { return false }
  // atproto requires the raw 64-byte (r‖s) compact form — DER-encoded sigs (longer) are INVALID
  // even if cryptographically sound (conformance rule §2). noble would otherwise parse DER.
  if (!sigBytes || sigBytes.length !== 64) return false
  const hash = sha256(messageBytes)
  const curve = dk.curve === CURVE_P256 ? p256 : secp256k1
  try { return curve.verify(sigBytes, hash, dk.pubkey, { lowS: true }) } catch { return false }
}

// Verify a PLC op's signature (over dag-cbor of the unsigned op) against a did:key.
export function verifyOpSig (signedOp, didKey) {
  let sig
  try { sig = base64urlToBytes(signedOp.sig) } catch { return false }
  return verifySig(encodeOp(unsignedOp(signedOp)), sig, didKey)
}

// True iff the op is signed by AT LEAST ONE of the given rotation did:keys (the directory
// accepts if any current rotation key matches — did-plc skill §2).
export function verifyOpSignedByAny (signedOp, rotationDidKeys) {
  return Array.isArray(rotationDidKeys) && rotationDidKeys.some((rk) => verifyOpSig(signedOp, rk))
}

// Local mirror of the directory's genesis validation (§6.2): plc_operation shape, prev:null,
// DID hashes from the op, sig by one of its OWN rotationKeys.
export function checkGenesisOp (signedOp, did) {
  if (!signedOp || signedOp.type !== 'plc_operation') return err('not a plc_operation')
  if (signedOp.prev !== null) return err('genesis prev must be null')
  if (!Array.isArray(signedOp.rotationKeys) || signedOp.rotationKeys.length === 0) return err('no rotationKeys')
  if (!signedOp.verificationMethods || !signedOp.verificationMethods.atproto) return err('no atproto verificationMethod')
  if (!signedOp.sig) return err('op is unsigned')
  let computed
  try { computed = didForCreateOp(signedOp) } catch (e) { return err('DID computation failed: ' + e.message) }
  if (did && computed !== did) return err('DID mismatch: op hashes to ' + computed + ', not ' + did)
  if (!verifyOpSignedByAny(signedOp, signedOp.rotationKeys)) return err('sig not by any of its own rotationKeys')
  return { ok: true, error: null, did: computed }
}

// Local mirror of the directory's forward-extension validation (§6.3-4): the op chains onto
// `prevSignedOp` (prev == its CID) and is signed by one of the HEAD op's rotationKeys.
export function checkUpdateOp (signedOp, prevSignedOp) {
  if (!signedOp || (signedOp.type !== 'plc_operation' && signedOp.type !== 'plc_tombstone')) return err('not a plc op')
  if (typeof signedOp.prev !== 'string' || signedOp.prev.length === 0) return err('update prev must be the head op CID')
  if (!signedOp.sig) return err('op is unsigned')
  if (!prevSignedOp) return err('no prev (head) op to validate against')
  let prevCid
  try { prevCid = opCid(prevSignedOp) } catch (e) { return err('prev CID computation failed: ' + e.message) }
  if (signedOp.prev !== prevCid) return err('prev != head CID (' + signedOp.prev + ' != ' + prevCid + ')')
  if (!verifyOpSignedByAny(signedOp, prevSignedOp.rotationKeys)) return err('sig not by a current (head) rotationKey')
  return { ok: true, error: null }
}

// Normalize a fetched op to EXACTLY the signed-op fields, so opCid() hashes the canonical bytes
// regardless of whether the endpoint wrapped it (`.operation`) or added audit fields (cid/…).
function opFieldsOnly (o) {
  const op = o && o.operation ? o.operation : o
  if (!op || typeof op !== 'object') return op
  if (op.type === 'plc_tombstone') return { type: op.type, prev: op.prev, sig: op.sig }
  return {
    type: op.type,
    rotationKeys: op.rotationKeys,
    verificationMethods: op.verificationMethods,
    alsoKnownAs: op.alsoKnownAs,
    services: op.services,
    prev: op.prev,
    sig: op.sig
  }
}

async function plcError (res, ctx) {
  let body = null
  try { body = await res.json() } catch { try { body = await res.text() } catch { /* ignore */ } }
  const msg = (body && body.message) || (body && body.error) || (typeof body === 'string' && body) || res.statusText || 'error'
  const e = new Error('PLC ' + ctx + ' failed (' + res.status + '): ' + msg)
  e.status = res.status
  e.plc = body
  return e
}

// ── the directory client + ceremony (injectable fetch — offline-testable) ──

export class PlcDirectory {
  constructor ({ baseUrl = DEFAULT_PLC, fetchImpl } = {}) {
    const f = fetchImpl || (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null)
    if (typeof f !== 'function') throw new Error('PlcDirectory: no fetch available — pass { fetchImpl }')
    this.baseUrl = String(baseUrl).replace(/\/+$/, '')
    this._fetch = f
  }

  async _get (path) {
    const res = await this._fetch(this.baseUrl + path, { method: 'GET' })
    if (!res.ok) throw await plcError(res, 'GET ' + path)
    return res.json()
  }

  // GET /:did/log/last → the head op (normalized to canonical op fields for opCid).
  async getHead (did) { return opFieldsOnly(await this._get('/' + did + '/log/last')) }
  // GET /:did/data → current PLC state { did, verificationMethods, rotationKeys, alsoKnownAs, services }.
  getData (did) { return this._get('/' + did + '/data') }
  // GET /:did → the DID document.
  resolve (did) { return this._get('/' + did) }
  // GET /:did/log/audit → the audit log (incl. nullified ops).
  getAuditLog (did) { return this._get('/' + did + '/log/audit') }

  // POST /:did — the raw irreversible append (body = the signed op, unwrapped). No local check;
  // callers should use submitGenesis / submitUpdate which validate first.
  async submitRaw (did, signedOp) {
    const res = await this._fetch(this.baseUrl + '/' + did, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(signedOp)
    })
    if (!res.ok) throw await plcError(res, 'POST /' + did)
    return true
  }

  // Submit a genesis op: local §6.2 validation → POST → confirm via getData.
  async submitGenesis ({ did, signedOp, confirm = true }) {
    const chk = checkGenesisOp(signedOp, did)
    if (!chk.ok) throw new Error('refusing to submit invalid genesis: ' + chk.error)
    await this.submitRaw(did, signedOp)
    return confirm ? this.getData(did) : true
  }

  // Submit an update op: fetch the LIVE head, require our op chains onto it (prev == opCid(head))
  // AND passes §6 validation, then POST → confirm. Re-fetching the head at submit time closes the
  // build-vs-submit TOCTOU (a MisorderedOperation reject on the live directory otherwise).
  async submitUpdate ({ did, signedOp, confirm = true }) {
    const head = await this.getHead(did)
    if (signedOp.prev !== opCid(head)) throw new Error('refusing to submit: op.prev != live head CID — refetch the head and rebuild')
    const chk = checkUpdateOp(signedOp, head)
    if (!chk.ok) throw new Error('refusing to submit invalid update: ' + chk.error)
    await this.submitRaw(did, signedOp)
    return confirm ? this.getData(did) : true
  }

  // The device-attestation migration primitive: rotate verificationMethods.atproto to a device
  // key on-chain. Fetches the live head, builds a root-signed rotation off it, validates, submits.
  // signerPrivateKey must correspond to a CURRENT rotation key (the root).
  async rotateVerificationMethodOnChain ({ did, newAtprotoDidKey, signerPrivateKey, confirm = true }) {
    const head = await this.getHead(did)
    const op = rotateVerificationMethod({ prevSignedOp: head, newAtprotoDidKey, signerPrivateKey })
    return this.submitUpdate({ did, signedOp: op, confirm })
  }
}