OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer commits / 5744583

0.3.115: rotation is a subcommand, not a loose script
The rotation tools first shipped as scripts/rotation-*.mjs, which in practice meant "ssh to the box, find the install tree, pick a node binary new enough to run it, then run a loose file" -- exactly the friction `diag` exists to have removed. A capability that is awkward to invoke is one nobody invokes, and this one has a six-week-late failure behind it (hyper-social #47). hyper-proto-peer rotation can we still change each hosted DID? hyper-proto-peer rotation gen mint an offline recovery key (prints ONCE) hyper-proto-peer rotation add --did D --key K register K as an additional rotation key Runs under Bare through the normal dispatch, alongside diag/cores, so it needs no external runtime and no absolute-path imports. `rotation` is READ-ONLY and offline-first; exit 1 if any hosted DID is unrecoverable, 2 if any is undetermined, so it is usable as a scheduled alarm. `rotation gen` writes NOTHING to disk. A backup this daemon could write would be a backup on the same disk as the thing it backs up -- which is precisely how wj62's only copy came to be its only copy. `rotation add` is DRY RUN unless --publish, refuses any --key that is not a public did:key, and APPENDS rather than prepends: lower index is higher authority, so ranking a fresh paper key at [0] would make a photograph of it supreme with no possible fork against it. The incumbent keeps [0] and its fork authority; the backup at [1] still fixes single-key loss. The two scripts are REMOVED rather than left beside the subcommand -- two paths to one capability drift, which is the same defect the audit found in the sibling guard (four divergent copies of the same shell). Both are one day old and remain in git history. Verified live under Bare on tabertron, all three verbs: rotation wj62 unrecoverable (exit 1), loser-club held via ~/.config/loser-club/secrets.json [recovery-path], single-key warned rotation gen mints a key, prints only the PUBLIC did:key rotation add loser-club dry run, local validation PASS, [0] incumbent [1] backup Tests: rotation-guard 6/6 14/14, diag 3/3 32/32. Refs: hyper-social #47 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5744583de20676cdeab8ab706952184750121f2b
by plankatron <fleeky@gmail.com> · 2026-08-17T21:43:21.000Z

Changed files (5) +210 -398

index.js +8 -0
……@@ -37,6 +37,14 @@ if (sub === 'diag') {
3737// to an actual core (store dir, public key, alias name, and whether THIS machine can sign it). That log is
3838// upstream's only trace of a conflict and prints a one-way hash, so the core was previously unnameable.
3939// Opens every corestore read-only, so it is safe to run against the daemon while it is up.
40// rotation — READ-ONLY by default: can we still CHANGE each hosted DID? Every other field in `diag`
41// answers "is this node advancing?"; none can answer "could we still fix this if we had to?", and that
42// question had exactly one wrong answer for six weeks (#47). `rotation gen` mints an offline recovery
43// key; `rotation add` registers one (DRY RUN unless --publish — a PLC append is permanent).
44if (sub === 'rotation') {
45 const { rotationCommand } = await import('./lib/rotation-cli.js')
46 globalThis.Bare.exit(await rotationCommand(argv.slice(1)))
47}
4048if (sub === 'cores') {
4149 const { coresCommand } = await import('./lib/cores.js')
4250 globalThis.Bare.exit(await coresCommand(argv.slice(1)))
lib/rotation-cli.js +201 -0
……@@ -0,0 +1,201 @@
1// `hyper-proto-peer rotation` — the operator surface for PLC rotation authority.
2//
3// WHY A SUBCOMMAND AND NOT A SCRIPT. These first shipped as scripts/rotation-*.mjs, which meant
4// "ssh to the box, find the install tree, pick a node binary that is new enough, run a loose file" —
5// the exact friction `diag` exists to have removed. A capability that is awkward to invoke is one
6// nobody invokes, and this one has a six-week-late failure behind it (hyper-social #47).
7//
8// hyper-proto-peer rotation can we still change each hosted DID?
9// hyper-proto-peer rotation gen mint an offline recovery key (prints ONCE)
10// hyper-proto-peer rotation add --did D --key K register K as an additional rotation key
11// (DRY RUN unless --publish)
12//
13// `rotation` (the check) is READ-ONLY and offline-first. `add --publish` is the only writing path in
14// this file, and it appends PERMANENTLY to a global public log.
15import fs from 'bare-fs'
16import os from 'bare-os'
17import IdentityKey from 'keet-identity-key'
18import {
19 deriveAtprotoKey, keyFromPrivate, buildUpdateOp, signOp, ATPROTO_RECOVERY_PATH, ATPROTO_SIGNING_PATH
20} from 'atproto-identity'
21import { PlcDirectory, checkUpdateOp } from 'atproto-identity/lib/plc-directory.js'
22import { loadConfig } from './config.js'
23import { collectRotationHeld, localRotationCandidates } from './rotation-guard.js'
24
25const flag = (argv, n, d = null) => { const i = argv.indexOf('--' + n); return i !== -1 && i + 1 < argv.length ? argv[i + 1] : d }
26const has = (argv, n) => argv.includes('--' + n)
27
28async function ensureFetch () {
29 if (typeof globalThis.fetch !== 'function') await import('bare-fetch/global')
30}
31
32// ── the check ────────────────────────────────────────────────────────────────────────────────────
33async function cmdStatus (argv) {
34 const cfg = loadConfig()
35 const json = has(argv, 'json')
36 const held = await collectRotationHeld({ cfg, storage: cfg.storage })
37 const rows = []
38 for (const d of (cfg.dids || [])) rows.push({ did: d.did, handle: d.handle, ...(held.get(d.did) || {}) })
39
40 if (json) { console.log(JSON.stringify({ checkedAt: new Date().toISOString(), dids: rows }, null, 2)); }
41 else {
42 console.log('rotation — can we still CHANGE each hosted DID?\n')
43 for (const r of rows) {
44 const mark = r.held === true ? '✅' : (r.held === null ? '❔' : '🚨')
45 console.log(`${mark} ${r.handle || r.did}\n did: ${r.did}`)
46 if (r.held === true) {
47 console.log(` held by: ${r.heldBy}`)
48 if (r.singleKeyRisk) {
49 console.log(' ⚠ only ONE rotation key exists. Lose this secret and the DID is frozen forever.')
50 console.log(' hyper-proto-peer rotation gen → then rotation add --did … --key …')
51 }
52 } else if (r.held === null) {
53 console.log(` could not determine — ${r.reason}`)
54 } else {
55 console.log(` 🚨 no local secret derives ANY of this DID's ${r.rotationKeyCount} rotation key(s).`)
56 console.log(' This DID can never be changed again: no handle change, no PDS move, no key')
57 console.log(' rotation after a leak, no tombstone. Serving still works, which is why it hides.')
58 console.log(' If a copy of the root exists ANYWHERE (another machine, removable media,')
59 console.log(' paper) recover it NOW — this is not repairable later.')
60 }
61 console.log('')
62 }
63 const bad = rows.filter((r) => r.held === false).length
64 const unk = rows.filter((r) => r.held === null).length
65 console.log(`${rows.length} hosted DID(s): ${rows.length - bad - unk} OK, ${bad} unrecoverable, ${unk} unknown`)
66 }
67 return rows.some((r) => r.held === false) ? 1 : (rows.some((r) => r.held === null) ? 2 : 0)
68}
69
70// ── mint an offline recovery key ─────────────────────────────────────────────────────────────────
71// The phrase leaves this process ONLY via your terminal. Nothing is written to disk, because a backup
72// this daemon could write is a backup on the same disk as the thing it is backing up — which is how
73// wj62's only copy came to be its only copy.
74function cmdGen () {
75 const mnemonic = IdentityKey.generateMnemonic()
76 const key = deriveAtprotoKey(mnemonic, ATPROTO_RECOVERY_PATH)
77 const w = mnemonic.split(' ')
78 console.log('')
79 console.log(' NEW ROTATION KEY — write these words down NOW, on paper, off this machine.')
80 console.log(' Shown once. Nothing is saved.\n')
81 console.log(' ' + w.slice(0, 12).join(' '))
82 console.log(' ' + w.slice(12).join(' '))
83 console.log('\n public did:key (safe to share — this is what you register):')
84 console.log(' ' + key.didKey)
85 console.log('\n derivation: BIP39 → ' + ATPROTO_RECOVERY_PATH + ' (secp256k1)')
86 console.log('\n VERIFY BEFORE REGISTERING: re-enter the phrase in a fresh shell and confirm it')
87 console.log(' reproduces the same did:key. A key you cannot reproduce is not a backup, and')
88 console.log(' registering one only moves the single point of failure.\n')
89 return 0
90}
91
92// ── register an additional rotation key ──────────────────────────────────────────────────────────
93async function cmdAdd (argv) {
94 const did = flag(argv, 'did')
95 const addKey = flag(argv, 'key')
96 const publish = has(argv, 'publish')
97 if (!did || !addKey) {
98 console.error('usage: hyper-proto-peer rotation add --did <did:plc:…> --key <did:key:…> [--publish]')
99 return 2
100 }
101 if (!/^did:key:z/.test(addKey)) {
102 console.error('refusing: --key must be a PUBLIC did:key (never a mnemonic or private key)')
103 return 2
104 }
105 await ensureFetch()
106 const cfg = loadConfig()
107 const plc = new PlcDirectory({})
108 const head = await plc.getHead(did)
109 const current = head.rotationKeys || []
110
111 console.log(`did: ${did}`)
112 console.log(`current rotation: ${current.length} key(s)`)
113 current.forEach((k, i) => console.log(` [${i}] ${k}`))
114 if (current.includes(addKey)) { console.log(`\n${addKey}\nis ALREADY a rotation key on this DID — nothing to do.`); return 0 }
115
116 const { owned } = localRotationCandidates(cfg)
117 const signer = current.map((k, i) => ({ k, i, by: owned.get(k) })).find((x) => x.by)
118 if (!signer) {
119 console.error('\n🚨 no local secret derives ANY current rotation key for this DID.')
120 console.error(' Nothing can add a key here — this DID is already frozen. (This is wj62.)')
121 return 1
122 }
123 // Re-derive the PRIVATE half of the signer we just identified by its public key.
124 const priv = privateFor(cfg, signer.k)
125 if (!priv) { console.error('\ninternal: located a signer public key but could not re-derive its private half'); return 1 }
126 console.log(`\nsigning with: [${signer.i}] ${signer.by}`)
127
128 // APPEND, never prepend. Lower index = higher authority (did-plc §4: a recovery fork must be signed by
129 // a STRICTLY LOWER index than the op it overrides). Ranking a fresh offline key first would make it
130 // supreme — nothing could fork against it, so a photograph of that paper would end the identity with no
131 // recourse. Keeping the incumbent at [0] preserves its power to fork back if the backup is stolen,
132 // while the backup at [1] still fixes single-key LOSS. Both failure modes covered, neither traded.
133 const rotationDidKeys = [...current, addKey]
134 const unsigned = buildUpdateOp({
135 prevSignedOp: head,
136 rotationDidKeys,
137 verificationMethods: head.verificationMethods,
138 alsoKnownAs: head.alsoKnownAs,
139 services: head.services
140 })
141 const signedOp = { ...unsigned, sig: signOp(unsigned, priv) }
142 const chk = checkUpdateOp(signedOp, head)
143 console.log(`\nlocal validation: ${chk.ok ? 'PASS' : 'FAIL — ' + chk.error}`)
144 console.log('\nresulting rotationKeys:')
145 rotationDidKeys.forEach((k, i) => console.log(` [${i}] ${k}${k === addKey ? ' ← NEW backup (lower authority than [0], by design)' : ''}`))
146 if (!chk.ok) return 1
147
148 if (!publish) {
149 console.log('\nDRY RUN — nothing submitted. Re-run with --publish to append this to the global')
150 console.log('PLC op-log. That append is PERMANENT: no edit, no delete, no undo. Confirm the new')
151 console.log('key is written down and reproducible FIRST.')
152 return 0
153 }
154 console.log('\nsubmitting to plc.directory (irreversible) …')
155 const after = await plc.submitUpdate({ did, signedOp })
156 console.log('submitted. live rotationKeys now:')
157 ;(after.rotationKeys || []).forEach((k, i) => console.log(` [${i}] ${k}`))
158 return 0
159}
160
161// Find the private key whose public did:key is `want`, from the same local material the check uses.
162function privateFor (cfg, want) {
163 const tryKey = (k) => (k && k.didKey === want ? k.privateKey : null)
164 const fromMnemonic = (mn) => {
165 for (const p of [ATPROTO_SIGNING_PATH, ATPROTO_RECOVERY_PATH]) {
166 try { const hit = tryKey(deriveAtprotoKey(mn, p)); if (hit) return hit } catch {}
167 }
168 return null
169 }
170 const home = os.homedir()
171 const read = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null } }
172 const vault = read(home + '/.hyperident/identity')
173 if (vault && vault.mnemonic && !vault.encrypted) { const h = fromMnemonic(vault.mnemonic); if (h) return h }
174 const lc = read(home + '/.config/loser-club/secrets.json')
175 if (lc && lc.rootMnemonic) { const h = fromMnemonic(lc.rootMnemonic); if (h) return h }
176 for (const d of (cfg.dids || [])) {
177 if (d.deviceMnemonic) { const h = fromMnemonic(d.deviceMnemonic); if (h) return h }
178 if (d.signingKey && /^[0-9a-f]{64}$/i.test(d.signingKey)) {
179 try { const hit = tryKey(keyFromPrivate(hexBytes(d.signingKey))); if (hit) return hit } catch {}
180 }
181 }
182 return null
183}
184
185function hexBytes (hex) {
186 const out = new Uint8Array(hex.length / 2)
187 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16)
188 return out
189}
190
191export async function rotationCommand (argv = []) {
192 const mode = argv[0] && !argv[0].startsWith('-') ? argv[0] : null
193 if (mode === 'gen') return cmdGen()
194 if (mode === 'add') return cmdAdd(argv.slice(1))
195 if (mode && mode !== 'status') {
196 console.error(`unknown: rotation ${mode}`)
197 console.error('usage: hyper-proto-peer rotation [--json] | rotation gen | rotation add --did <did> --key <did:key> [--publish]')
198 return 2
199 }
200 return cmdStatus(argv)
201}
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-proto-peer",
3 "version": "0.3.114",
3 "version": "0.3.115",
44 "description": "Self-custody, P2P-native atproto peer \u2014 holds the user's key + content as Hypercores and authors+signs its own repo. The source of truth in the peer/bridge split (Model B); atproto-bridge projects it to bsky.",
55 "type": "module",
66 "main": "lib/peer.js",
scripts/rotation-check.mjs +0 -196
……@@ -1,196 +0,0 @@
1// rotation-check — assert that every hosted DID still has a rotation key we can derive locally.
2//
3// WHY THIS EXISTS (hyper-social #47):
4// wj62's PLC rotation key was derived once, on 2026-07-02, from the mnemonic in
5// ~/.hyperident/identity. Nothing in the running stack ever read that file again — pgh and
6// pear-git-v3 resolve identity from ~/.pgh/linked-device.json instead — so when the vault went
7// missing sometime before 2026-07-28, NOTHING NOTICED. Not a daemon, not a health check, not a
8// user. The DID kept working perfectly, because serving a repo needs only the SIGNING key, which
9// we still hold. What was lost was the ability to ever CHANGE the identity: no handle change, no
10// PDS move, no key rotation after a leak, no tombstone. Permanently.
11//
12// That failure is invisible by construction. The only way to catch it is to explicitly ask the
13// question this script asks. Run it at boot and on a schedule.
14//
15// READ-ONLY. Derives public keys from local secrets and compares did:key strings. Never prints,
16// logs, or transmits a secret. Never writes anything.
17//
18// node scripts/rotation-check.mjs [--json] [--offline]
19//
20// Exit 0 = every hosted DID has a locally-derivable rotation key.
21// Exit 1 = at least one does not ← the alarm. Act on it while recovery is still possible.
22// Exit 2 = could not determine (config unreadable / rotationKeys unresolvable).
23
24import fs from 'fs'
25import os from 'os'
26import path from 'path'
27import {
28 deriveAtprotoKey,
29 keyFromPrivate,
30 ATPROTO_SIGNING_PATH,
31 ATPROTO_RECOVERY_PATH
32} from '../node_modules/atproto-identity/lib/atproto-identity.js'
33
34const JSON_OUT = process.argv.includes('--json')
35const OFFLINE = process.argv.includes('--offline')
36const HOME = os.homedir()
37
38function readJson (p) {
39 try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null }
40}
41
42// ---------------------------------------------------------------------------
43// Candidate local secrets.
44//
45// Deliberately broad: the point is to prove SOME local material still controls the DID, not to
46// assert which one should. A key found in an unexpected place is still a key we hold — and
47// finding it in a surprising place is itself worth reporting.
48// ---------------------------------------------------------------------------
49function collectCandidates (cfg) {
50 const out = [] // { label, kind: 'mnemonic'|'privhex', value }
51 const vault = readJson(path.join(HOME, '.hyperident', 'identity'))
52 if (vault && vault.mnemonic && !vault.encrypted) {
53 out.push({ label: '~/.hyperident/identity', kind: 'mnemonic', value: vault.mnemonic })
54 } else if (vault && vault.encrypted) {
55 out.push({ label: '~/.hyperident/identity (ENCRYPTED — cannot check)', kind: 'locked' })
56 }
57
58 const lc = readJson(path.join(HOME, '.config', 'loser-club', 'secrets.json'))
59 if (lc && lc.rootMnemonic) {
60 out.push({ label: '~/.config/loser-club/secrets.json', kind: 'mnemonic', value: lc.rootMnemonic })
61 }
62
63 for (const d of (cfg.dids || [])) {
64 if (d.deviceMnemonic) {
65 out.push({ label: `hpp config: ${short(d.did)}.deviceMnemonic`, kind: 'mnemonic', value: d.deviceMnemonic })
66 }
67 if (d.signingKey && /^[0-9a-f]{64}$/i.test(d.signingKey)) {
68 out.push({ label: `hpp config: ${short(d.did)}.signingKey`, kind: 'privhex', value: d.signingKey })
69 }
70 }
71 return out
72}
73
74// Every public did:key a candidate can produce. secp256k1 only — PLC rotation keys are secp256k1.
75function didKeysFor (cand) {
76 const keys = []
77 try {
78 if (cand.kind === 'mnemonic') {
79 for (const [pathName, p] of [['signing-path', ATPROTO_SIGNING_PATH], ['recovery-path', ATPROTO_RECOVERY_PATH]]) {
80 keys.push({ didKey: deriveAtprotoKey(cand.value, p).didKey, how: pathName })
81 }
82 } else if (cand.kind === 'privhex') {
83 keys.push({ didKey: keyFromPrivate(Buffer.from(cand.value, 'hex')).didKey, how: 'raw-private' })
84 }
85 } catch { /* an unusable candidate is not an error — it just proves nothing */ }
86 return keys
87}
88
89const short = (did) => (did || '').replace('did:plc:', '').slice(0, 8)
90
91async function rotationKeysFor (did, cfg) {
92 // Offline first: a boot check must not depend on the network. The genesis op we persisted at
93 // mint time carries the authoritative rotationKeys for a never-rotated DID.
94 for (const p of [
95 path.join(HOME, '.config', 'atproto-pds', 'pending-genesis.json'),
96 path.join(cfgStorage(cfg), 'peer-identity.json')
97 ]) {
98 const j = readJson(p)
99 if (j && j.did === did) {
100 const rk = (j.signedOp && j.signedOp.rotationKeys) || j.rotationKeys
101 if (Array.isArray(rk) && rk.length) return { keys: rk, source: p.replace(HOME, '~') }
102 }
103 }
104 if (OFFLINE) return { keys: null, source: 'offline: no local genesis found' }
105 try {
106 const res = await fetch(`https://plc.directory/${did}/data`, { signal: AbortSignal.timeout(10000) })
107 if (!res.ok) return { keys: null, source: `plc.directory HTTP ${res.status}` }
108 const doc = await res.json()
109 return { keys: doc.rotationKeys || null, source: 'plc.directory' }
110 } catch (e) {
111 return { keys: null, source: 'plc.directory unreachable: ' + e.message }
112 }
113}
114
115function cfgStorage (cfg) {
116 const s = cfg.storage || path.join(HOME, '.config', 'hyper-proto-peer', 'data')
117 return s.replace(/^~/, HOME).replace(/\$\{HOME\}/g, HOME)
118}
119
120async function main () {
121 const cfgPath = path.join(HOME, '.config', 'hyper-proto-peer', 'config.json')
122 const cfg = readJson(cfgPath)
123 if (!cfg || !Array.isArray(cfg.dids)) {
124 console.error(`rotation-check: cannot read hosted DIDs from ${cfgPath}`)
125 process.exit(2)
126 }
127
128 const candidates = collectCandidates(cfg)
129 // Build the lookup once — this is the expensive part (PBKDF2 per mnemonic).
130 const owned = new Map() // didKey -> "label (how)"
131 for (const c of candidates) {
132 for (const k of didKeysFor(c)) {
133 if (!owned.has(k.didKey)) owned.set(k.didKey, `${c.label} [${k.how}]`)
134 }
135 }
136
137 const report = []
138 let alarm = false
139 let unknown = false
140
141 for (const d of cfg.dids) {
142 const { keys, source } = await rotationKeysFor(d.did, cfg)
143 if (!keys) {
144 unknown = true
145 report.push({ did: d.did, handle: d.handle, status: 'UNKNOWN', detail: source, rotationKeys: null })
146 continue
147 }
148 const held = keys.map((k) => ({ rotationKey: k, heldBy: owned.get(k) || null })).filter((x) => x.heldBy)
149 const ok = held.length > 0
150 if (!ok) alarm = true
151 report.push({
152 did: d.did,
153 handle: d.handle,
154 status: ok ? 'OK' : 'NO_LOCAL_ROTATION_KEY',
155 rotationKeyCount: keys.length,
156 heldLocally: held.map((h) => ({ rotationKey: h.rotationKey, source: h.heldBy })),
157 rotationKeysSource: source,
158 // A single rotation key is a single point of failure even when we DO hold it.
159 singleKeyRisk: keys.length === 1
160 })
161 }
162
163 if (JSON_OUT) {
164 console.log(JSON.stringify({ checkedAt: new Date().toISOString(), report }, null, 2))
165 } else {
166 console.log('rotation-check — can we still control each hosted DID?\n')
167 for (const r of report) {
168 const mark = r.status === 'OK' ? '✅' : (r.status === 'UNKNOWN' ? '❔' : '🚨')
169 console.log(`${mark} ${r.handle || r.did}`)
170 console.log(` did: ${r.did}`)
171 if (r.status === 'UNKNOWN') {
172 console.log(` could not resolve rotationKeys — ${r.detail}`)
173 } else if (r.status === 'OK') {
174 for (const h of r.heldLocally) console.log(` held by: ${h.source}`)
175 if (r.singleKeyRisk) {
176 console.log(' ⚠ only ONE rotation key exists. Lose this secret and the DID is frozen forever.')
177 console.log(' Add more rotation keys, held on different machines/media.')
178 }
179 } else {
180 console.log(` 🚨 NO local secret derives ANY of this DID's ${r.rotationKeyCount} rotation key(s).`)
181 console.log(' This DID can never be changed again: no handle change, no PDS move,')
182 console.log(' no key rotation after a leak, no tombstone. Serving still works.')
183 console.log(' If a copy of the root secret exists ANYWHERE (other machine, removable')
184 console.log(' media, paper), recover it NOW — this is not repairable later.')
185 }
186 console.log('')
187 }
188 const bad = report.filter((r) => r.status === 'NO_LOCAL_ROTATION_KEY').length
189 const unk = report.filter((r) => r.status === 'UNKNOWN').length
190 console.log(`${report.length} hosted DID(s): ${report.length - bad - unk} OK, ${bad} unrecoverable, ${unk} unknown`)
191 }
192
193 process.exit(alarm ? 1 : (unknown ? 2 : 0))
194}
195
196main().catch((e) => { console.error('rotation-check failed:', e.message); process.exit(2) })
scripts/rotation-keys.mjs +0 -201
……@@ -1,201 +0,0 @@
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 APPENDED, never prepended. Lower index = higher authority (did-plc §4:
30// a recovery fork must be signed by a key with a STRICTLY LOWER index than the one it overrides).
31//
32// It is tempting to rank a fresh offline key first "because it is the safest one". That is
33// backwards. A key at [0] is supreme: nothing can fork against it, so a photograph of that paper
34// ends the identity with no recourse — precisely the unrecoverable shape keet has and did:plc
35// exists to avoid. Keeping the incumbent root at [0] preserves its power to fork back if the
36// backup is ever stolen, while the backup at [1] still fixes the single-key LOSS problem.
37//
38// The existing key is always preserved, so capability is only ever added to, never reduced.
39//
40// ⚠ Do not register a key you have not recorded. An unbacked-up rotation key is the exact
41// failure this tool exists to prevent, and registering one merely moves the single point.
42
43import fs from 'fs'
44import os from 'os'
45import path from 'path'
46import {
47 deriveAtprotoKey,
48 keyFromPrivate,
49 buildUpdateOp,
50 signOp,
51 generateAtprotoKey,
52 ATPROTO_SIGNING_PATH,
53 ATPROTO_RECOVERY_PATH
54} from '../node_modules/atproto-identity/lib/atproto-identity.js'
55import { PlcDirectory, checkUpdateOp } from '../node_modules/atproto-identity/lib/plc-directory.js'
56// Same generator hyperident itself uses (`identity-manager.js:25`, `hpp-onboard.js:114`), so a
57// phrase produced here is interchangeable with any other root in this stack.
58import IdentityKey from 'keet-identity-key'
59
60const HOME = os.homedir()
61const argv = process.argv.slice(2)
62const MODE = argv[0]
63const flag = (n, d = null) => { const i = argv.indexOf('--' + n); return i !== -1 && i + 1 < argv.length ? argv[i + 1] : d }
64const has = (n) => argv.includes('--' + n)
65
66const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null } }
67
68// ---------------------------------------------------------------------------
69// gen — a new offline recovery key. The phrase leaves this process only via your terminal.
70// ---------------------------------------------------------------------------
71function cmdGen () {
72 const mnemonic = IdentityKey.generateMnemonic() // 24 words, same as hyperident
73 const key = deriveAtprotoKey(mnemonic, ATPROTO_RECOVERY_PATH)
74 console.log('')
75 console.log(' NEW ROTATION KEY — write these 24 words down NOW, on paper, off this machine.')
76 console.log(' They are shown once. Nothing is saved to disk.')
77 console.log('')
78 console.log(' ' + mnemonic.split(' ').slice(0, 12).join(' '))
79 console.log(' ' + mnemonic.split(' ').slice(12).join(' '))
80 console.log('')
81 console.log(' public did:key (safe to share, this is what you register):')
82 console.log(' ' + key.didKey)
83 console.log('')
84 console.log(' derivation: BIP39 -> ' + ATPROTO_RECOVERY_PATH + ' (secp256k1)')
85 console.log('')
86 console.log(' VERIFY before registering — re-type the phrase into a fresh shell and confirm')
87 console.log(' it reproduces the same did:key. A key you cannot reproduce is not a backup.')
88 console.log('')
89}
90
91// ---------------------------------------------------------------------------
92// Local rotation-key material we can sign with. Same sources as rotation-check.
93// Returns { didKey -> {privateKey, label} } for every secp256k1 key we can produce.
94// ---------------------------------------------------------------------------
95function localSigners (cfg) {
96 const out = new Map()
97 const add = (k, label) => { if (k && !out.has(k.didKey)) out.set(k.didKey, { privateKey: k.privateKey, label }) }
98
99 const vault = readJson(path.join(HOME, '.hyperident', 'identity'))
100 if (vault && vault.mnemonic && !vault.encrypted) {
101 for (const [n, p] of [['signing-path', ATPROTO_SIGNING_PATH], ['recovery-path', ATPROTO_RECOVERY_PATH]]) {
102 try { add(deriveAtprotoKey(vault.mnemonic, p), `~/.hyperident/identity [${n}]`) } catch {}
103 }
104 }
105 const lc = readJson(path.join(HOME, '.config', 'loser-club', 'secrets.json'))
106 if (lc && lc.rootMnemonic) {
107 for (const [n, p] of [['signing-path', ATPROTO_SIGNING_PATH], ['recovery-path', ATPROTO_RECOVERY_PATH]]) {
108 try { add(deriveAtprotoKey(lc.rootMnemonic, p), `~/.config/loser-club/secrets.json [${n}]`) } catch {}
109 }
110 }
111 for (const d of (cfg.dids || [])) {
112 if (d.deviceMnemonic) {
113 for (const [n, p] of [['signing-path', ATPROTO_SIGNING_PATH], ['recovery-path', ATPROTO_RECOVERY_PATH]]) {
114 try { add(deriveAtprotoKey(d.deviceMnemonic, p), `hpp config deviceMnemonic [${n}]`) } catch {}
115 }
116 }
117 }
118 return out
119}
120
121async function cmdAdd () {
122 const did = flag('did')
123 const addKey = flag('add')
124 const publish = has('publish')
125 if (!did || !addKey) {
126 console.error('usage: rotation-keys.mjs add --did <did:plc:...> --add <did:key:...> [--publish]')
127 process.exit(2)
128 }
129 if (!/^did:key:z/.test(addKey)) {
130 console.error('refusing: --add must be a PUBLIC did:key (never a mnemonic or private key)')
131 process.exit(2)
132 }
133
134 const cfg = readJson(path.join(HOME, '.config', 'hyper-proto-peer', 'config.json')) || {}
135 const plc = new PlcDirectory({})
136 const head = await plc.getHead(did)
137 const current = head.rotationKeys || []
138
139 console.log(`did: ${did}`)
140 console.log(`current rotation: ${current.length} key(s)`)
141 current.forEach((k, i) => console.log(` [${i}] ${k}`))
142
143 if (current.includes(addKey)) {
144 console.log(`\n${addKey} is ALREADY a rotation key on this DID — nothing to do.`)
145 process.exit(0)
146 }
147
148 const signers = localSigners(cfg)
149 const usable = current.map((k, i) => ({ k, i, s: signers.get(k) })).filter((x) => x.s)
150 if (!usable.length) {
151 console.error('\n🚨 no local secret derives ANY current rotation key for this DID.')
152 console.error(' Nothing can add a key here — this DID is already frozen. (This is wj62.)')
153 process.exit(1)
154 }
155 const signer = usable[0]
156 console.log(`\nsigning with: [${signer.i}] ${signer.s.label}`)
157
158 // New key APPENDED — the incumbent root keeps index 0 and therefore keeps fork authority over
159 // the backup. Everything else carried forward unchanged: a PLC op replaces the whole document
160 // state, it is not a patch.
161 const rotationDidKeys = [...current, addKey]
162 const unsigned = buildUpdateOp({
163 prevSignedOp: head,
164 rotationDidKeys,
165 verificationMethods: head.verificationMethods,
166 alsoKnownAs: head.alsoKnownAs,
167 services: head.services
168 })
169 const sig = signOp(unsigned, signer.s.privateKey)
170 const signedOp = { ...unsigned, sig }
171
172 const chk = checkUpdateOp(signedOp, head)
173 console.log(`\nlocal validation: ${chk.ok ? 'PASS' : 'FAIL — ' + chk.error}`)
174 console.log('\nresulting rotationKeys:')
175 rotationDidKeys.forEach((k, i) => console.log(` [${i}] ${k}${k === addKey ? ' <-- NEW backup (lower authority than [0], by design)' : ''}`))
176 console.log('\nop to be submitted:')
177 console.log(JSON.stringify(signedOp, null, 2))
178
179 if (!chk.ok) process.exit(1)
180
181 if (!publish) {
182 console.log('\nDRY RUN — nothing was submitted. Re-run with --publish to append this to the')
183 console.log('global PLC op-log. That append is PERMANENT and cannot be undone or deleted.')
184 console.log('Confirm the new key is written down and reproducible FIRST.')
185 return
186 }
187
188 console.log('\nsubmitting to plc.directory (irreversible) …')
189 const after = await plc.submitUpdate({ did, signedOp })
190 console.log('submitted. live rotationKeys now:')
191 ;(after.rotationKeys || []).forEach((k, i) => console.log(` [${i}] ${k}`))
192}
193
194const main = async () => {
195 if (MODE === 'gen') return cmdGen()
196 if (MODE === 'add') return cmdAdd()
197 console.error('usage: rotation-keys.mjs gen | add --did <did> --add <did:key> [--publish]')
198 process.exit(2)
199}
200
201main().catch((e) => { console.error('rotation-keys failed:', e.message); process.exit(1) })