| … | … | @@ -0,0 +1,94 @@ |
| 1 | // Post-genesis PLC rotation: device attestation is a ROTATION of |
| 2 | // verificationMethods.atproto, NOT a field in the genesis. The genesis stays a |
| 3 | // deterministic, root-derived, reproducible DID; the device signing key arrives as a |
| 4 | // root-signed update op that chains via `prev` and leaves the DID unchanged. |
| 5 | // |
| 6 | // This is the atproto-correct fix for "DID = f(device key)" (see atproto-base/DESIGN.md |
| 7 | // §4a and the 2026-07-04 post-mortem). Reference IS the spec (verification-first). |
| 8 | |
| 9 | import test from 'brittle' |
| 10 | import { |
| 11 | createAtprotoIdentity, deriveAtprotoKey, |
| 12 | rotateVerificationMethod, buildUpdateOp, signOp |
| 13 | } from '../lib/atproto-identity.js' |
| 14 | import { didForCreateOp, unsignedOp, opCid } from '../lib/did-plc.js' |
| 15 | import { verifyOp } from '../lib/atproto-identity.js' |
| 16 | |
| 17 | const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' |
| 18 | const DEVICE = 'legal winner thank year wave sausage worth useful legal winner thank yellow' |
| 19 | const DEVICE2 = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong' |
| 20 | const OPTS = { handle: 'p2p.7rnx.net', pdsUrl: 'https://pds.7rnx.net' } |
| 21 | |
| 22 | test('genesis is deterministic + root-keyed (the DID we keep)', (t) => { |
| 23 | const g = createAtprotoIdentity(ROOT, OPTS) |
| 24 | const root = deriveAtprotoKey(ROOT) |
| 25 | t.is(g.signedOp.verificationMethods.atproto, root.didKey, 'genesis vm = ROOT-derived key (not a device key)') |
| 26 | t.is(g.signedOp.rotationKeys[0], root.didKey, 'genesis rotationKey = root') |
| 27 | t.is(g.signedOp.prev, null, 'genesis has prev:null') |
| 28 | // Reproducible: same mnemonic+handle+pds → same DID. |
| 29 | t.is(createAtprotoIdentity(ROOT, OPTS).did, g.did, 'DID reproduces from root+handle+pds') |
| 30 | }) |
| 31 | |
| 32 | test('rotate vm to a device key: DID unchanged, root-signed, chained via prev', (t) => { |
| 33 | const g = createAtprotoIdentity(ROOT, OPTS) // root holds the genesis signing key |
| 34 | const device = deriveAtprotoKey(DEVICE) |
| 35 | const root = deriveAtprotoKey(ROOT) |
| 36 | |
| 37 | const op = rotateVerificationMethod({ |
| 38 | prevSignedOp: g.signedOp, |
| 39 | newAtprotoDidKey: device.didKey, |
| 40 | signerPrivateKey: g.privateKey // = a rotationKey (the root) |
| 41 | }) |
| 42 | |
| 43 | t.not(device.didKey, root.didKey, 'device signing key ≠ root (real separation)') |
| 44 | t.is(op.verificationMethods.atproto, device.didKey, 'vm rotated to the DEVICE key') |
| 45 | t.alike(op.rotationKeys, g.signedOp.rotationKeys, 'rotationKeys carried forward (root keeps authority)') |
| 46 | t.alike(op.alsoKnownAs, g.signedOp.alsoKnownAs, 'handle carried forward') |
| 47 | t.alike(op.services, g.signedOp.services, 'pds endpoint carried forward') |
| 48 | t.is(op.prev, opCid(g.signedOp), 'prev = CID of the genesis op (chains the log)') |
| 49 | t.not(op.prev, null, 'update op is not a genesis') |
| 50 | |
| 51 | // The DID is a pure function of the GENESIS — a rotation never changes it. |
| 52 | t.is(didForCreateOp(g.signedOp), g.did, 'DID still resolves from the genesis, unchanged by rotation') |
| 53 | |
| 54 | // Signed by the ROOT (a rotation key), NOT the device. |
| 55 | t.ok(verifyOp(unsignedOp(op), op.sig, root.publicKey), 'update op signed by the root rotation key') |
| 56 | t.absent(verifyOp(unsignedOp(op), op.sig, device.publicKey), 'update op NOT signed by the device key') |
| 57 | }) |
| 58 | |
| 59 | test('rotation is deterministic (RFC6979) — same inputs, byte-identical op', (t) => { |
| 60 | const g = createAtprotoIdentity(ROOT, OPTS) |
| 61 | const device = deriveAtprotoKey(DEVICE) |
| 62 | const a = rotateVerificationMethod({ prevSignedOp: g.signedOp, newAtprotoDidKey: device.didKey, signerPrivateKey: g.privateKey }) |
| 63 | const b = rotateVerificationMethod({ prevSignedOp: g.signedOp, newAtprotoDidKey: device.didKey, signerPrivateKey: g.privateKey }) |
| 64 | t.is(a.sig, b.sig, 'same signature (deterministic ECDSA)') |
| 65 | t.is(opCid(a), opCid(b), 'same op CID (byte-exact)') |
| 66 | }) |
| 67 | |
| 68 | test('a second rotation chains onto the first (prev walks the log)', (t) => { |
| 69 | const g = createAtprotoIdentity(ROOT, OPTS) |
| 70 | const d1 = deriveAtprotoKey(DEVICE) |
| 71 | const d2 = deriveAtprotoKey(DEVICE2) |
| 72 | const op1 = rotateVerificationMethod({ prevSignedOp: g.signedOp, newAtprotoDidKey: d1.didKey, signerPrivateKey: g.privateKey }) |
| 73 | const op2 = rotateVerificationMethod({ prevSignedOp: op1, newAtprotoDidKey: d2.didKey, signerPrivateKey: g.privateKey }) |
| 74 | |
| 75 | t.is(op1.prev, opCid(g.signedOp), 'op1.prev → genesis') |
| 76 | t.is(op2.prev, opCid(op1), 'op2.prev → op1 (log is a chain)') |
| 77 | t.is(op2.verificationMethods.atproto, d2.didKey, 'latest vm = newest device key') |
| 78 | t.is(didForCreateOp(g.signedOp), g.did, 'DID STILL unchanged after two rotations') |
| 79 | }) |
| 80 | |
| 81 | test('buildUpdateOp requires a prev op (guards against accidental genesis)', (t) => { |
| 82 | t.exception(() => buildUpdateOp({ prevSignedOp: null, rotationDidKeys: [], verificationMethods: {} }), |
| 83 | 'buildUpdateOp throws without prevSignedOp') |
| 84 | }) |
| 85 | |
| 86 | test('signing bytes cover the whole update op (tamper detection)', (t) => { |
| 87 | const g = createAtprotoIdentity(ROOT, OPTS) |
| 88 | const root = deriveAtprotoKey(ROOT) |
| 89 | const device = deriveAtprotoKey(DEVICE) |
| 90 | const op = rotateVerificationMethod({ prevSignedOp: g.signedOp, newAtprotoDidKey: device.didKey, signerPrivateKey: g.privateKey }) |
| 91 | // Flip the endpoint after signing → signature must no longer verify. |
| 92 | const tampered = { ...op, services: { atproto_pds: { type: 'AtprotoPersonalDataServer', endpoint: 'https://evil.example' } } } |
| 93 | t.absent(verifyOp(unsignedOp(tampered), op.sig, root.publicKey), 'a post-sign edit breaks the signature') |
| 94 | }) |