| … | … | @@ -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 | |
| 24 | import fs from 'fs' |
| 25 | import os from 'os' |
| 26 | import path from 'path' |
| 27 | import { |
| 28 | deriveAtprotoKey, |
| 29 | keyFromPrivate, |
| 30 | ATPROTO_SIGNING_PATH, |
| 31 | ATPROTO_RECOVERY_PATH |
| 32 | } from '../node_modules/atproto-identity/lib/atproto-identity.js' |
| 33 | |
| 34 | const JSON_OUT = process.argv.includes('--json') |
| 35 | const OFFLINE = process.argv.includes('--offline') |
| 36 | const HOME = os.homedir() |
| 37 | |
| 38 | function 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 | // --------------------------------------------------------------------------- |
| 49 | function 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. |
| 75 | function 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 | |
| 89 | const short = (did) => (did || '').replace('did:plc:', '').slice(0, 8) |
| 90 | |
| 91 | async 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 | |
| 115 | function 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 | |
| 120 | async 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 | |
| 196 | main().catch((e) => { console.error('rotation-check failed:', e.message); process.exit(2) }) |