OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer commits / 91adb38

0.3.114: diag answers "can we still CHANGE this identity?"
Every field in diag answers "is this node advancing?". None could answer the one question that mattered for wj62 -- and that question had exactly one wrong answer, discovered six weeks late (hyper-social #47). wj62's PLC rotation key was derived once, on 2026-07-02, from the mnemonic in ~/.hyperident/identity. Nothing in the running stack read that file again -- pgh and pear-git-v3 resolve identity from ~/.pgh/linked-device.json -- so when the vault went missing 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 it: no handle change, no PDS move, no key rotation after a leak, no tombstone. Permanently. diag now carries two new summary fields: rotationKeyHeld TRI-STATE. true = a local secret derives one of the DID's rotation keys; false = none does, and the DID is frozen forever; null = could not determine (no network, encrypted vault). null is deliberately NOT false -- a check that could not run is not a NO, which is the exact conflation the 5A gate shipped as a fabricated "revoked". singleRotationKey the DID has exactly ONE rotation key. held:true with this also true is one disk failure from wj62's outcome, so the two facts are reported separately rather than merged. Placed in diag specifically because `diag --remote` is the only verdict-producing surface in the stack that needs no SSH. lib/rotation-guard.js is read-only and never handles a secret it need not: it derives PUBLIC did:keys from local material and compares strings. Nothing is printed, logged, written or transmitted. Offline first (local genesis), then plc.directory under a HARD deadline via AbortController -- NOT a `timeout` option, because bare-fetch destructures only {body,method,headers,signal,agent} and silently drops it, which would be a phantom deadline that bounds nothing. bare-fetch/global is imported lazily so the offline path never pays for it. Matches on BOTH derivation paths: genesis called resolveKey(rootMnemonic) with no path argument, so the root fell through to deriveAtprotoKey's default (ATPROTO_SIGNING_PATH). Checking only the recovery path would have reported wj62's own root as absent. Verified live under Bare on tabertron: wj62 rotationKeyHeld=false singleRotationKey=true <- the alarm loser-club rotationKeyHeld=true singleRotationKey=true Matching the standalone scripts/rotation-check.mjs exactly -- negative and positive both correct in one run. Tests: 6/6, 14/14 asserts, offline with an injected fetchImpl. diag.test.js still 3/3, 32/32 -- no regression. Refs: hyper-social #47 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
91adb3802dfdc48fd132aaa5378f67018c00da3c
by plankatron <fleeky@gmail.com> · 2026-08-17T21:26:21.000Z

Changed files (5) +234 -1

lib/diag.js +20 -0
……@@ -25,6 +25,7 @@ import { Client } from 'hyper-svc'
2525import { loadConfig } from './config.js'
2626import { sockFor } from './control-socket.js'
2727import { loadOrCreateSwarmKeyPair } from './swarm-keypair.js'
28import { collectRotationHeld } from './rotation-guard.js'
2829import b4a from 'b4a'
2930
3031// 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) {
109110 stale: p.stale !== undefined ? p.stale : null,
110111 missing: p.missing !== undefined ? p.missing : null,
111112 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,
112123 reachable: !r.unreachable,
113124 failed: r.errors ? Object.keys(r.errors) : []
114125 }
……@@ -137,6 +148,15 @@ export async function collectLocalDiag ({ cfg, storage, dids, limit = 50 } = {})
137148 } finally { try { if (client) await client.close() } catch {} }
138149 out.push(row)
139150 }
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 }
140160 return out
141161}
142162
lib/rotation-guard.js +137 -0
……@@ -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.
16import fs from 'bare-fs'
17import os from 'bare-os'
18import path from 'bare-path'
19import { deriveAtprotoKey, keyFromPrivate, ATPROTO_SIGNING_PATH, ATPROTO_RECOVERY_PATH } from 'atproto-identity'
20
21const HOME = os.homedir()
22const PATHS = [['signing-path', ATPROTO_SIGNING_PATH], ['recovery-path', ATPROTO_RECOVERY_PATH]]
23
24function 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.
33export 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
61function 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.
73export 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.
115export 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}
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-proto-peer",
3 "version": "0.3.113",
3 "version": "0.3.114",
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",
test/all.js +1 -0
……@@ -2,6 +2,7 @@
22// Run: bare test/all.js
33import '../lib/bare-globals.js'
44import './diag.test.js' // fast + offline; early so it runs before the slow network suites
5import './rotation-guard.test.js' // fast + offline; can we still CHANGE each hosted DID (#47)
56import './cores.test.js' // fast + offline; discovery-key -> core mapping for conflict triage
67import './set-password.test.js' // fast + offline; --set-password must write the account the daemon READS
78import './conflict-watch.test.js' // fast + offline; drives a REAL conflict — guards an internal hypercore hook
test/rotation-guard.test.js +75 -0
……@@ -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
17import '../lib/bare-globals.js'
18import test from 'brittle'
19import { deriveAtprotoKey, ATPROTO_SIGNING_PATH, ATPROTO_RECOVERY_PATH } from 'atproto-identity'
20import { collectRotationHeld, localRotationCandidates } from '../lib/rotation-guard.js'
21
22const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
23const DID = 'did:plc:rotationguardtest00000'
24
25const signingKey = deriveAtprotoKey(MNEMONIC, ATPROTO_SIGNING_PATH).didKey
26const recoveryKey = deriveAtprotoKey(MNEMONIC, ATPROTO_RECOVERY_PATH).didKey
27const cfg = { dids: [{ did: DID, deviceMnemonic: MNEMONIC }] }
28
29const plc = (rotationKeys) => async () => ({ ok: true, json: async () => ({ rotationKeys }) })
30
31test('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
38test('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
46test('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
53test('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
61test('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
71test('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})