| … | … | @@ -20,6 +20,10 @@ import { encodeOp, unsignedOp, didForCreateOp, opCid } from './did-plc.js' |
| 20 | 20 | // is a project convention — the *mnemonic* is the recovery anchor; the path is fixed |
| 21 | 21 | // by this constant so the same phrase always yields the same atproto key. |
| 22 | 22 | export 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. | |
| 26 | export const ATPROTO_RECOVERY_PATH = "m/44'/0'/1'/0/0" | |
| 23 | 27 | |
| 24 | 28 | // raw base64url (no multibase prefix, no padding) — atproto's sig encoding. |
| 25 | 29 | function sigToBase64url (bytes) { |
| … | … | @@ -95,6 +99,26 @@ export function createAtprotoIdentityFromKey (privateKey, { handle, pdsUrl }) { |
| 95 | 99 | return identityFromKey(keyFromPrivate(privateKey), { handle, pdsUrl }) |
| 96 | 100 | } |
| 97 | 101 | |
| 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. | |
| 112 | export 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 | ||
| 98 | 122 | // ── Post-genesis PLC operations (device attestation = rotation, NOT genesis) ── |
| 99 | 123 | // |
| 100 | 124 | // A did:plc is fixed at genesis; you evolve it with UPDATE operations that chain via |
| … | … | @@ -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 | ||
| 6 | import test from 'brittle' | |
| 7 | import { createSwarmIdentity, deriveAtprotoKey, ATPROTO_SIGNING_PATH, ATPROTO_RECOVERY_PATH } from '../lib/atproto-identity.js' | |
| 8 | import { checkGenesisOp, verifyOpSignedByAny } from '../lib/plc-directory.js' | |
| 9 | ||
| 10 | const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' | |
| 11 | const HANDLE = 'swarmtest.example.com' | |
| 12 | const PDS = 'https://pds.example.com' | |
| 13 | ||
| 14 | test('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 | ||
| 26 | test('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 | ||
| 35 | test('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 | }) |