OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer commits / f178977

scripts/rotation-check: assert each hosted DID still has a locally-derivable rotation key
wj62's PLC rotation key was derived once, on 2026-07-02, from the mnemonic in ~/.hyperident/identity. Nothing in the running stack ever read that file again -- pgh and pear-git-v3 resolve identity from ~/.pgh/linked-device.json instead -- so when the vault went missing sometime before 2026-07-28, nothing noticed. The DID kept serving perfectly, because serving needs only the SIGNING key, which we still hold. What was lost was the ability to ever CHANGE the identity: no handle change, no PDS move, no key rotation after a leak, no tombstone. Permanently. That failure is invisible by construction. This is the only check in the stack that would have fired between 07-02 and 07-28, while recovery was still possible. Read-only: derives public keys from local secrets and compares did:key strings. Never prints, logs or transmits a secret; never writes. Prefers the persisted genesis op over the network so it is usable at boot; falls back to plc.directory with a 10s deadline. Also flags singleKeyRisk -- holding the only rotation key is still a single point of failure. exit 0 = every hosted DID has a locally-derivable rotation key exit 1 = at least one does NOT <- the alarm exit 2 = undetermined Validated both directions against production: tabertron wj62 NO_LOCAL_ROTATION_KEY (exit 1); loser-club OK via ~/.config/loser-club/secrets.json [recovery-path] ashatron e2jev OK via ~/.hyperident/identity [recovery-path] (exit 0) Two DIDs resolving through different sources, so it detects real key possession rather than the presence of a file. Refs: hyper-social #47 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f1789773f6a19b387607f8e70d2f1530ad6dc01c
by plankatron <fleeky@gmail.com> · 2026-08-17T18:03:57.000Z

Changed files (1) +196 -0

scripts/rotation-check.mjs +196 -0
……@@ -0,0 +1,196 @@
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) })