| … | … | @@ -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 | |
| 5 | import test from 'brittle' |
| 6 | import { |
| 7 | PlcDirectory, verifyOpSig, verifyOpSignedByAny, checkGenesisOp, checkUpdateOp |
| 8 | } from '../lib/plc-directory.js' |
| 9 | import { selfCustodyIdentity, deriveAtprotoKey, rotateVerificationMethod } from '../lib/atproto-identity.js' |
| 10 | import { opCid, didForCreateOp } from '../lib/did-plc.js' |
| 11 | |
| 12 | const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' |
| 13 | const DEVICE = 'legal winner thank year wave sausage worth useful legal winner thank yellow' |
| 14 | const DEVICE2 = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong' |
| 15 | const OPTS = { handle: 'p2p.7rnx.net', pdsUrl: 'https://pds.7rnx.net' } |
| 16 | |
| 17 | function 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. |
| 28 | function 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 | |
| 55 | test('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 | |
| 65 | test('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 | |
| 73 | test('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 | |
| 83 | test('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 | |
| 96 | test('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 | |
| 105 | test('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 | |
| 123 | test('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 | |
| 136 | test('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 | |
| 147 | test('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 | |
| 156 | test('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 | }) |