OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-identity commits / bfae211

PLC submit ceremony (offline-first) — the submit half of did:plc
The offline half (op construction, DID/prev/sig) was proven; the submit half was entirely missing (per the did-plc skill §7). Add lib/plc-directory.js: - §6 local validation mirror (pure): verifyOpSig (curve-aware secp256k1+p256, low-S), verifyOpSignedByAny, checkGenesisOp, checkUpdateOp — run BEFORE the irreversible write. - PlcDirectory client (injectable fetchImpl): getHead (/log/last, normalized to canonical op fields for stable opCid), getData, resolve, getAuditLog, submitRaw (POST /:did). - Ceremony: submitGenesis (validate→POST→confirm), submitUpdate (fetch LIVE head → require prev==opCid(head) → validate → POST → confirm; closes the build-vs-submit TOCTOU), rotateVerificationMethodOnChain (the device-attestation migration primitive). test/plc-directory.test.js 10/10 (29 asserts) — mock in-memory directory, ZERO network: validators, client round-trip, audit-extra normalization, genesis→rotation happy path (DID unchanged, prev chained), and guards (prev!=head → no POST; invalid genesis → no POST; HTTP error surfaces status+message). Full suite green. Still missing (not needed for the wj62/s6cyl migration): recovery/nullification fork builder + tombstone builder. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bfae2114415b4317d5cf0a672d483923cde7808d
by plankatron <fleeky@gmail.com> · 2026-07-04T20:35:31.000Z

Changed files (2) +337 -0

