OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer commits / 18994ac

0.3.116: rotation verify — prove a written phrase reproduces a key
The highest-stakes step had no verb. `rotation gen` prints a phrase and a did:key and tells the operator to confirm the phrase reproduces it -- with no tool to do so. The interface was a hand-written node one-liner, and it broke on pasted input. hyper-proto-peer rotation verify --key <did:key:...> Reads a LINE, not raw characters. A 24-word phrase is PASTED, so it arrives as a chunk; the char-by-char raw-mode pattern used elsewhere in this stack tests `char === '\r'` and never matches a chunk that merely ENDS with one. That is the broken-line-endings bug, and it is why this reads to a line terminator instead. Normalised the way keet normalises (lowercase + whitespace-collapse), so a paste that wrapped, used CRLF, or picked up double spaces still verifies. Tries BOTH derivation paths. `gen` mints at the recovery path, but a root minted by the older ceremony lives at the signing path -- that is how wj62's key was found. Reporting WHICH path matched is the difference between "this is your key" and a confusing false negative. The phrase is never echoed, logged, written, or passed as argv (which would land in shell history). Word count is checked so a truncated paste is caught before the verdict, rather than being reported as a mismatch. exit 0 = reproduces the key, safe to register exit 1 = does NOT, so `rotation add --publish` must not run Verified: correct phrase, mixed case + double spaces + CRLF MATCH via recovery-path, exit 0 wrong phrase NO MATCH, exit 1 3-word truncated paste word-count warning + NO MATCH Refs: hyper-social #47 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
18994acd05299fb3cf03817cb343770ea0616721
by plankatron <fleeky@gmail.com> · 2026-08-17T23:34:35.000Z

Changed files (2) +70 -2

lib/rotation-cli.js +69 -1
……@@ -158,6 +158,73 @@ async function cmdAdd (argv) {
158158 return 0
159159}
160160
161// ── verify a written-down phrase reproduces a key ────────────────────────────────────────────────
162//
163// The step that matters most had no verb: `gen` prints a phrase and a did:key, and the operator is told
164// to confirm the phrase reproduces it — with no tool to do so. A one-liner was the interface, and it
165// broke on pasted input.
166//
167// Reads a LINE, not raw characters. A 24-word phrase is pasted, so it arrives as a chunk; the
168// char-by-char raw-mode pattern used elsewhere in this stack tests `char === '\r'` and never matches a
169// chunk that merely ENDS with one. That is the broken-line-endings bug.
170//
171// Normalised the way keet normalises (lowercase + whitespace-collapse), so a paste that wrapped, used
172// CRLF, or picked up double spaces still verifies. The phrase is never echoed back, never logged, never
173// written, and never passed as an argv (which would land in shell history).
174function readPhrase (prompt) {
175 process.stdout.write(prompt)
176 return new Promise((resolve) => {
177 let buf = ''
178 const onData = (d) => {
179 buf += d.toString()
180 if (!/[\r\n]/.test(buf)) return // keep reading until the line actually terminates
181 try { process.stdin.removeListener('data', onData) } catch {}
182 try { process.stdin.pause() } catch {}
183 resolve(buf)
184 }
185 process.stdin.on('data', onData)
186 try { process.stdin.resume() } catch {}
187 })
188}
189
190const normalizePhrase = (s) => String(s).replace(/\s+/g, ' ').trim().toLowerCase()
191
192async function cmdVerify (argv) {
193 const want = flag(argv, 'key')
194 if (!want || !/^did:key:z/.test(want)) {
195 console.error('usage: hyper-proto-peer rotation verify --key <did:key:...>')
196 console.error(' then paste the recovery phrase when prompted.')
197 return 2
198 }
199 const phrase = normalizePhrase(await readPhrase('paste the recovery phrase (not echoed, not saved): '))
200 const words = phrase.split(' ').filter(Boolean)
201 console.log('')
202 if (words.length !== 12 && words.length !== 24) {
203 console.log(` ⚠ read ${words.length} words — a BIP39 phrase is 12 or 24. Check the paste.`)
204 } else {
205 console.log(` read ${words.length} words`)
206 }
207
208 // Try BOTH derivation paths: `gen` mints at the recovery path, but a root minted by the older
209 // ceremony lives at the signing path (that is how wj62's key was found). Reporting WHICH path
210 // matched is the difference between "this is your key" and a confusing false negative.
211 let matched = null
212 for (const [name, path] of [['recovery-path', ATPROTO_RECOVERY_PATH], ['signing-path', ATPROTO_SIGNING_PATH]]) {
213 try { if (deriveAtprotoKey(phrase, path).didKey === want) matched = name } catch { /* unusable phrase */ }
214 }
215
216 if (matched) {
217 console.log(` ✅ MATCH — this phrase reproduces ${want}`)
218 console.log(` via ${matched}`)
219 console.log(' Safe to register. Store the paper somewhere the disk is not.')
220 return 0
221 }
222 console.log(` 🚨 NO MATCH — this phrase does NOT reproduce ${want}`)
223 console.log(' Do NOT register that key. A key you cannot reproduce is not a backup — registering')
224 console.log(' it only moves the single point of failure. Re-run `rotation gen` and start over.')
225 return 1
226}
227
161228// Find the private key whose public did:key is `want`, from the same local material the check uses.
162229function privateFor (cfg, want) {
163230 const tryKey = (k) => (k && k.didKey === want ? k.privateKey : null)
……@@ -191,10 +258,11 @@ function hexBytes (hex) {
191258export async function rotationCommand (argv = []) {
192259 const mode = argv[0] && !argv[0].startsWith('-') ? argv[0] : null
193260 if (mode === 'gen') return cmdGen()
261 if (mode === 'verify') return cmdVerify(argv.slice(1))
194262 if (mode === 'add') return cmdAdd(argv.slice(1))
195263 if (mode && mode !== 'status') {
196264 console.error(`unknown: rotation ${mode}`)
197 console.error('usage: hyper-proto-peer rotation [--json] | rotation gen | rotation add --did <did> --key <did:key> [--publish]')
265 console.error('usage: hyper-proto-peer rotation [--json] | rotation gen | rotation verify --key <did:key> | rotation add --did <did> --key <did:key> [--publish]')
198266 return 2
199267 }
200268 return cmdStatus(argv)
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-proto-peer",
3 "version": "0.3.115",
3 "version": "0.3.116",
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",