| … | … | @@ -37,6 +37,14 @@ if (sub === 'diag') { |
| 37 | 37 | // to an actual core (store dir, public key, alias name, and whether THIS machine can sign it). That log is |
| 38 | 38 | // upstream's only trace of a conflict and prints a one-way hash, so the core was previously unnameable. |
| 39 | 39 | // 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). | |
| 44 | if (sub === 'rotation') { | |
| 45 | const { rotationCommand } = await import('./lib/rotation-cli.js') | |
| 46 | globalThis.Bare.exit(await rotationCommand(argv.slice(1))) | |
| 47 | } | |
| 40 | 48 | if (sub === 'cores') { |
| 41 | 49 | const { coresCommand } = await import('./lib/cores.js') |
| 42 | 50 | globalThis.Bare.exit(await coresCommand(argv.slice(1))) |
| … | … | @@ -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. | |
| 15 | import fs from 'bare-fs' | |
| 16 | import os from 'bare-os' | |
| 17 | import IdentityKey from 'keet-identity-key' | |
| 18 | import { | |
| 19 | deriveAtprotoKey, keyFromPrivate, buildUpdateOp, signOp, ATPROTO_RECOVERY_PATH, ATPROTO_SIGNING_PATH | |
| 20 | } from 'atproto-identity' | |
| 21 | import { PlcDirectory, checkUpdateOp } from 'atproto-identity/lib/plc-directory.js' | |
| 22 | import { loadConfig } from './config.js' | |
| 23 | import { collectRotationHeld, localRotationCandidates } from './rotation-guard.js' | |
| 24 | ||
| 25 | const flag = (argv, n, d = null) => { const i = argv.indexOf('--' + n); return i !== -1 && i + 1 < argv.length ? argv[i + 1] : d } | |
| 26 | const has = (argv, n) => argv.includes('--' + n) | |
| 27 | ||
| 28 | async function ensureFetch () { | |
| 29 | if (typeof globalThis.fetch !== 'function') await import('bare-fetch/global') | |
| 30 | } | |
| 31 | ||
| 32 | // ── the check ──────────────────────────────────────────────────────────────────────────────────── | |
| 33 | async 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. | |
| 74 | function 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 ────────────────────────────────────────────────────────── | |
| 93 | async 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. | |
| 162 | function 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 | ||
| 185 | function 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 | ||
| 191 | export 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 | } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "hyper-proto-peer", |
| 3 | "version": "0.3.114", | |
| 3 | "version": "0.3.115", | |
| 4 | 4 | "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.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "lib/peer.js", |
| … | … | @@ -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 | ||
| 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) }) |
| … | … | @@ -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 | ||
| 43 | import fs from 'fs' | |
| 44 | import os from 'os' | |
| 45 | import path from 'path' | |
| 46 | import { | |
| 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' | |
| 55 | import { 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. | |
| 58 | import IdentityKey from 'keet-identity-key' | |
| 59 | ||
| 60 | const HOME = os.homedir() | |
| 61 | const argv = process.argv.slice(2) | |
| 62 | const MODE = argv[0] | |
| 63 | const flag = (n, d = null) => { const i = argv.indexOf('--' + n); return i !== -1 && i + 1 < argv.length ? argv[i + 1] : d } | |
| 64 | const has = (n) => argv.includes('--' + n) | |
| 65 | ||
| 66 | const 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 | // --------------------------------------------------------------------------- | |
| 71 | function 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 | // --------------------------------------------------------------------------- | |
| 95 | function 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 | ||
| 121 | async 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 | ||
| 194 | const 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 | ||
| 201 | main().catch((e) => { console.error('rotation-keys failed:', e.message); process.exit(1) }) |