| … | … | @@ -25,6 +25,7 @@ import { Client } from 'hyper-svc' |
| 25 | 25 | import { loadConfig } from './config.js' |
| 26 | 26 | import { sockFor } from './control-socket.js' |
| 27 | 27 | import { loadOrCreateSwarmKeyPair } from './swarm-keypair.js' |
| 28 | import { collectRotationHeld } from './rotation-guard.js' | |
| 28 | 29 | import b4a from 'b4a' |
| 29 | 30 | |
| 30 | 31 | // Ask one op, and record WHY it failed rather than dropping the whole DID. A node with a wedged |
| … | … | @@ -109,6 +110,16 @@ export function summarize (did, r) { |
| 109 | 110 | stale: p.stale !== undefined ? p.stale : null, |
| 110 | 111 | missing: p.missing !== undefined ? p.missing : null, |
| 111 | 112 | noOid: p.unresolvable !== undefined ? p.unresolvable : null, |
| 113 | // CAN WE STILL CHANGE THIS IDENTITY? Every field above answers "is this node advancing?"; none of | |
| 114 | // them can answer this one, which is why wj62 went six weeks before anyone noticed (#47). TRI-STATE: | |
| 115 | // true = a local secret derives one of the DID's rotation keys; false = none does, and the DID is | |
| 116 | // frozen FOREVER (serving still works, which is exactly why it hides); null = could not determine | |
| 117 | // (no network, or an encrypted vault) — deliberately not false, because a check that could not run | |
| 118 | // is not a NO. | |
| 119 | rotationKeyHeld: r.rotation ? r.rotation.held : null, | |
| 120 | // true when the DID has exactly ONE rotation key. `rotationKeyHeld: true` with this also true is one | |
| 121 | // disk failure away from wj62's outcome, so the two are reported separately rather than merged. | |
| 122 | singleRotationKey: r.rotation ? r.rotation.singleKeyRisk : null, | |
| 112 | 123 | reachable: !r.unreachable, |
| 113 | 124 | failed: r.errors ? Object.keys(r.errors) : [] |
| 114 | 125 | } |
| … | … | @@ -137,6 +148,15 @@ export async function collectLocalDiag ({ cfg, storage, dids, limit = 50 } = {}) |
| 137 | 148 | } finally { try { if (client) await client.close() } catch {} } |
| 138 | 149 | out.push(row) |
| 139 | 150 | } |
| 151 | // Rotation authority is a LOCAL fact (a secret on this disk vs the DID's published rotationKeys), not | |
| 152 | // something the daemon can be asked for — so it is collected here rather than via an op. Bounded and | |
| 153 | // non-fatal: a diagnostic must never be the thing that hangs. | |
| 154 | try { | |
| 155 | const rot = await collectRotationHeld({ cfg, dids, storage }) | |
| 156 | for (const row of out) row.rotation = rot.get(row.did) || null | |
| 157 | } catch (e) { | |
| 158 | for (const row of out) row.rotation = { held: null, reason: 'rotation check failed: ' + ((e && e.message) || e) } | |
| 159 | } | |
| 140 | 160 | return out |
| 141 | 161 | } |
| 142 | 162 |
| … | … | @@ -0,0 +1,137 @@ |
| 1 | // rotation-guard — can this node still CHANGE the identities it hosts? | |
| 2 | // | |
| 3 | // WHY THIS EXISTS (hyper-social #47). wj62's PLC rotation key was derived once, on 2026-07-02, from | |
| 4 | // the mnemonic in ~/.hyperident/identity. Nothing in the running stack ever read that file again — | |
| 5 | // pgh and pear-git-v3 resolve identity from ~/.pgh/linked-device.json instead — so when the vault | |
| 6 | // went missing before 2026-07-28, NOTHING NOTICED. The DID kept serving perfectly, because serving | |
| 7 | // needs only the SIGNING key, which we still hold. What was lost was the ability to ever change the | |
| 8 | // identity: no handle change, no PDS move, no key rotation after a leak, no tombstone. Permanently. | |
| 9 | // | |
| 10 | // Every other field in `diag` answers "is this node advancing?". None of them can answer "could we | |
| 11 | // still fix this DID if we had to?" — and that question has exactly one wrong answer, discovered six | |
| 12 | // weeks late. This is the only check in the stack that would have fired while it was still fixable. | |
| 13 | // | |
| 14 | // READ-ONLY, and never handles a secret it does not have to: it derives PUBLIC did:keys from local | |
| 15 | // secrets and compares strings. Nothing is printed, logged, written or transmitted. | |
| 16 | import fs from 'bare-fs' | |
| 17 | import os from 'bare-os' | |
| 18 | import path from 'bare-path' | |
| 19 | import { deriveAtprotoKey, keyFromPrivate, ATPROTO_SIGNING_PATH, ATPROTO_RECOVERY_PATH } from 'atproto-identity' | |
| 20 | ||
| 21 | const HOME = os.homedir() | |
| 22 | const PATHS = [['signing-path', ATPROTO_SIGNING_PATH], ['recovery-path', ATPROTO_RECOVERY_PATH]] | |
| 23 | ||
| 24 | function readJson (p) { | |
| 25 | try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null } | |
| 26 | } | |
| 27 | ||
| 28 | // Every PUBLIC did:key we can still produce from a secret on this box, mapped to where it came from. | |
| 29 | // | |
| 30 | // Deliberately broad. The point is to prove SOME local material still controls the DID, not to assert | |
| 31 | // which file ought to. A key found somewhere unexpected is still a key we hold — and finding it | |
| 32 | // somewhere unexpected is itself worth reporting. | |
| 33 | export function localRotationCandidates (cfg = {}) { | |
| 34 | const owned = new Map() // didKey -> "source [how]" | |
| 35 | const add = (k, label) => { if (k && k.didKey && !owned.has(k.didKey)) owned.set(k.didKey, label) } | |
| 36 | const fromMnemonic = (mn, label) => { | |
| 37 | for (const [how, p] of PATHS) { | |
| 38 | try { add(deriveAtprotoKey(mn, p), `${label} [${how}]`) } catch { /* an unusable candidate proves nothing */ } | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | const vault = readJson(path.join(HOME, '.hyperident', 'identity')) | |
| 43 | if (vault && vault.mnemonic && !vault.encrypted) fromMnemonic(vault.mnemonic, '~/.hyperident/identity') | |
| 44 | // An ENCRYPTED vault is not "absent" — we simply cannot answer without the passphrase, and saying | |
| 45 | // "no key" there would be the same lie the 5A gate shipped (a check that could not run is not a NO). | |
| 46 | const vaultLocked = !!(vault && vault.encrypted) | |
| 47 | ||
| 48 | const lc = readJson(path.join(HOME, '.config', 'loser-club', 'secrets.json')) | |
| 49 | if (lc && lc.rootMnemonic) fromMnemonic(lc.rootMnemonic, '~/.config/loser-club/secrets.json') | |
| 50 | ||
| 51 | for (const d of (cfg.dids || [])) { | |
| 52 | const tag = (d.did || '').replace('did:plc:', '').slice(0, 8) | |
| 53 | if (d.deviceMnemonic) fromMnemonic(d.deviceMnemonic, `hpp config ${tag}.deviceMnemonic`) | |
| 54 | if (d.signingKey && /^[0-9a-f]{64}$/i.test(d.signingKey)) { | |
| 55 | try { add(keyFromPrivate(b(d.signingKey)), `hpp config ${tag}.signingKey [raw]`) } catch {} | |
| 56 | } | |
| 57 | } | |
| 58 | return { owned, vaultLocked } | |
| 59 | } | |
| 60 | ||
| 61 | function b (hex) { | |
| 62 | const out = new Uint8Array(hex.length / 2) | |
| 63 | for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16) | |
| 64 | return out | |
| 65 | } | |
| 66 | ||
| 67 | // The DID's CURRENT rotationKeys. Offline first — a diagnostic that needs the network to answer is | |
| 68 | // one more thing that can hang — then plc.directory under a HARD deadline. | |
| 69 | // | |
| 70 | // The deadline uses AbortController, NOT a `timeout` option: bare-fetch destructures only | |
| 71 | // { body, method, headers, signal, agent } and silently DROPS `timeout`, so a timeout option here | |
| 72 | // would be a phantom deadline that reads as handled in review and bounds nothing. | |
| 73 | export async function rotationKeysFor (did, { storage = null, timeoutMs = 5000, plcUrl = 'https://plc.directory', fetchImpl = null } = {}) { | |
| 74 | const local = [ | |
| 75 | path.join(HOME, '.config', 'atproto-pds', 'pending-genesis.json'), | |
| 76 | storage ? path.join(storage, 'peer-identity.json') : null | |
| 77 | ].filter(Boolean) | |
| 78 | for (const p of local) { | |
| 79 | const j = readJson(p) | |
| 80 | if (j && j.did === did) { | |
| 81 | const rk = (j.signedOp && j.signedOp.rotationKeys) || j.rotationKeys | |
| 82 | if (Array.isArray(rk) && rk.length) return { keys: rk, source: p.replace(HOME, '~') } | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | // Bare has no global fetch. hpp already depends on bare-fetch for exactly this (lib/notifications.js, | |
| 87 | // bin/hpp-onboard.js), and it is installed LAZILY so the offline path above never pays for it — and so | |
| 88 | // a node with no network still answers from local genesis instead of failing to load a module. | |
| 89 | let f = fetchImpl | |
| 90 | if (!f && typeof globalThis.fetch !== 'function') { | |
| 91 | try { await import('bare-fetch/global') } catch { /* fall through to the honest "no fetch" below */ } | |
| 92 | } | |
| 93 | if (!f) f = typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null | |
| 94 | if (!f) return { keys: null, source: 'no fetch available' } | |
| 95 | ||
| 96 | const ac = new AbortController() | |
| 97 | const timer = setTimeout(() => ac.abort(), timeoutMs) | |
| 98 | try { | |
| 99 | const res = await f(`${plcUrl}/${did}/data`, { signal: ac.signal }) | |
| 100 | if (!res.ok) return { keys: null, source: `plc.directory HTTP ${res.status}` } | |
| 101 | const doc = await res.json() | |
| 102 | return { keys: Array.isArray(doc.rotationKeys) ? doc.rotationKeys : null, source: 'plc.directory' } | |
| 103 | } catch (e) { | |
| 104 | return { keys: null, source: 'plc.directory unreachable: ' + ((e && e.message) || 'aborted') } | |
| 105 | } finally { | |
| 106 | clearTimeout(timer) | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | // Per-DID verdict. `held` is deliberately TRI-STATE: | |
| 111 | // true a local secret derives one of this DID's rotation keys — we can still change it | |
| 112 | // false none does — this DID is frozen forever, and no later action can undo that | |
| 113 | // null we could not determine it (no network, locked vault). NOT the same as false, and | |
| 114 | // reporting it as false would be a fabricated verdict. | |
| 115 | export async function collectRotationHeld ({ cfg = {}, dids = null, storage = null, timeoutMs = 5000, fetchImpl = null } = {}) { | |
| 116 | const { owned, vaultLocked } = localRotationCandidates(cfg) | |
| 117 | const list = dids || (cfg.dids || []).map((d) => d.did) | |
| 118 | const out = new Map() | |
| 119 | for (const did of list) { | |
| 120 | const { keys, source } = await rotationKeysFor(did, { storage, timeoutMs, fetchImpl }) | |
| 121 | if (!keys) { | |
| 122 | out.set(did, { held: null, reason: source, rotationKeyCount: null, singleKeyRisk: null, heldBy: null }) | |
| 123 | continue | |
| 124 | } | |
| 125 | const hit = keys.map((k) => ({ k, by: owned.get(k) })).find((x) => x.by) | |
| 126 | out.set(did, { | |
| 127 | held: hit ? true : (vaultLocked ? null : false), | |
| 128 | reason: hit ? null : (vaultLocked ? 'hyperident vault is passphrase-encrypted — cannot check' : 'no local secret derives any rotation key'), | |
| 129 | rotationKeyCount: keys.length, | |
| 130 | // Holding the ONLY rotation key is still a single point of failure. A DID can be `held: true` | |
| 131 | // and one disk failure from wj62's outcome, so the two facts are reported separately. | |
| 132 | singleKeyRisk: keys.length === 1, | |
| 133 | heldBy: hit ? hit.by : null | |
| 134 | }) | |
| 135 | } | |
| 136 | return out | |
| 137 | } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "hyper-proto-peer", |
| 3 | "version": "0.3.113", | |
| 3 | "version": "0.3.114", | |
| 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", |
| … | … | @@ -2,6 +2,7 @@ |
| 2 | 2 | // Run: bare test/all.js |
| 3 | 3 | import '../lib/bare-globals.js' |
| 4 | 4 | import './diag.test.js' // fast + offline; early so it runs before the slow network suites |
| 5 | import './rotation-guard.test.js' // fast + offline; can we still CHANGE each hosted DID (#47) | |
| 5 | 6 | import './cores.test.js' // fast + offline; discovery-key -> core mapping for conflict triage |
| 6 | 7 | import './set-password.test.js' // fast + offline; --set-password must write the account the daemon READS |
| 7 | 8 | import './conflict-watch.test.js' // fast + offline; drives a REAL conflict — guards an internal hypercore hook |
| … | … | @@ -0,0 +1,75 @@ |
| 1 | // rotation-guard — can we still CHANGE each hosted DID? (hyper-social #47) | |
| 2 | // | |
| 3 | // The failure this guards is invisible by construction: wj62 kept serving perfectly for six weeks with | |
| 4 | // its rotation authority already gone, because SERVING needs only the signing key. So the property under | |
| 5 | // test is not "does it compute a boolean" but "does it distinguish the three answers that matter": | |
| 6 | // | |
| 7 | // true we hold a rotation key — the DID is still fixable | |
| 8 | // false we hold none — frozen FOREVER, and no later action undoes it | |
| 9 | // null we could not tell (no network, encrypted vault) | |
| 10 | // | |
| 11 | // The null case is the one worth a test of its own. Collapsing "could not check" into "false" is exactly | |
| 12 | // the bug the 5A gate shipped (a user told their device was revoked when the check simply could not run), | |
| 13 | // and collapsing it the other way hides a dead identity behind a green tick. | |
| 14 | // | |
| 15 | // fetchImpl is injected, so this is offline and deterministic — it never touches plc.directory. | |
| 16 | // Run: bare test/rotation-guard.test.js | |
| 17 | import '../lib/bare-globals.js' | |
| 18 | import test from 'brittle' | |
| 19 | import { deriveAtprotoKey, ATPROTO_SIGNING_PATH, ATPROTO_RECOVERY_PATH } from 'atproto-identity' | |
| 20 | import { collectRotationHeld, localRotationCandidates } from '../lib/rotation-guard.js' | |
| 21 | ||
| 22 | const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' | |
| 23 | const DID = 'did:plc:rotationguardtest00000' | |
| 24 | ||
| 25 | const signingKey = deriveAtprotoKey(MNEMONIC, ATPROTO_SIGNING_PATH).didKey | |
| 26 | const recoveryKey = deriveAtprotoKey(MNEMONIC, ATPROTO_RECOVERY_PATH).didKey | |
| 27 | const cfg = { dids: [{ did: DID, deviceMnemonic: MNEMONIC }] } | |
| 28 | ||
| 29 | const plc = (rotationKeys) => async () => ({ ok: true, json: async () => ({ rotationKeys }) }) | |
| 30 | ||
| 31 | test('a rotation key we can derive reports held:true and names its source', async (t) => { | |
| 32 | const r = (await collectRotationHeld({ cfg, fetchImpl: plc([recoveryKey]) })).get(DID) | |
| 33 | t.is(r.held, true, 'held') | |
| 34 | t.ok(r.heldBy && r.heldBy.includes('deviceMnemonic'), 'names WHERE the key came from — an operator must be able to find it') | |
| 35 | t.is(r.rotationKeyCount, 1) | |
| 36 | }) | |
| 37 | ||
| 38 | test('it matches on EITHER derivation path, because genesis used the signing path', async (t) => { | |
| 39 | // buildAttestedIdentity called resolveKey(rootMnemonic) with no path argument, so the root fell through | |
| 40 | // to deriveAtprotoKey's default — ATPROTO_SIGNING_PATH. A check that only tried the recovery path would | |
| 41 | // have reported wj62's own root as absent. | |
| 42 | const r = (await collectRotationHeld({ cfg, fetchImpl: plc([signingKey]) })).get(DID) | |
| 43 | t.is(r.held, true, 'signing-path root is found too') | |
| 44 | }) | |
| 45 | ||
| 46 | test('a DID whose rotation key we do NOT hold reports held:false — the alarm', async (t) => { | |
| 47 | const foreign = 'did:key:zQ3sheRHJoLgdiqjFjLyTnVF2sXbP1HNEXnANEwZGjGzCeeGy' // wj62's real one | |
| 48 | const r = (await collectRotationHeld({ cfg, fetchImpl: plc([foreign]) })).get(DID) | |
| 49 | t.is(r.held, false, 'false, not null — this is a determined verdict') | |
| 50 | t.ok(r.reason && r.reason.includes('no local secret'), 'and says why') | |
| 51 | }) | |
| 52 | ||
| 53 | test('an unresolvable DID reports null, NEVER false', async (t) => { | |
| 54 | const dead = async () => { throw new Error('network is down') } | |
| 55 | const r = (await collectRotationHeld({ cfg, fetchImpl: dead })).get(DID) | |
| 56 | t.is(r.held, null, 'a check that could not run is not a NO') | |
| 57 | t.ok(r.reason && r.reason.includes('plc.directory'), 'and reports the cause rather than a bare null') | |
| 58 | t.is(r.rotationKeyCount, null, 'and claims no count it did not observe') | |
| 59 | }) | |
| 60 | ||
| 61 | test('holding the ONLY rotation key is still flagged as a single point of failure', async (t) => { | |
| 62 | const one = (await collectRotationHeld({ cfg, fetchImpl: plc([recoveryKey]) })).get(DID) | |
| 63 | t.is(one.held, true) | |
| 64 | t.is(one.singleKeyRisk, true, 'held AND fragile are different facts, reported separately') | |
| 65 | ||
| 66 | const two = (await collectRotationHeld({ cfg, fetchImpl: plc([recoveryKey, signingKey]) })).get(DID) | |
| 67 | t.is(two.singleKeyRisk, false, 'a second key clears the risk') | |
| 68 | t.is(two.rotationKeyCount, 2) | |
| 69 | }) | |
| 70 | ||
| 71 | test('candidate collection never throws on unusable material', async (t) => { | |
| 72 | const junk = { dids: [{ did: DID, deviceMnemonic: 'not a valid mnemonic at all', signingKey: 'zzzz' }] } | |
| 73 | const { owned } = localRotationCandidates(junk) | |
| 74 | t.ok(owned instanceof Map, 'returns a map rather than throwing — one bad entry must not blind the whole check') | |
| 75 | }) |