// Post-genesis PLC rotation: device attestation is a ROTATION of
// verificationMethods.atproto, NOT a field in the genesis. The genesis stays a
// deterministic, root-derived, reproducible DID; the device signing key arrives as a
// root-signed update op that chains via `prev` and leaves the DID unchanged.
//
// This is the atproto-correct fix for "DID = f(device key)" (see atproto-base/DESIGN.md
// §4a and the 2026-07-04 post-mortem). Reference IS the spec (verification-first).
import test from 'brittle'
import {
createAtprotoIdentity, deriveAtprotoKey,
rotateVerificationMethod, buildUpdateOp, signOp
} from '../lib/atproto-identity.js'
import { didForCreateOp, unsignedOp, opCid } from '../lib/did-plc.js'
import { verifyOp } from '../lib/atproto-identity.js'
const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
const DEVICE = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
const DEVICE2 = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const OPTS = { handle: 'p2p.7rnx.net', pdsUrl: 'https://pds.7rnx.net' }
test('genesis is deterministic + root-keyed (the DID we keep)', (t) => {
const g = createAtprotoIdentity(ROOT, OPTS)
const root = deriveAtprotoKey(ROOT)
t.is(g.signedOp.verificationMethods.atproto, root.didKey, 'genesis vm = ROOT-derived key (not a device key)')
t.is(g.signedOp.rotationKeys[0], root.didKey, 'genesis rotationKey = root')
t.is(g.signedOp.prev, null, 'genesis has prev:null')
// Reproducible: same mnemonic+handle+pds → same DID.
t.is(createAtprotoIdentity(ROOT, OPTS).did, g.did, 'DID reproduces from root+handle+pds')
})
test('rotate vm to a device key: DID unchanged, root-signed, chained via prev', (t) => {
const g = createAtprotoIdentity(ROOT, OPTS) // root holds the genesis signing key
const device = deriveAtprotoKey(DEVICE)
const root = deriveAtprotoKey(ROOT)
const op = rotateVerificationMethod({
prevSignedOp: g.signedOp,
newAtprotoDidKey: device.didKey,
signerPrivateKey: g.privateKey // = a rotationKey (the root)
})
t.not(device.didKey, root.didKey, 'device signing key ≠ root (real separation)')
t.is(op.verificationMethods.atproto, device.didKey, 'vm rotated to the DEVICE key')
t.alike(op.rotationKeys, g.signedOp.rotationKeys, 'rotationKeys carried forward (root keeps authority)')
t.alike(op.alsoKnownAs, g.signedOp.alsoKnownAs, 'handle carried forward')
t.alike(op.services, g.signedOp.services, 'pds endpoint carried forward')
t.is(op.prev, opCid(g.signedOp), 'prev = CID of the genesis op (chains the log)')
t.not(op.prev, null, 'update op is not a genesis')
// The DID is a pure function of the GENESIS — a rotation never changes it.
t.is(didForCreateOp(g.signedOp), g.did, 'DID still resolves from the genesis, unchanged by rotation')
// Signed by the ROOT (a rotation key), NOT the device.
t.ok(verifyOp(unsignedOp(op), op.sig, root.publicKey), 'update op signed by the root rotation key')
t.absent(verifyOp(unsignedOp(op), op.sig, device.publicKey), 'update op NOT signed by the device key')
})
test('rotation is deterministic (RFC6979) — same inputs, byte-identical op', (t) => {
const g = createAtprotoIdentity(ROOT, OPTS)
const device = deriveAtprotoKey(DEVICE)
const a = rotateVerificationMethod({ prevSignedOp: g.signedOp, newAtprotoDidKey: device.didKey, signerPrivateKey: g.privateKey })
const b = rotateVerificationMethod({ prevSignedOp: g.signedOp, newAtprotoDidKey: device.didKey, signerPrivateKey: g.privateKey })
t.is(a.sig, b.sig, 'same signature (deterministic ECDSA)')
t.is(opCid(a), opCid(b), 'same op CID (byte-exact)')
})
test('a second rotation chains onto the first (prev walks the log)', (t) => {
const g = createAtprotoIdentity(ROOT, OPTS)
const d1 = deriveAtprotoKey(DEVICE)
const d2 = deriveAtprotoKey(DEVICE2)
const op1 = rotateVerificationMethod({ prevSignedOp: g.signedOp, newAtprotoDidKey: d1.didKey, signerPrivateKey: g.privateKey })
const op2 = rotateVerificationMethod({ prevSignedOp: op1, newAtprotoDidKey: d2.didKey, signerPrivateKey: g.privateKey })
t.is(op1.prev, opCid(g.signedOp), 'op1.prev → genesis')
t.is(op2.prev, opCid(op1), 'op2.prev → op1 (log is a chain)')
t.is(op2.verificationMethods.atproto, d2.didKey, 'latest vm = newest device key')
t.is(didForCreateOp(g.signedOp), g.did, 'DID STILL unchanged after two rotations')
})
test('buildUpdateOp requires a prev op (guards against accidental genesis)', (t) => {
t.exception(() => buildUpdateOp({ prevSignedOp: null, rotationDidKeys: [], verificationMethods: {} }),
'buildUpdateOp throws without prevSignedOp')
})
test('signing bytes cover the whole update op (tamper detection)', (t) => {
const g = createAtprotoIdentity(ROOT, OPTS)
const root = deriveAtprotoKey(ROOT)
const device = deriveAtprotoKey(DEVICE)
const op = rotateVerificationMethod({ prevSignedOp: g.signedOp, newAtprotoDidKey: device.didKey, signerPrivateKey: g.privateKey })
// Flip the endpoint after signing → signature must no longer verify.
const tampered = { ...op, services: { atproto_pds: { type: 'AtprotoPersonalDataServer', endpoint: 'https://evil.example' } } }
t.absent(verifyOp(unsignedOp(tampered), op.sig, root.publicKey), 'a post-sign edit breaks the signature')
})