// 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 })
}
}