OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer commits / 1dc8496

scripts/rotation-keys: retrofit additional PLC rotation keys onto a live DID
did:plc supports N rotation keys precisely so that losing one is survivable. createSwarmIdentity:127 hardcoded a one-element array and no caller ever passed more, so every DID this fleet minted has exactly one -- and when wj62's went missing the DID became permanently unchangeable (hyper-social #47). Two modes, deliberately separated so secrets and registration never mix: gen generate a NEW 24-word recovery phrase (IdentityKey.generateMnemonic, the same generator hyperident uses). Prints the phrase ONCE for paper backup plus the resulting PUBLIC did:key. Writes nothing, transmits nothing. add register a PUBLIC did:key as an additional rotation key. Takes only the did:key -- never the new secret -- and signs with a rotation key we already hold. Refuses any --add argument that is not a did:key. The new key is placed FIRST: lower index = higher authority in a dispute (did-plc S4, a recovery fork must be signed by a lower-index key). The existing key is preserved, so capability is only ever added to, never reduced. DRY-RUN BY DEFAULT. --publish is required for the irreversible append. Every other field is carried forward from the live head, because a PLC op replaces the whole document state and is not a patch -- the dry run against loser-club confirmed verificationMethods.atproto_label and the bsky_fg service survive, both of which a naive op would silently destroy. This is time-limited by nature: a DID can only gain rotation keys while one of its current rotation keys is still in hand. Validated (no writes): loser-club dry run, local validation PASS, new key ordered first wj62 refuses, exit 1 -- "no local secret derives ANY current rotation key ... this DID is already frozen" guard refuses a mnemonic-shaped --add argument Refs: hyper-social #47 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1dc849682520f6c22b01d80549138fa5f667f3e0
by plankatron <fleeky@gmail.com> · 2026-08-17T18:15:10.000Z

Changed files (1) +193 -0

