OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-identity commits / 8dde936

Phase 0: post-genesis PLC rotation (device attestation without changing the DID)
Device attestation was baked into the genesis (verificationMethods.atproto = device key), making did:plc = f(device key): a different device yields a different DID, so a DID can't be reproduced and can't span multiple devices. Fix the model per atproto-base/DESIGN.md §4a: keep createAtprotoIdentity's deterministic, root-derived genesis (reproducible DID), and attest a device key as a POST-genesis PLC UPDATE op that rotates verificationMethods.atproto, leaving the DID unchanged. - did-plc.js: opCid(signedOp) — CIDv1/dag-cbor/sha256 of a signed op, the value the next op carries in `prev` to chain the PLC log. - atproto-identity.js: buildUpdateOp({prevSignedOp,...}) + rotateVerificationMethod ({prevSignedOp, newAtprotoDidKey, signerPrivateKey}) — root-signed vm rotation. - test/plc-rotation.test.js: 6 tests / 22 asserts — DID unchanged, root-signed (not device), prev-chained, RFC6979-deterministic, tamper-detected. Full suite green incl. the golden did:plc vector (codec unperturbed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8dde9360c5e70af4cc9e77fe46e397276c562ca5
by plankatron <fleeky@gmail.com> · 2026-07-04T19:07:38.000Z

Changed files (3) +150 -2

lib/atproto-identity.js +43 -2
……@@ -14,7 +14,7 @@ import { secp256k1 } from '@noble/curves/secp256k1'
1414import { sha256 } from '@noble/hashes/sha2'
1515import { base64url } from 'multiformats/bases/base64'
1616import { encodeDidKey, CURVE_SECP256K1 } from './did-key.js'
17import { encodeOp, unsignedOp, didForCreateOp } from './did-plc.js'
17import { encodeOp, unsignedOp, didForCreateOp, opCid } from './did-plc.js'
1818
1919// Our fixed derivation path. No BIP44 coin type is registered for atproto, so this
2020// is a project convention — the *mnemonic* is the recovery anchor; the path is fixed
……@@ -95,4 +95,45 @@ export function createAtprotoIdentityFromKey (privateKey, { handle, pdsUrl }) {
9595 return identityFromKey(keyFromPrivate(privateKey), { handle, pdsUrl })
9696}
9797
98export { unsignedOp, didForCreateOp }
98// ── Post-genesis PLC operations (device attestation = rotation, NOT genesis) ──
99//
100// A did:plc is fixed at genesis; you evolve it with UPDATE operations that chain via
101// `prev` (the CID of the previous signed op) and are signed by one of the CURRENT
102// rotationKeys. This is how a device signing key is attested AFTER genesis without
103// changing the DID — the atproto-correct model (see DESIGN.md §4a). The genesis stays
104// deterministic (root-derived signing key → reproducible DID); the device key arrives
105// as a rotation of verificationMethods.atproto.
106
107// Build an UNSIGNED update op. `prevSignedOp` is the previous op in the log (its CID
108// becomes `prev`). The remaining fields are the FULL new operation state — carry
109// forward what you're not changing (a PLC op replaces the whole doc state, it is not a
110// patch). This module never submits to the PLC directory; it only builds/signs offline.
111export function buildUpdateOp ({ prevSignedOp, rotationDidKeys, verificationMethods, alsoKnownAs, services }) {
112 if (!prevSignedOp) throw new Error('buildUpdateOp: prevSignedOp required')
113 return {
114 type: 'plc_operation',
115 rotationKeys: rotationDidKeys,
116 verificationMethods,
117 alsoKnownAs,
118 services,
119 prev: opCid(prevSignedOp)
120 }
121}
122
123// Attest a device by ROTATING verificationMethods.atproto to the device's did:key,
124// carrying every other field forward from `prevSignedOp`, signed by a rotation key
125// (the root — `signerPrivateKey` must correspond to one of prevSignedOp.rotationKeys).
126// The DID is UNCHANGED. Returns the signed update op ready to submit to the PLC log.
127export function rotateVerificationMethod ({ prevSignedOp, newAtprotoDidKey, signerPrivateKey }) {
128 const unsigned = buildUpdateOp({
129 prevSignedOp,
130 rotationDidKeys: prevSignedOp.rotationKeys,
131 verificationMethods: { ...prevSignedOp.verificationMethods, atproto: newAtprotoDidKey },
132 alsoKnownAs: prevSignedOp.alsoKnownAs,
133 services: prevSignedOp.services
134 })
135 const sig = signOp(unsigned, signerPrivateKey)
136 return { ...unsigned, sig }
137}
138
139export { unsignedOp, didForCreateOp, opCid }
lib/did-plc.js +13 -0
……@@ -14,6 +14,8 @@
1414import * as dagCbor from '@ipld/dag-cbor'
1515import { sha256 } from '@noble/hashes/sha2'
1616import { base32 } from 'multiformats/bases/base32'
17import { CID } from 'multiformats/cid'
18import * as Digest from 'multiformats/hashes/digest'
1719
1820// multiformats' base32.encode returns a multibase string prefixed with 'b'.
1921// did:plc uses the raw base32 alphabet output, so strip the leading multibase char.
……@@ -41,3 +43,14 @@ export function unsignedOp (op) {
4143 const { sig, ...rest } = op // eslint-disable-line no-unused-vars
4244 return rest
4345}
46
47// The CID of a SIGNED operation — CIDv1, dag-cbor codec (0x71), sha2-256 multihash
48// (0x12), rendered as a base32 string. This is the value the NEXT operation carries in
49// its `prev` field to chain the PLC op-log (genesis has `prev: null`). Same encoding as
50// atproto-repo/lib/cid.js cidForBytesSync, over DAG-CBOR(signedOp). Reference: the
51// did-method-plc CID = CID.create(1, 0x71, sha256(cborEncode(op))).
52export function opCid (signedOp) {
53 const bytes = encodeOp(signedOp)
54 const digest = Digest.create(0x12, sha256(bytes))
55 return CID.createV1(dagCbor.code, digest).toString()
56}
test/plc-rotation.test.js +94 -0
……@@ -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
9import test from 'brittle'
10import {
11 createAtprotoIdentity, deriveAtprotoKey,
12 rotateVerificationMethod, buildUpdateOp, signOp
13} from '../lib/atproto-identity.js'
14import { didForCreateOp, unsignedOp, opCid } from '../lib/did-plc.js'
15import { verifyOp } from '../lib/atproto-identity.js'
16
17const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
18const DEVICE = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
19const DEVICE2 = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
20const OPTS = { handle: 'p2p.7rnx.net', pdsUrl: 'https://pds.7rnx.net' }
21
22test('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
32test('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
59test('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
68test('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
81test('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
86test('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})