OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-identity commits / 13c52a6

P0b: selfCustodyIdentity — deterministic genesis + device rotation onboarding
Compose the atproto-correct onboarding: a deterministic root-derived genesis (createAtprotoIdentity) pins the DID, then rotateVerificationMethod attests a per-device signing key. The DID is f(root,handle,pds) — reproducible and device-independent — vs buildAttestedIdentity where DID = f(device key). The peer holds only the device key; the root signs genesis+rotation then is discarded. test/self-custody.test.js 4/4: DID reproducible + device-independent, genesis vm=root / rotation vm=device, prev-chained, root-signed. Full suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
13c52a65f92a4136e9aa6a8ada99c53ecb491b70
by plankatron <fleeky@gmail.com> · 2026-07-04T20:00:47.000Z

Changed files (2) +84 -0

lib/atproto-identity.js +29 -0
……@@ -136,4 +136,33 @@ export function rotateVerificationMethod ({ prevSignedOp, newAtprotoDidKey, sign
136136 return { ...unsigned, sig }
137137}
138138
139// Full self-custody onboarding — the atproto-correct device-attested model. A DETERMINISTIC,
140// root-derived genesis pins the DID (reproducible from rootMnemonic+handle+pdsUrl), then a
141// POST-genesis rotation attests a per-device signing key. The DID is f(root,handle,pds) — NOT
142// f(device key) — so it reproduces across runs and can span multiple devices. The root key
143// signs the genesis + the rotation and is then discarded (the user keeps it via hyperident);
144// the peer holds ONLY the device key. This supersedes buildAttestedIdentity, which baked the
145// device key INTO the genesis (making did:plc = f(device key) — non-reproducible, single-device).
146// Pass a device secp256k1 privateKey to pin it; otherwise a fresh device key is minted.
147export function selfCustodyIdentity ({ rootMnemonic, rootKey, handle, pdsUrl, deviceKey, path = ATPROTO_SIGNING_PATH }) {
148 const genesis = rootKey
149 ? createAtprotoIdentityFromKey(rootKey, { handle, pdsUrl })
150 : createAtprotoIdentity(rootMnemonic, { handle, pdsUrl, path })
151 const device = deviceKey ? keyFromPrivate(deviceKey) : generateAtprotoKey()
152 const rotationOp = rotateVerificationMethod({
153 prevSignedOp: genesis.signedOp,
154 newAtprotoDidKey: device.didKey,
155 signerPrivateKey: genesis.privateKey // the root — a rotationKey of the genesis
156 })
157 return {
158 did: genesis.did, // = f(root, handle, pds) — reproducible, device-independent
159 genesisOp: genesis.signedOp, // vm = root-derived key, signed by root, prev:null
160 rotationOp, // vm → device key, signed by root, prev = cid(genesis)
161 deviceKey: device, // { privateKey, publicKey, didKey } — the peer holds THIS
162 rootDidKey: genesis.didKey, // the root (rotation / recovery) key's did:key
163 rootPublicKey: genesis.publicKey,
164 rotationKeys: genesis.signedOp.rotationKeys
165 }
166}
167
139168export { unsignedOp, didForCreateOp, opCid }
test/self-custody.test.js +55 -0
……@@ -0,0 +1,55 @@
1// selfCustodyIdentity: the atproto-correct onboarding — a deterministic root-derived genesis
2// pins the DID (reproducible, device-independent), then a post-genesis rotation attests the
3// per-device signing key. Contrast device-attestation.test.js (buildAttestedIdentity), where a
4// different device yields a different DID. This is the P0b fix (atproto-base/DESIGN.md §4a).
5
6import test from 'brittle'
7import {
8 selfCustodyIdentity, createAtprotoIdentity, deriveAtprotoKey, generateAtprotoKey, verifyOp
9} from '../lib/atproto-identity.js'
10import { didForCreateOp, unsignedOp, opCid } from '../lib/did-plc.js'
11
12const ROOT = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
13const DEVICE = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
14const OPTS = { handle: 'p2p.7rnx.net', pdsUrl: 'https://pds.7rnx.net' }
15
16test('DID is reproducible + device-independent (= the deterministic genesis)', (t) => {
17 const genesisDid = createAtprotoIdentity(ROOT, OPTS).did
18 const a = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
19 const b = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: generateAtprotoKey().privateKey })
20 t.is(a.did, genesisDid, 'DID = the root-derived deterministic genesis DID')
21 t.is(b.did, genesisDid, 'a DIFFERENT device key yields the SAME DID (device-independent)')
22 t.is(didForCreateOp(a.genesisOp), a.did, 'DID hashes from the genesis op')
23})
24
25test('genesis vm = root; rotation vm = device; chained + root-signed', (t) => {
26 const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
27 const root = deriveAtprotoKey(ROOT)
28 const device = deriveAtprotoKey(DEVICE)
29
30 t.is(id.genesisOp.verificationMethods.atproto, root.didKey, 'genesis vm = ROOT-derived key')
31 t.is(id.genesisOp.prev, null, 'genesis prev:null')
32 t.is(id.rotationOp.verificationMethods.atproto, device.didKey, 'rotation vm = DEVICE key')
33 t.is(id.rotationOp.prev, opCid(id.genesisOp), 'rotation chains onto the genesis (prev)')
34 t.alike(id.rotationOp.rotationKeys, id.genesisOp.rotationKeys, 'rotationKeys carried forward (root keeps authority)')
35
36 // both ops signed by the ROOT (a rotationKey), not the device
37 t.ok(verifyOp(unsignedOp(id.genesisOp), id.genesisOp.sig, root.publicKey), 'genesis signed by root')
38 t.ok(verifyOp(unsignedOp(id.rotationOp), id.rotationOp.sig, root.publicKey), 'rotation signed by root')
39 t.absent(verifyOp(unsignedOp(id.rotationOp), id.rotationOp.sig, device.publicKey), 'rotation NOT signed by device')
40})
41
42test('peer holds the DEVICE key (root-independent signing) + root stays public-only', (t) => {
43 const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS, deviceKey: deriveAtprotoKey(DEVICE).privateKey })
44 const device = deriveAtprotoKey(DEVICE)
45 t.is(id.deviceKey.privateKey.length, 32, 'device key is a usable 32-byte signing key')
46 t.is(id.deviceKey.didKey, device.didKey, 'device key matches the rotation vm')
47 t.not(id.deviceKey.didKey, id.rootDidKey, 'device signing key ≠ root rotation key (separation)')
48})
49
50test('minted device key (no deviceKey arg) still yields the same DID', (t) => {
51 const genesisDid = createAtprotoIdentity(ROOT, OPTS).did
52 const id = selfCustodyIdentity({ rootMnemonic: ROOT, ...OPTS })
53 t.is(id.did, genesisDid, 'auto-minted device key does not change the DID')
54 t.ok(id.deviceKey.privateKey && id.deviceKey.privateKey.length === 32, 'a fresh device key was minted')
55})