scripts/rotation-keys.mjs +193 -0
……@@ -0,0 +1,193 @@
1// rotation-keys — generate an offline recovery key, and add rotation keys to a live DID.
2//
3// WHY (hyper-social #47): wj62 was minted with ONE rotation key. When the secret behind it went
4// missing, the DID became permanently unchangeable — no handle change, no PDS move, no key
5// rotation after a leak, no tombstone. did:plc supports N rotation keys precisely so that losing
6// one is survivable; `createSwarmIdentity:127` hardcoded a one-element array and nobody passed
7// more. This tool retrofits the redundancy onto DIDs whose rotation key we STILL hold.
8//
9// It is time-limited by nature: a DID can only gain rotation keys while at least one of its
10// current rotation keys is in hand. Once that is gone, nothing here can help (see wj62).
11//
12// ---------------------------------------------------------------------------
13// TWO MODES, deliberately separated so secrets and registration never mix.
14//
15// gen Generate a NEW recovery mnemonic. Prints the phrase ONCE, for you to write down,
16// plus the resulting PUBLIC did:key. Run this yourself, on a trusted terminal.
17// Nothing is written to disk. The phrase is never transmitted.
18//
19// add Register a PUBLIC did:key as an additional rotation key on a live DID. Takes only
20// the did:key — never the new secret. Signs with a rotation key we already hold.
21//
22// node scripts/rotation-keys.mjs gen
23// node scripts/rotation-keys.mjs add --did <did:plc:...> --add <did:key:...> [--publish]
24//
25// DRY-RUN BY DEFAULT. Without --publish it prints the exact op and validates it against the live
26// head, touching nothing. `POST /:did` is a PERMANENT, IRREVERSIBLE append to the global PLC
27// op-log — there is no delete and no undo.
28//
29// ORDERING: the new key is placed FIRST. Lower index = higher authority in a dispute (did-plc
30// §4: a recovery fork must be signed by a key with a LOWER index than the one it overrides). The
31// existing key is preserved, so current capability is never reduced — only added to.
32//
33// ⚠ Do not register a key you have not recorded. An unbacked-up rotation key is the exact
34// failure this tool exists to prevent, and registering one merely moves the single point.
35
36import fs from 'fs'
37import os from 'os'
38import path from 'path'
39import {
40 deriveAtprotoKey,
41 keyFromPrivate,
42 buildUpdateOp,
43 signOp,
44 generateAtprotoKey,
45 ATPROTO_SIGNING_PATH,
46 ATPROTO_RECOVERY_PATH
47} from '../node_modules/atproto-identity/lib/atproto-identity.js'
48import { PlcDirectory, checkUpdateOp } from '../node_modules/atproto-identity/lib/plc-directory.js'
49// Same generator hyperident itself uses (`identity-manager.js:25`, `hpp-onboard.js:114`), so a
50// phrase produced here is interchangeable with any other root in this stack.
51import IdentityKey from 'keet-identity-key'
52
53const HOME = os.homedir()
54const argv = process.argv.slice(2)
55const MODE = argv[0]
56const flag = (n, d = null) => { const i = argv.indexOf('--' + n); return i !== -1 && i + 1 < argv.length ? argv[i + 1] : d }
57const has = (n) => argv.includes('--' + n)
58
59const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null } }
60
61// ---------------------------------------------------------------------------
62// gen — a new offline recovery key. The phrase leaves this process only via your terminal.
63// ---------------------------------------------------------------------------
64function cmdGen () {
65 const mnemonic = IdentityKey.generateMnemonic() // 24 words, same as hyperident
66 const key = deriveAtprotoKey(mnemonic, ATPROTO_RECOVERY_PATH)
67 console.log('')
68 console.log(' NEW ROTATION KEY — write these 24 words down NOW, on paper, off this machine.')
69 console.log(' They are shown once. Nothing is saved to disk.')
70 console.log('')
71 console.log(' ' + mnemonic.split(' ').slice(0, 12).join(' '))
72 console.log(' ' + mnemonic.split(' ').slice(12).join(' '))
73 console.log('')
74 console.log(' public did:key (safe to share, this is what you register):')
75 console.log(' ' + key.didKey)
76 console.log('')
77 console.log(' derivation: BIP39 -> ' + ATPROTO_RECOVERY_PATH + ' (secp256k1)')
78 console.log('')
79 console.log(' VERIFY before registering — re-type the phrase into a fresh shell and confirm')
80 console.log(' it reproduces the same did:key. A key you cannot reproduce is not a backup.')
81 console.log('')
82}
83
84// ---------------------------------------------------------------------------
85// Local rotation-key material we can sign with. Same sources as rotation-check.
86// Returns { didKey -> {privateKey, label} } for every secp256k1 key we can produce.
87// ---------------------------------------------------------------------------
88function localSigners (cfg) {
89 const out = new Map()
90 const add = (k, label) => { if (k && !out.has(k.didKey)) out.set(k.didKey, { privateKey: k.privateKey, label }) }
91
92 const vault = readJson(path.join(HOME, '.hyperident', 'identity'))
93 if (vault && vault.mnemonic && !vault.encrypted) {
94 for (const [n, p] of [['signing-path', ATPROTO_SIGNING_PATH], ['recovery-path', ATPROTO_RECOVERY_PATH]]) {
95 try { add(deriveAtprotoKey(vault.mnemonic, p), `~/.hyperident/identity [${n}]`) } catch {}
96 }
97 }
98 const lc = readJson(path.join(HOME, '.config', 'loser-club', 'secrets.json'))
99 if (lc && lc.rootMnemonic) {
100 for (const [n, p] of [['signing-path', ATPROTO_SIGNING_PATH], ['recovery-path', ATPROTO_RECOVERY_PATH]]) {
101 try { add(deriveAtprotoKey(lc.rootMnemonic, p), `~/.config/loser-club/secrets.json [${n}]`) } catch {}
102 }
103 }
104 for (const d of (cfg.dids || [])) {
105 if (d.deviceMnemonic) {
106 for (const [n, p] of [['signing-path', ATPROTO_SIGNING_PATH], ['recovery-path', ATPROTO_RECOVERY_PATH]]) {
107 try { add(deriveAtprotoKey(d.deviceMnemonic, p), `hpp config deviceMnemonic [${n}]`) } catch {}
108 }
109 }
110 }
111 return out
112}
113
114async function cmdAdd () {
115 const did = flag('did')
116 const addKey = flag('add')
117 const publish = has('publish')
118 if (!did || !addKey) {
119 console.error('usage: rotation-keys.mjs add --did <did:plc:...> --add <did:key:...> [--publish]')
120 process.exit(2)
121 }
122 if (!/^did:key:z/.test(addKey)) {
123 console.error('refusing: --add must be a PUBLIC did:key (never a mnemonic or private key)')
124 process.exit(2)
125 }
126
127 const cfg = readJson(path.join(HOME, '.config', 'hyper-proto-peer', 'config.json')) || {}
128 const plc = new PlcDirectory({})
129 const head = await plc.getHead(did)
130 const current = head.rotationKeys || []
131
132 console.log(`did: ${did}`)
133 console.log(`current rotation: ${current.length} key(s)`)
134 current.forEach((k, i) => console.log(` [${i}] ${k}`))
135
136 if (current.includes(addKey)) {
137 console.log(`\n${addKey} is ALREADY a rotation key on this DID — nothing to do.`)
138 process.exit(0)
139 }
140
141 const signers = localSigners(cfg)
142 const usable = current.map((k, i) => ({ k, i, s: signers.get(k) })).filter((x) => x.s)
143 if (!usable.length) {
144 console.error('\n🚨 no local secret derives ANY current rotation key for this DID.')
145 console.error(' Nothing can add a key here — this DID is already frozen. (This is wj62.)')
146 process.exit(1)
147 }
148 const signer = usable[0]
149 console.log(`\nsigning with: [${signer.i}] ${signer.s.label}`)
150
151 // New key FIRST — highest authority. Everything else carried forward unchanged: a PLC op
152 // replaces the whole document state, it is not a patch.
153 const rotationDidKeys = [addKey, ...current]
154 const unsigned = buildUpdateOp({
155 prevSignedOp: head,
156 rotationDidKeys,
157 verificationMethods: head.verificationMethods,
158 alsoKnownAs: head.alsoKnownAs,
159 services: head.services
160 })
161 const sig = signOp(unsigned, signer.s.privateKey)
162 const signedOp = { ...unsigned, sig }
163
164 const chk = checkUpdateOp(signedOp, head)
165 console.log(`\nlocal validation: ${chk.ok ? 'PASS' : 'FAIL — ' + chk.error}`)
166 console.log('\nresulting rotationKeys:')
167 rotationDidKeys.forEach((k, i) => console.log(` [${i}] ${k}${k === addKey ? ' <-- NEW (highest authority)' : ''}`))
168 console.log('\nop to be submitted:')
169 console.log(JSON.stringify(signedOp, null, 2))
170
171 if (!chk.ok) process.exit(1)
172
173 if (!publish) {
174 console.log('\nDRY RUN — nothing was submitted. Re-run with --publish to append this to the')
175 console.log('global PLC op-log. That append is PERMANENT and cannot be undone or deleted.')
176 console.log('Confirm the new key is written down and reproducible FIRST.')
177 return
178 }
179
180 console.log('\nsubmitting to plc.directory (irreversible) …')
181 const after = await plc.submitUpdate({ did, signedOp })
182 console.log('submitted. live rotationKeys now:')
183 ;(after.rotationKeys || []).forEach((k, i) => console.log(` [${i}] ${k}`))
184}
185
186const main = async () => {
187 if (MODE === 'gen') return cmdGen()
188 if (MODE === 'add') return cmdAdd()
189 console.error('usage: rotation-keys.mjs gen | add --did <did> --add <did:key> [--publish]')
190 process.exit(2)
191}
192
193main().catch((e) => { console.error('rotation-keys failed:', e.message); process.exit(1) })