lib/plc-directory.js +167 -0
……@@ -0,0 +1,167 @@
1// PLC Directory submit ceremony — the SUBMIT half of did:plc (the offline half lives in
2// did-plc.js / atproto-identity.js and is byte-exact-proven). See the did-plc skill +
3// atproto-base/docs/did-plc-contract-reference.md.
4//
5// ⚠️ POST /:did is a PERMANENT, irreversible append to the global PLC op-log (nullifiable only
6// by a higher-authority fork within a 72h window). So this module is OFFLINE-FIRST: the op
7// construction (elsewhere), the §6 validation MIRROR (checkGenesisOp / checkUpdateOp), and the
8// ceremony orchestration are all pure/injectable and fully tested WITHOUT the network. The only
9// I/O is the injectable `fetchImpl`; nothing here touches the live directory at import.
10//
11// The self-custody sequence (did-plc skill §5):
12// GET /:did/log/last → live head → opCid(head) == our prev → build full-state op → sign with a
13// CURRENT rotation key → LOCALLY re-validate (§6) → POST /:did → confirm via GET /:did/data.
14
15import { sha256 } from '@noble/hashes/sha2'
16import { secp256k1 } from '@noble/curves/secp256k1'
17import { p256 } from '@noble/curves/p256'
18import { encodeOp, unsignedOp, didForCreateOp, opCid } from './did-plc.js'
19import { base64urlToBytes, rotateVerificationMethod } from './atproto-identity.js'
20import { decodeDidKey, CURVE_P256 } from './did-key.js'
21
22const DEFAULT_PLC = 'https://plc.directory'
23
24function err (message) { return { ok: false, error: message } }
25
26// ── §6 local validation mirror (pure — run BEFORE the irreversible write) ──
27
28// Verify an op's signature against a single did:key, honoring its curve (secp256k1 or P-256),
29// low-S enforced (§2). Returns false on any decode/verify error (never throws).
30export function verifyOpSig (signedOp, didKey) {
31 let dk
32 try { dk = decodeDidKey(didKey) } catch { return false }
33 let sig
34 try { sig = base64urlToBytes(signedOp.sig) } catch { return false }
35 const hash = sha256(encodeOp(unsignedOp(signedOp)))
36 const curve = dk.curve === CURVE_P256 ? p256 : secp256k1
37 try { return curve.verify(sig, hash, dk.pubkey, { lowS: true }) } catch { return false }
38}
39
40// True iff the op is signed by AT LEAST ONE of the given rotation did:keys (the directory
41// accepts if any current rotation key matches — did-plc skill §2).
42export function verifyOpSignedByAny (signedOp, rotationDidKeys) {
43 return Array.isArray(rotationDidKeys) && rotationDidKeys.some((rk) => verifyOpSig(signedOp, rk))
44}
45
46// Local mirror of the directory's genesis validation (§6.2): plc_operation shape, prev:null,
47// DID hashes from the op, sig by one of its OWN rotationKeys.
48export function checkGenesisOp (signedOp, did) {
49 if (!signedOp || signedOp.type !== 'plc_operation') return err('not a plc_operation')
50 if (signedOp.prev !== null) return err('genesis prev must be null')
51 if (!Array.isArray(signedOp.rotationKeys) || signedOp.rotationKeys.length === 0) return err('no rotationKeys')
52 if (!signedOp.verificationMethods || !signedOp.verificationMethods.atproto) return err('no atproto verificationMethod')
53 if (!signedOp.sig) return err('op is unsigned')
54 let computed
55 try { computed = didForCreateOp(signedOp) } catch (e) { return err('DID computation failed: ' + e.message) }
56 if (did && computed !== did) return err('DID mismatch: op hashes to ' + computed + ', not ' + did)
57 if (!verifyOpSignedByAny(signedOp, signedOp.rotationKeys)) return err('sig not by any of its own rotationKeys')
58 return { ok: true, error: null, did: computed }
59}
60
61// Local mirror of the directory's forward-extension validation (§6.3-4): the op chains onto
62// `prevSignedOp` (prev == its CID) and is signed by one of the HEAD op's rotationKeys.
63export function checkUpdateOp (signedOp, prevSignedOp) {
64 if (!signedOp || (signedOp.type !== 'plc_operation' && signedOp.type !== 'plc_tombstone')) return err('not a plc op')
65 if (typeof signedOp.prev !== 'string' || signedOp.prev.length === 0) return err('update prev must be the head op CID')
66 if (!signedOp.sig) return err('op is unsigned')
67 if (!prevSignedOp) return err('no prev (head) op to validate against')
68 let prevCid
69 try { prevCid = opCid(prevSignedOp) } catch (e) { return err('prev CID computation failed: ' + e.message) }
70 if (signedOp.prev !== prevCid) return err('prev != head CID (' + signedOp.prev + ' != ' + prevCid + ')')
71 if (!verifyOpSignedByAny(signedOp, prevSignedOp.rotationKeys)) return err('sig not by a current (head) rotationKey')
72 return { ok: true, error: null }
73}
74
75// Normalize a fetched op to EXACTLY the signed-op fields, so opCid() hashes the canonical bytes
76// regardless of whether the endpoint wrapped it (`.operation`) or added audit fields (cid/…).
77function opFieldsOnly (o) {
78 const op = o && o.operation ? o.operation : o
79 if (!op || typeof op !== 'object') return op
80 if (op.type === 'plc_tombstone') return { type: op.type, prev: op.prev, sig: op.sig }
81 return {
82 type: op.type,
83 rotationKeys: op.rotationKeys,
84 verificationMethods: op.verificationMethods,
85 alsoKnownAs: op.alsoKnownAs,
86 services: op.services,
87 prev: op.prev,
88 sig: op.sig
89 }
90}
91
92async function plcError (res, ctx) {
93 let body = null
94 try { body = await res.json() } catch { try { body = await res.text() } catch { /* ignore */ } }
95 const msg = (body && body.message) || (body && body.error) || (typeof body === 'string' && body) || res.statusText || 'error'
96 const e = new Error('PLC ' + ctx + ' failed (' + res.status + '): ' + msg)
97 e.status = res.status
98 e.plc = body
99 return e
100}
101
102// ── the directory client + ceremony (injectable fetch — offline-testable) ──
103
104export class PlcDirectory {
105 constructor ({ baseUrl = DEFAULT_PLC, fetchImpl } = {}) {
106 const f = fetchImpl || (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null)
107 if (typeof f !== 'function') throw new Error('PlcDirectory: no fetch available — pass { fetchImpl }')
108 this.baseUrl = String(baseUrl).replace(/\/+$/, '')
109 this._fetch = f
110 }
111
112 async _get (path) {
113 const res = await this._fetch(this.baseUrl + path, { method: 'GET' })
114 if (!res.ok) throw await plcError(res, 'GET ' + path)
115 return res.json()
116 }
117
118 // GET /:did/log/last → the head op (normalized to canonical op fields for opCid).
119 async getHead (did) { return opFieldsOnly(await this._get('/' + did + '/log/last')) }
120 // GET /:did/data → current PLC state { did, verificationMethods, rotationKeys, alsoKnownAs, services }.
121 getData (did) { return this._get('/' + did + '/data') }
122 // GET /:did → the DID document.
123 resolve (did) { return this._get('/' + did) }
124 // GET /:did/log/audit → the audit log (incl. nullified ops).
125 getAuditLog (did) { return this._get('/' + did + '/log/audit') }
126
127 // POST /:did — the raw irreversible append (body = the signed op, unwrapped). No local check;
128 // callers should use submitGenesis / submitUpdate which validate first.
129 async submitRaw (did, signedOp) {
130 const res = await this._fetch(this.baseUrl + '/' + did, {
131 method: 'POST',
132 headers: { 'content-type': 'application/json' },
133 body: JSON.stringify(signedOp)
134 })
135 if (!res.ok) throw await plcError(res, 'POST /' + did)
136 return true
137 }
138
139 // Submit a genesis op: local §6.2 validation → POST → confirm via getData.
140 async submitGenesis ({ did, signedOp, confirm = true }) {
141 const chk = checkGenesisOp(signedOp, did)
142 if (!chk.ok) throw new Error('refusing to submit invalid genesis: ' + chk.error)
143 await this.submitRaw(did, signedOp)
144 return confirm ? this.getData(did) : true
145 }
146
147 // Submit an update op: fetch the LIVE head, require our op chains onto it (prev == opCid(head))
148 // AND passes §6 validation, then POST → confirm. Re-fetching the head at submit time closes the
149 // build-vs-submit TOCTOU (a MisorderedOperation reject on the live directory otherwise).
150 async submitUpdate ({ did, signedOp, confirm = true }) {
151 const head = await this.getHead(did)
152 if (signedOp.prev !== opCid(head)) throw new Error('refusing to submit: op.prev != live head CID — refetch the head and rebuild')
153 const chk = checkUpdateOp(signedOp, head)
154 if (!chk.ok) throw new Error('refusing to submit invalid update: ' + chk.error)
155 await this.submitRaw(did, signedOp)
156 return confirm ? this.getData(did) : true
157 }
158
159 // The device-attestation migration primitive: rotate verificationMethods.atproto to a device
160 // key on-chain. Fetches the live head, builds a root-signed rotation off it, validates, submits.
161 // signerPrivateKey must correspond to a CURRENT rotation key (the root).
162 async rotateVerificationMethodOnChain ({ did, newAtprotoDidKey, signerPrivateKey, confirm = true }) {
163 const head = await this.getHead(did)
164 const op = rotateVerificationMethod({ prevSignedOp: head, newAtprotoDidKey, signerPrivateKey })
165 return this.submitUpdate({ did, signedOp: op, confirm })
166 }
167}
test/plc-directory.test.js +170 -0
……@@ -0,0 +1,170 @@
1// PLC submit ceremony — OFFLINE-first. A mock in-memory PLC directory (injected as fetchImpl)
2// exercises the full fetch-head → §6 local-validate → POST → confirm path with zero network.
3// Nothing here touches the live plc.directory. (did-plc skill §5/§6.)
4
5import test from 'brittle'
6import {
7 PlcDirectory, verifyOpSig, verifyOpSignedByAny, checkGenesisOp, checkUpdateOp
8} from '../lib/plc-directory.js'
9import { selfCustodyIdentity, deriveAtprotoKey, rotateVerificationMethod } from '../lib/atproto-identity.js'
10import { opCid, didForCreateOp } from '../lib/did-plc.js'
11
12const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
13const DEVICE = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
14const DEVICE2 = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
15const OPTS = { handle: 'p2p.7rnx.net', pdsUrl: 'https://pds.7rnx.net' }
16
17function mkRes (status, body) {
18 return {
19 ok: status >= 200 && status < 400,
20 status,
21 statusText: 'status ' + status,
22 async json () { if (typeof body === 'string') throw new Error('not json'); return body },
23 async text () { return typeof body === 'string' ? body : JSON.stringify(body) }
24 }
25}
26
27// A recording in-memory directory: appends any POSTed op, serves head/data from the log.
28function recordingDirectory () {
29 const logs = new Map() // did -> [op]
30 const posts = [] // [{ did, op }]
31 const fetchImpl = async (url, opts = {}) => {
32 const u = new URL(url)
33 const segs = u.pathname.split('/').filter(Boolean)
34 const did = decodeURIComponent(segs[0] || '')
35 const rest = segs.slice(1).join('/')
36 if ((opts.method || 'GET') === 'POST') {
37 const op = JSON.parse(opts.body)
38 posts.push({ did, op })
39 const arr = logs.get(did) || []; arr.push(op); logs.set(did, arr)
40 return mkRes(200, 'OK')
41 }
42 const arr = logs.get(did)
43 if (rest === 'log/last') { if (!arr || !arr.length) return mkRes(404, { error: 'NotFound' }); return mkRes(200, arr[arr.length - 1]) }
44 if (rest === 'data') {
45 if (!arr || !arr.length) return mkRes(404, { error: 'NotFound' })
46 const op = arr[arr.length - 1]
47 return mkRes(200, { did, verificationMethods: op.verificationMethods, rotationKeys: op.rotationKeys, alsoKnownAs: op.alsoKnownAs, services: op.services })
48 }
49 if (rest === '') { if (!arr || !arr.length) return mkRes(404, { error: 'NotFound' }); return mkRes(200, { id: did }) }
50 return mkRes(404, { error: 'NotFound' })
51 }
52 return { fetchImpl, logs, posts }
53}
54
55test('verifyOpSig / verifyOpSignedByAny: curve-aware, key-bound', (t) => {
56 const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
57 const root = deriveAtprotoKey(ROOT)
58 const device = deriveAtprotoKey(DEVICE)
59 t.ok(verifyOpSig(id.genesisOp, root.didKey), 'genesis verifies vs its root did:key')
60 t.absent(verifyOpSig(id.genesisOp, device.didKey), 'genesis does NOT verify vs the device key')
61 t.ok(verifyOpSignedByAny(id.rotationOp, id.genesisOp.rotationKeys), 'rotation verifies vs a head rotationKey')
62 t.absent(verifyOpSignedByAny(id.rotationOp, [device.didKey]), 'rotation not signed by the device key')
63})
64
65test('checkGenesisOp mirrors §6.2', (t) => {
66 const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
67 t.ok(checkGenesisOp(id.genesisOp, id.did).ok, 'valid genesis passes')
68 t.absent(checkGenesisOp(id.genesisOp, 'did:plc:wrongwrongwrongwrong0000').ok, 'DID mismatch fails')
69 t.absent(checkGenesisOp({ ...id.genesisOp, prev: 'x' }, id.did).ok, 'non-null prev fails')
70 t.absent(checkGenesisOp({ ...id.genesisOp, sig: id.rotationOp.sig }, id.did).ok, 'swapped/bad sig fails')
71})
72
73test('checkUpdateOp mirrors §6.3-4', (t) => {
74 const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
75 t.ok(checkUpdateOp(id.rotationOp, id.genesisOp).ok, 'valid rotation chained on genesis passes')
76 t.absent(checkUpdateOp({ ...id.rotationOp, prev: 'bafybogus' }, id.genesisOp).ok, 'wrong prev fails')
77 // a rotation whose sig is by the DEVICE key (not a head rotationKey) must fail
78 const device = deriveAtprotoKey(DEVICE)
79 const badSigner = rotateVerificationMethod({ prevSignedOp: id.genesisOp, newAtprotoDidKey: device.didKey, signerPrivateKey: device.privateKey })
80 t.absent(checkUpdateOp(badSigner, id.genesisOp).ok, 'sig not by a head rotationKey fails')
81})
82
83test('client: getHead/getData/submitRaw over injected fetch', async (t) => {
84 const dir = recordingDirectory()
85 const plc = new PlcDirectory({ fetchImpl: dir.fetchImpl })
86 const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
87 await plc.submitRaw(id.did, id.genesisOp)
88 t.is(dir.posts.length, 1, 'POST recorded')
89 t.alike(dir.posts[0].op, id.genesisOp, 'body is the unwrapped signed op')
90 const head = await plc.getHead(id.did)
91 t.is(opCid(head), opCid(id.genesisOp), 'getHead returns the head op (CID matches)')
92 const data = await plc.getData(id.did)
93 t.is(data.verificationMethods.atproto, id.genesisOp.verificationMethods.atproto, 'getData reflects genesis vm')
94})
95
96test('getHead normalizes wrapped/audit-extra responses (stable opCid)', async (t) => {
97 const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
98 // a directory that returns the op with audit extras (cid/createdAt/nullified/did)
99 const fetchImpl = async () => mkRes(200, { ...id.genesisOp, did: id.did, cid: 'bafyxxx', createdAt: '2026-07-04T00:00:00Z', nullified: false })
100 const plc = new PlcDirectory({ fetchImpl })
101 const head = await plc.getHead(id.did)
102 t.is(opCid(head), opCid(id.genesisOp), 'audit extras stripped → CID matches the bare op')
103})
104
105test('happy path: submit genesis then rotation, confirm on-chain state', async (t) => {
106 const dir = recordingDirectory()
107 const plc = new PlcDirectory({ fetchImpl: dir.fetchImpl })
108 const root = deriveAtprotoKey(ROOT)
109 const device = deriveAtprotoKey(DEVICE)
110 const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: device.privateKey })
111
112 const afterGenesis = await plc.submitGenesis({ did: id.did, signedOp: id.genesisOp })
113 t.is(afterGenesis.verificationMethods.atproto, root.didKey, 'after genesis, vm = root key')
114
115 const afterRotate = await plc.submitUpdate({ did: id.did, signedOp: id.rotationOp })
116 t.is(afterRotate.verificationMethods.atproto, device.didKey, 'after rotation, vm = device key (DID unchanged)')
117
118 t.is(dir.posts.length, 2, 'exactly two ops appended')
119 t.is(dir.posts[1].op.prev, opCid(dir.posts[0].op), 'rotation chains onto genesis (prev)')
120 t.is(didForCreateOp(dir.posts[0].op), id.did, 'genesis still hashes to the DID')
121})
122
123test('ceremony builds the rotation itself: rotateVerificationMethodOnChain', async (t) => {
124 const dir = recordingDirectory()
125 const plc = new PlcDirectory({ fetchImpl: dir.fetchImpl })
126 const root = deriveAtprotoKey(ROOT)
127 const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
128 await plc.submitGenesis({ did: id.did, signedOp: id.genesisOp })
129
130 const newDevice = deriveAtprotoKey(DEVICE2)
131 const data = await plc.rotateVerificationMethodOnChain({ did: id.did, newAtprotoDidKey: newDevice.didKey, signerPrivateKey: root.privateKey })
132 t.is(data.verificationMethods.atproto, newDevice.didKey, 'vm rotated to the NEW device key')
133 t.is(dir.posts[dir.posts.length - 1].op.prev, opCid(id.genesisOp), 'built rotation chained onto the live head')
134})
135
136test('guard: submitUpdate refuses when prev != live head (no POST)', async (t) => {
137 const dir = recordingDirectory()
138 const plc = new PlcDirectory({ fetchImpl: dir.fetchImpl })
139 const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
140 await plc.submitGenesis({ did: id.did, signedOp: id.genesisOp })
141 const postsBefore = dir.posts.length
142 const stale = { ...id.rotationOp, prev: 'bafyreieqm ' } // wrong prev
143 await t.exception(plc.submitUpdate({ did: id.did, signedOp: stale }), /prev != live head/)
144 t.is(dir.posts.length, postsBefore, 'no op was appended')
145})
146
147test('guard: invalid genesis rejected locally before any POST', async (t) => {
148 const dir = recordingDirectory()
149 const plc = new PlcDirectory({ fetchImpl: dir.fetchImpl })
150 const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
151 // wrong DID for the op → checkGenesisOp fails → must throw before POST
152 await t.exception(plc.submitGenesis({ did: 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa', signedOp: id.genesisOp }), /invalid genesis/)
153 t.is(dir.posts.length, 0, 'nothing submitted')
154})
155
156test('HTTP error surfaces with status + message', async (t) => {
157 const fetchImpl = async (url, opts = {}) => {
158 if ((opts.method || 'GET') === 'POST') return mkRes(400, { error: 'ImproperOperation', message: 'bad op' })
159 return mkRes(404, { error: 'NotFound' })
160 }
161 const plc = new PlcDirectory({ fetchImpl })
162 const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
163 try {
164 await plc.submitRaw(id.did, id.genesisOp)
165 t.fail('should have thrown')
166 } catch (e) {
167 t.is(e.status, 400, 'error carries HTTP status')
168 t.ok(/bad op/.test(e.message), 'error carries the directory message')
169 }
170})