OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-identity commits / 60c492a

createSwarmIdentity: swarm genesis with a SEPARATE root recovery key (revocation slice 1)
The atproto-base swarm DID must support credible device revocation. createAtprotoIdentity sets rotationKeys=[signingKey] — the shared #atproto key is its own rotation authority, so a device holding it could rotate/hijack the DID (no revocation). createSwarmIdentity separates them: verificationMethods.atproto = shared signing key (SIGNING_PATH; distributed to devices) rotationKeys = [recovery key] (RECOVERY_PATH m/44'/0'/1'; ROOT-ONLY) genesis signed by the recovery key. Devices sign commits but have ZERO PLC authority — only the root recovers/rotates, and devices never hold the root so they cannot self-recover. See hyper-proto-peer/docs/atproto-base-REVOCATION.md. Tests: swarm-identity.test.js 3/3 (custody separation, valid genesis signed by recovery, goat calc-did confirmed the DID). Suite 38/38, 144/144. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
60c492a401cdc0fbf9e6cc792230691828c58a43
by plankatron <fleeky@gmail.com> · 2026-07-05T12:14:46.000Z

Changed files (2) +63 -0

lib/atproto-identity.js +24 -0
……@@ -20,6 +20,10 @@ import { encodeOp, unsignedOp, didForCreateOp, opCid } from './did-plc.js'
2020// is a project convention — the *mnemonic* is the recovery anchor; the path is fixed
2121// by this constant so the same phrase always yields the same atproto key.
2222export const ATPROTO_SIGNING_PATH = "m/44'/0'/0'/0/0"
23// A SEPARATE derivation for the swarm's ROOT recovery/rotation key — held only by the root, NEVER
24// distributed to devices. Distinct account index ('1') so a device that holds the signing key
25// cannot derive this. See atproto-base-REVOCATION.md §4-5.
26export const ATPROTO_RECOVERY_PATH = "m/44'/0'/1'/0/0"
2327
2428// raw base64url (no multibase prefix, no padding) — atproto's sig encoding.
2529function sigToBase64url (bytes) {
……@@ -95,6 +99,26 @@ export function createAtprotoIdentityFromKey (privateKey, { handle, pdsUrl }) {
9599 return identityFromKey(keyFromPrivate(privateKey), { handle, pdsUrl })
96100}
97101
102// The atproto-base SWARM identity: ONE did:plc whose #atproto signing key is SHARED among N
103// attested devices, but whose ROTATION authority is a SEPARATE root recovery key that is NEVER
104// distributed to devices. Devices sign commits (verificationMethods.atproto = shared signing key)
105// but have ZERO PLC authority — only the root (recovery key) can rotate/revoke. Revocation = the
106// root rotates the shared signing key via a PLC update; a device holds only the old signing key,
107// not the root, so it cannot self-recover. Contrast createAtprotoIdentity (rotationKeys =
108// [signingKey], no separation — a device could rotate the DID). The genesis is signed by the
109// recovery key (a genesis must be signed by one of its own rotationKeys). See
110// atproto-base-REVOCATION.md §5. Returns { did, signedOp, signingKey, recoveryKey } — `signingKey`
111// is distributed to attested devices; `recoveryKey` STAYS WITH THE ROOT.
112export function createSwarmIdentity (mnemonic, { handle, pdsUrl }) {
113 const signingKey = deriveAtprotoKey(mnemonic, ATPROTO_SIGNING_PATH)
114 const recoveryKey = deriveAtprotoKey(mnemonic, ATPROTO_RECOVERY_PATH)
115 const unsigned = buildGenesisOp({ signingDidKey: signingKey.didKey, rotationDidKeys: [recoveryKey.didKey], handle, pdsUrl })
116 const sig = signOp(unsigned, recoveryKey.privateKey)
117 const signedOp = { ...unsigned, sig }
118 const did = didForCreateOp(signedOp)
119 return { did, signedOp, signingKey, recoveryKey, didKey: signingKey.didKey }
120}
121
98122// ── Post-genesis PLC operations (device attestation = rotation, NOT genesis) ──
99123//
100124// A did:plc is fixed at genesis; you evolve it with UPDATE operations that chain via
test/swarm-identity.test.js +39 -0
……@@ -0,0 +1,39 @@
1// createSwarmIdentity — the atproto-base swarm genesis with a SEPARATE root recovery key.
2// The custody invariant (atproto-base-REVOCATION.md §4-5): the shared #atproto signing key
3// (distributed to devices) is NOT a rotation key; the root recovery key (never on devices) is the
4// sole rotation authority. So a device that holds only the signing key cannot rotate/revoke the DID.
5
6import test from 'brittle'
7import { createSwarmIdentity, deriveAtprotoKey, ATPROTO_SIGNING_PATH, ATPROTO_RECOVERY_PATH } from '../lib/atproto-identity.js'
8import { checkGenesisOp, verifyOpSignedByAny } from '../lib/plc-directory.js'
9
10const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
11const HANDLE = 'swarmtest.example.com'
12const PDS = 'https://pds.example.com'
13
14test('recovery key is the sole rotation authority; the shared signing key only signs commits', (t) => {
15 const id = createSwarmIdentity(MNEMONIC, { handle: HANDLE, pdsUrl: PDS })
16 const signing = deriveAtprotoKey(MNEMONIC, ATPROTO_SIGNING_PATH)
17 const recovery = deriveAtprotoKey(MNEMONIC, ATPROTO_RECOVERY_PATH)
18 t.not(signing.didKey, recovery.didKey, 'signing and recovery keys are distinct paths')
19 t.is(id.signingKey.didKey, signing.didKey, 'signingKey = SIGNING_PATH (distributed to devices)')
20 t.is(id.recoveryKey.didKey, recovery.didKey, 'recoveryKey = RECOVERY_PATH (root-only)')
21 t.is(id.signedOp.verificationMethods.atproto, signing.didKey, 'vm.atproto = shared signing key')
22 t.alike(id.signedOp.rotationKeys, [recovery.didKey], 'rotationKeys = [recovery key] only')
23 t.absent(id.signedOp.rotationKeys.includes(signing.didKey), 'the shared signing key has NO rotation authority')
24})
25
26test('genesis is valid and signed BY the recovery key (not the signing key)', (t) => {
27 const id = createSwarmIdentity(MNEMONIC, { handle: HANDLE, pdsUrl: PDS })
28 const chk = checkGenesisOp(id.signedOp, id.did)
29 t.ok(chk.ok, 'checkGenesisOp passes: ' + (chk.error || 'ok'))
30 t.is(chk.did, id.did, 'DID matches the op hash')
31 t.ok(verifyOpSignedByAny(id.signedOp, [id.recoveryKey.didKey]), 'signed by the recovery key')
32 t.absent(verifyOpSignedByAny(id.signedOp, [id.signingKey.didKey]), 'NOT signed by the signing key')
33})
34
35test('deterministic: same root + handle + pds reproduces the same DID', (t) => {
36 const a = createSwarmIdentity(MNEMONIC, { handle: HANDLE, pdsUrl: PDS })
37 const b = createSwarmIdentity(MNEMONIC, { handle: HANDLE, pdsUrl: PDS })
38 t.is(a.did, b.did, 'reproducible DID')
39})