OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer commits / 8910597

0.3.117: read rotationKeys LIVE, and add `rotation add --replace`
Two defects found by using the tool for real (hyper-social #47). 1. THE CHECK REPORTED STALE STATE AFTER A SUCCESSFUL ROTATION. rotationKeysFor preferred the persisted genesis op so the check would work at boot with no network. But a genesis op is a snapshot of the DID at MINT time, and rotationKeys is exactly the field that changes afterwards. So immediately after a successful `rotation add --publish`, `rotation` kept reporting the ORIGINAL single key and warned the operator their DID was still one secret from frozen -- while plc.directory showed two. A diagnostic that contradicts the live document right after you fix something is worse than no diagnostic: it teaches you to distrust the tool. Live now wins; the cached genesis remains the offline fallback and is LABELLED as a possibly-stale snapshot, with `stale: true` on the verdict, so a fallback answer can never be mistaken for an authoritative one. 2. NO WAY TO SUPERSEDE AN EXPOSED KEY. A PLC append cannot be undone, so a rotation key that turns out to be compromised can only be superseded -- publish a new key set without it. There was no verb for that, which meant the only remediation for a bad key was to leave it registered. rotation add --did D --key <new> --replace <exposed> [--publish] This is possible ONLY because a different current key can sign the op -- which is precisely what appending rather than prepending preserved (0.3.115). Had the new key been ranked at [0], an exposed key would have outranked every remaining one. Two guards, both tested: - refuses to replace the key it would SIGN with. Valid per the protocol, but it signs away the authority being used in the same op; require a machine holding a different key, so a mistake leaves something to recover with. - refuses to replace a key that is not currently in the set (nothing to remove), and refuses any op that would leave zero rotation keys. Verified against the live DID: dry run shows the swap with local validation PASS, both guards refuse correctly, nothing submitted. rotation-guard tests 6/6, 14/14. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
891059738633736a76ab32247a6c010d45eebee3
by plankatron <fleeky@gmail.com> · 2026-08-18T00:32:35.000Z

Changed files (3) +67 -35

lib/rotation-cli.js +30 -4
……@@ -93,9 +93,14 @@ function cmdGen () {
9393async function cmdAdd (argv) {
9494 const did = flag(argv, 'did')
9595 const addKey = flag(argv, 'key')
96 const dropKey = flag(argv, 'replace')
9697 const publish = has(argv, 'publish')
9798 if (!did || !addKey) {
98 console.error('usage: hyper-proto-peer rotation add --did <did:plc:…> --key <did:key:…> [--publish]')
99 console.error('usage: hyper-proto-peer rotation add --did <did:plc:…> --key <did:key:…> [--replace <did:key:…>] [--publish]')
100 return 2
101 }
102 if (dropKey && !/^did:key:z/.test(dropKey)) {
103 console.error('refusing: --replace must be a PUBLIC did:key')
99104 return 2
100105 }
101106 if (!/^did:key:z/.test(addKey)) {
……@@ -130,7 +135,27 @@ async function cmdAdd (argv) {
130135 // supreme — nothing could fork against it, so a photograph of that paper would end the identity with no
131136 // recourse. Keeping the incumbent at [0] preserves its power to fork back if the backup is stolen,
132137 // while the backup at [1] still fixes single-key LOSS. Both failure modes covered, neither traded.
133 const rotationDidKeys = [...current, addKey]
138 let rotationDidKeys = [...current, addKey]
139 if (dropKey) {
140 // REPLACE. A PLC append cannot be undone, so a key that turns out to be exposed can only be
141 // superseded — publish a new key set without it. That is possible only while a DIFFERENT current
142 // key can sign the op, which is exactly what appending rather than prepending preserved.
143 if (!current.includes(dropKey)) {
144 console.error(`\nrefusing: ${dropKey}\nis not currently a rotation key on this DID — nothing to replace.`)
145 return 2
146 }
147 if (dropKey === signer.k) {
148 // Valid per the protocol, but it signs away the authority being used in the same breath. Require
149 // signing with a key that survives the op, so a mistake leaves something to recover with.
150 console.error(`\nrefusing: that is the key this node would SIGN with ([${signer.i}]).`)
151 console.error(' Removing it in the same op signs away the authority you are using. Run this from')
152 console.error(' a machine holding a DIFFERENT rotation key.')
153 return 2
154 }
155 rotationDidKeys = [...current.filter((k) => k !== dropKey), addKey]
156 if (!rotationDidKeys.length) { console.error('\nrefusing: that would leave the DID with NO rotation keys — permanently frozen.'); return 2 }
157 console.log(`removing: ${dropKey}`)
158 }
134159 const unsigned = buildUpdateOp({
135160 prevSignedOp: head,
136161 rotationDidKeys,
……@@ -142,7 +167,8 @@ async function cmdAdd (argv) {
142167 const chk = checkUpdateOp(signedOp, head)
143168 console.log(`\nlocal validation: ${chk.ok ? 'PASS' : 'FAIL — ' + chk.error}`)
144169 console.log('\nresulting rotationKeys:')
145 rotationDidKeys.forEach((k, i) => console.log(` [${i}] ${k}${k === addKey ? ' ← NEW backup (lower authority than [0], by design)' : ''}`))
170 rotationDidKeys.forEach((k, i) => console.log(` [${i}] ${k}${k === addKey ? ' ← NEW' : ''}`))
171 if (dropKey) console.log(` (removed: ${dropKey})`)
146172 if (!chk.ok) return 1
147173
148174 if (!publish) {
……@@ -262,7 +288,7 @@ export async function rotationCommand (argv = []) {
262288 if (mode === 'add') return cmdAdd(argv.slice(1))
263289 if (mode && mode !== 'status') {
264290 console.error(`unknown: rotation ${mode}`)
265 console.error('usage: hyper-proto-peer rotation [--json] | rotation gen | rotation verify --key <did:key> | rotation add --did <did> --key <did:key> [--publish]')
291 console.error('usage: hyper-proto-peer rotation [--json] | rotation gen | rotation verify --key <did:key> | rotation add --did <did> --key <did:key> [--replace <did:key>] [--publish]')
266292 return 2
267293 }
268294 return cmdStatus(argv)
lib/rotation-guard.js +36 -30
……@@ -64,47 +64,52 @@ function b (hex) {
6464 return out
6565}
6666
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.
67// The DID's CURRENT rotationKeys, from plc.directory under a HARD deadline, with the persisted
68// genesis as an offline fallback.
6969//
7070// The deadline uses AbortController, NOT a `timeout` option: bare-fetch destructures only
7171// { body, method, headers, signal, agent } and silently DROPS `timeout`, so a timeout option here
7272// would be a phantom deadline that reads as handled in review and bounds nothing.
7373export async function rotationKeysFor (did, { storage = null, timeoutMs = 5000, plcUrl = 'https://plc.directory', fetchImpl = null } = {}) {
74 const local = [
74 // LIVE FIRST. This used to prefer the persisted genesis so the check worked at boot with no network —
75 // but a genesis op is a snapshot of the DID at MINT time, and rotationKeys are exactly the field that
76 // changes afterwards. So after a successful `rotation add` the check kept reporting the original single
77 // key and told the operator their DID was still one secret from frozen. A diagnostic that contradicts
78 // the live document right after you fix something is worse than no diagnostic (hyper-social #47).
79 //
80 // The cached genesis remains the OFFLINE fallback, but it is labelled so a stale answer can never be
81 // mistaken for an authoritative one.
82 let f = fetchImpl
83 if (!f && typeof globalThis.fetch !== 'function') {
84 try { await import('bare-fetch/global') } catch { /* fall through to the local fallback */ }
85 }
86 if (!f) f = typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null
87
88 if (f) {
89 const ac = new AbortController()
90 const timer = setTimeout(() => ac.abort(), timeoutMs)
91 try {
92 const res = await f(`${plcUrl}/${did}/data`, { signal: ac.signal })
93 if (res.ok) {
94 const doc = await res.json()
95 if (Array.isArray(doc.rotationKeys)) return { keys: doc.rotationKeys, source: 'plc.directory (live)' }
96 }
97 } catch { /* offline — fall through */ } finally { clearTimeout(timer) }
98 }
99
100 for (const p of [
75101 path.join(HOME, '.config', 'atproto-pds', 'pending-genesis.json'),
76102 storage ? path.join(storage, 'peer-identity.json') : null
77 ].filter(Boolean)
78 for (const p of local) {
103 ].filter(Boolean)) {
79104 const j = readJson(p)
80105 if (j && j.did === did) {
81106 const rk = (j.signedOp && j.signedOp.rotationKeys) || j.rotationKeys
82 if (Array.isArray(rk) && rk.length) return { keys: rk, source: p.replace(HOME, '~') }
107 if (Array.isArray(rk) && rk.length) {
108 return { keys: rk, source: p.replace(HOME, '~') + ' ⚠ GENESIS SNAPSHOT — may be stale, plc.directory was unreachable', stale: true }
109 }
83110 }
84111 }
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 }
112 return { keys: null, source: 'plc.directory unreachable and no local genesis found' }
108113}
109114
110115// Per-DID verdict. `held` is deliberately TRI-STATE:
……@@ -117,7 +122,7 @@ export async function collectRotationHeld ({ cfg = {}, dids = null, storage = nu
117122 const list = dids || (cfg.dids || []).map((d) => d.did)
118123 const out = new Map()
119124 for (const did of list) {
120 const { keys, source } = await rotationKeysFor(did, { storage, timeoutMs, fetchImpl })
125 const { keys, source, stale } = await rotationKeysFor(did, { storage, timeoutMs, fetchImpl })
121126 if (!keys) {
122127 out.set(did, { held: null, reason: source, rotationKeyCount: null, singleKeyRisk: null, heldBy: null })
123128 continue
……@@ -130,6 +135,7 @@ export async function collectRotationHeld ({ cfg = {}, dids = null, storage = nu
130135 // Holding the ONLY rotation key is still a single point of failure. A DID can be `held: true`
131136 // and one disk failure from wj62's outcome, so the two facts are reported separately.
132137 singleKeyRisk: keys.length === 1,
138 stale: !!stale,
133139 heldBy: hit ? hit.by : null
134140 })
135141 }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-proto-peer",
3 "version": "0.3.116",
3 "version": "0.3.117",
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",