| … | … | @@ -158,6 +158,73 @@ async function cmdAdd (argv) { |
| 158 | 158 | return 0 |
| 159 | 159 | } |
| 160 | 160 | |
| 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). |
| 174 | function 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 | |
| 190 | const normalizePhrase = (s) => String(s).replace(/\s+/g, ' ').trim().toLowerCase() |
| 191 | |
| 192 | async 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 | |
| 161 | 228 | // Find the private key whose public did:key is `want`, from the same local material the check uses. |
| 162 | 229 | function privateFor (cfg, want) { |
| 163 | 230 | const tryKey = (k) => (k && k.didKey === want ? k.privateKey : null) |
| … | … | @@ -191,10 +258,11 @@ function hexBytes (hex) { |
| 191 | 258 | export async function rotationCommand (argv = []) { |
| 192 | 259 | const mode = argv[0] && !argv[0].startsWith('-') ? argv[0] : null |
| 193 | 260 | if (mode === 'gen') return cmdGen() |
| 261 | if (mode === 'verify') return cmdVerify(argv.slice(1)) |
| 194 | 262 | if (mode === 'add') return cmdAdd(argv.slice(1)) |
| 195 | 263 | if (mode && mode !== 'status') { |
| 196 | 264 | 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]') |
| 198 | 266 | return 2 |
| 199 | 267 | } |
| 200 | 268 | return cmdStatus(argv) |