oxedyne/daimond/dev/verify_voice.mjs
26.0 KiB, 1 run
created by r2519314175:795, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | // verify_voice.mjs — a voice is held encrypted, and leaves only as a header. |
| 2 | // |
| 3 | // WHAT THIS IS FOR. To write a proposal on the Oregami forge a tester presents a VOICE: a |
| 4 | // per-person secret the forge looks the writer up BY. No name travels with it — the forge |
| 5 | // identifies the voice from the secret alone — so the secret IS the identity, and losing it to a |
| 6 | // log line or an access log is losing the tester's ability to write as themselves and nobody |
| 7 | // else's. `www/js/voice.js` is the only place in the browser that holds one, and this file is the |
| 8 | // proof that it holds it the way `improve_panel_contract.md` §4 says it must: |
| 9 | // |
| 10 | // the browser holds it, encrypted under the user's passphrase, and sends it with each request; |
| 11 | // the gateway forwards and stores nothing. |
| 12 | // |
| 13 | // The properties, and the last three carry the weight: |
| 14 | // |
| 15 | // 1. A voice can be SET, and `header()` then carries EXACTLY it, on `x-daimond-voice` — the |
| 16 | // name `improve.rs`'s HDR_VOICE reads. Asserted against a real request as well as against |
| 17 | // the returned map, because a map nothing sends proves nothing. |
| 18 | // 1b. AND IT SURVIVES A RELOAD AND AN UNLOCK. This is what says the secret is at REST rather |
| 19 | // than in a module variable, and it is the necessary companion to check 3: 3 alone would |
| 20 | // pass over an empty store, and 1b alone would pass over a store holding the plaintext. |
| 21 | // Neither is sufficient; together they are the claim. |
| 22 | // 2. `clear()` REALLY REMOVES IT — asserted on what is IN STORAGE, not on `has()`. A `has()` |
| 23 | // that answers false over a secret still on disk is the exact failure worth catching: |
| 24 | // nothing in the interface would ever offer to remove it again. |
| 25 | // 3. THE SECRET IS NOT IN STORAGE IN PLAINTEXT. Asserted by reading every byte the origin has |
| 26 | // in localStorage, raw, and searching it for the secret. |
| 27 | // 4. THE SECRET IS NEVER IN A URL, A QUERY STRING OR A LOG LINE — and a URL that arrives |
| 28 | // already carrying it is REFUSED rather than quietly cleaned, because code that built one |
| 29 | // will build another. |
| 30 | // 5. A PUBLIC READ CARRIES NO VOICE HEADER AT ALL when none is held, and does not fail. |
| 31 | // Reading a public repository needs no voice; a client that demanded one would put a fence |
| 32 | // around a public page. |
| 33 | // 6. VALIDATION IS NOT LOOSER THAN THE GATEWAY'S `check_secret` — non-empty, ASCII graphic, |
| 34 | // at most 256 bytes. It is STRICTER in one place, a 16-character floor, which the forge's |
| 35 | // 45-character minted secret clears by a mile and a truncated paste does not. |
| 36 | // |
| 37 | // EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST, and each break is chosen so it survives every |
| 38 | // check except the one it is meant to prove. A break caught by an earlier, cheaper check leaves |
| 39 | // the later check untested — the run goes red for the wrong reason and reads like proof. |
| 40 | // |
| 41 | // node dev/verify_voice.mjs --break wrongheader # 1 — the header is spelled differently |
| 42 | // node dev/verify_voice.mjs --break sessiononly # 1b — stored in memory, not at rest |
| 43 | // node dev/verify_voice.mjs --break sticky # 2 — clear() keeps the ciphertext beside a flag |
| 44 | // node dev/verify_voice.mjs --break plain # 3 — stored unwrapped |
| 45 | // node dev/verify_voice.mjs --break inurl # 4a — put in the query string as well |
| 46 | // node dev/verify_voice.mjs --break chatty # 4b — logged to the console |
| 47 | // node dev/verify_voice.mjs --break unguarded # 4c — the URL guard comes off |
| 48 | // node dev/verify_voice.mjs --break rawonly # 4d — the guard reads the raw URL only |
| 49 | // node dev/verify_voice.mjs --break alwayssend # 5 — an empty header sent when none is held |
| 50 | // node dev/verify_voice.mjs --break loose # 6 — validation accepts anything |
| 51 | // node dev/verify_voice.mjs # and then, clean |
| 52 | // |
| 53 | // Every one of those was run, and each reddens ONLY the property it is for — several assertions |
| 54 | // of that one property in three cases, and no assertion of any other. Written down because it is |
| 55 | // the thing that goes wrong: `sessiononly` first turned the URL-guard check red as well, since a |
| 56 | // voice that did not survive the reload is no voice at all by the time the guard is reached, and |
| 57 | // the guard was therefore untested by every run of that break. The fixture is restored after the |
| 58 | // reload for exactly that reason. |
| 59 | // |
| 60 | // Two of those pairs are worth explaining, because each was nearly ONE break covering two checks: |
| 61 | // |
| 62 | // - `sticky` and `sessiononly` both concern storage and are opposite mistakes. `sticky` keeps the |
| 63 | // ciphertext after a clear; `sessiononly` never writes it at all. A single "storage is wrong" |
| 64 | // break would turn both checks red at once and neither would have been tested by it. |
| 65 | // - `inurl`, `chatty` and `unguarded` are three breaks for one sentence of the rule, because the |
| 66 | // sentence is held by three different lines: where the URL is built, whether anything is |
| 67 | // printed, and the guard that refuses a caller's own bad URL. `unguarded` deliberately does NOT |
| 68 | // put the secret in a URL — it only removes the refusal — so check 4a stays green under it and |
| 69 | // is not credited with catching something it never saw. |
| 70 | // |
| 71 | // RUN IT IN A WORLD OF ITS OWN. Without the world env this drives world 0 on :8777 and says |
| 72 | // nothing about it: |
| 73 | // |
| 74 | // eval "$(bash dev/world.sh 13 --env)"; bash dev/world.sh 13 --up >/dev/null |
| 75 | // node dev/verify_voice.mjs |
| 76 | // bash dev/world.sh 13 --down |
| 77 | import fs from 'node:fs'; |
| 78 | import path from 'node:path'; |
| 79 | import { fileURLToPath } from 'node:url'; |
| 80 | import { open, signInAs } from './harness.mjs'; |
| 81 | |
| 82 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 83 | const WWW = path.join(HERE, '..', 'www'); |
| 84 | |
| 85 | // The header on Daimond's own leg. WRITTEN OUT rather than read from |
| 86 | // `DaimondVoice.HEADER`: a test that asks the code under test what it is called agrees with it by |
| 87 | // construction, and the `wrongheader` break would then pass. This string is `HDR_VOICE` in |
| 88 | // gateway/src/handlers/improve.rs and it is the contract. |
| 89 | const HDR = 'x-daimond-voice'; |
| 90 | |
| 91 | // A fixture shaped like a real minted voice: the forge mints 32 bytes and prints them in the |
| 92 | // Hematite64 alphabet WITH ITS PADDING, so 43 symbols then `=` and a marker digit: 45 ASCII-graphic |
| 93 | // characters ending `=2`. It was 41 until 2026-08-29 -- shaped like a voice but no length the forge |
| 94 | // has ever issued -- which is how a fixture stops standing in for the thing it names. Distinctive, |
| 95 | // so that finding it in a dump of storage or a console line is finding THIS and not a coincidence. |
| 96 | // Invented here, never minted, valid nowhere. It exists so the checks below can search storage, |
| 97 | // console lines and request URLs FOR it — a fixture read from the environment could not be |
| 98 | // searched for, which is the one thing this file has to do. |
| 99 | // allowlist secret |
| 100 | const SECRET = 'Vz7Kq3Np9Rw2Ty5Uv8Bd4Fg6Hj1Lm0Qs3Xc5Vb7Nm2P=2'; |
| 101 | |
| 102 | const BREAK = (() => { |
| 103 | const i = process.argv.indexOf('--break'); |
| 104 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 105 | })(); |
| 106 | |
| 107 | const BREAKS = { |
| 108 | // The header the browser sends on. One character out is a 401 nobody can explain, and it is |
| 109 | // exactly what a rename in one tree and not the other produces. |
| 110 | wrongheader: { |
| 111 | file: 'js/voice.js', |
| 112 | find: "\tvar HDR = 'x-daimond-voice';", |
| 113 | with: "\tvar HDR = 'x-daimond-voice-1';", |
| 114 | }, |
| 115 | // Held in memory for the life of the tab and never written down. Everything works until the |
| 116 | // page reloads, which is the shape of a bug that ships: the developer never closes the tab. |
| 117 | // Three sites, because `clear()` has to forget the memory too — a `clear()` left broken here |
| 118 | // would turn check 2 red as well and this break would be credited with catching something it |
| 119 | // is not about. |
| 120 | sessiononly: [ |
| 121 | { |
| 122 | file: 'js/voice.js', |
| 123 | find: "\t\tlocalStorage.setItem(LS, JSON.stringify({ v: REC_V, s: wrapped, at: Date.now() }));", |
| 124 | with: "\t\twindow.__voiceMem = { v: REC_V, s: wrapped, at: Date.now() };", |
| 125 | }, |
| 126 | { |
| 127 | file: 'js/voice.js', |
| 128 | find: "\t\tvar raw = null;\n\t\ttry { raw = localStorage.getItem(LS); } catch (e) { return null; }", |
| 129 | with: "\t\tvar raw = window.__voiceMem ? JSON.stringify(window.__voiceMem) : null;", |
| 130 | }, |
| 131 | { |
| 132 | file: 'js/voice.js', |
| 133 | find: "\t\ttry { localStorage.removeItem(LS); } catch (e) { /* private mode: nothing was stored */ }", |
| 134 | with: "\t\ttry { delete window.__voiceMem; localStorage.removeItem(LS); } catch (e) {}", |
| 135 | }, |
| 136 | ], |
| 137 | // `clear()` keeps the ciphertext beside a flag that makes `has()` answer false. The kindest |
| 138 | // version of this mistake — "in case they come back" — and the one that leaves a secret on a |
| 139 | // device that nothing will ever offer to remove again. |
| 140 | sticky: { |
| 141 | file: 'js/voice.js', |
| 142 | find: "\t\ttry { localStorage.removeItem(LS); } catch (e) { /* private mode: nothing was stored */ }", |
| 143 | with: "\t\ttry { var k = rec(); localStorage.setItem(LS, JSON.stringify({ v: 0, keep: k ? k.s : '' })); } catch (e) {}", |
| 144 | }, |
| 145 | // Stored unwrapped, and read back unwrapped, so that everything else still works. BOTH sites, |
| 146 | // because a break that only stopped wrapping would break `header()` too and check 1 would go |
| 147 | // red first — which would leave check 3 exactly as untested as it was before. |
| 148 | plain: [ |
| 149 | { |
| 150 | file: 'js/voice.js', |
| 151 | find: "\t\tvar wrapped = await DaimondIdentity.wrap(tidy(secret));", |
| 152 | with: "\t\tvar wrapped = tidy(secret);", |
| 153 | }, |
| 154 | { |
| 155 | file: 'js/voice.js', |
| 156 | find: "\t\t\tsecret = await DaimondIdentity.unwrap(rec().s);", |
| 157 | with: "\t\t\tsecret = rec().s;", |
| 158 | }, |
| 159 | ], |
| 160 | // The secret goes in the query string AS WELL as the header, so every check that reads the |
| 161 | // header still passes and only the URL moves. Placed after the guard, which is what a caller |
| 162 | // appending a parameter downstream would do. |
| 163 | inurl: { |
| 164 | file: 'js/voice.js', |
| 165 | find: "\t\tvar o = Object.assign({}, opts || {});", |
| 166 | with: "\t\tif (h[HDR]) url += (url.indexOf('?') < 0 ? '?' : '&') + 'voice=' + encodeURIComponent(h[HDR]);\n" |
| 167 | + "\t\tvar o = Object.assign({}, opts || {});", |
| 168 | }, |
| 169 | // One console line, of the kind added while debugging and left in. Nothing else changes. |
| 170 | chatty: { |
| 171 | file: 'js/voice.js', |
| 172 | find: "\t\tvar h = await header();", |
| 173 | with: "\t\tvar h = await header();\n\t\tconsole.log('voice send ' + url + ' ' + (h[HDR] || ''));", |
| 174 | }, |
| 175 | // The refusal of a URL that already carries the secret. NOT the same as `inurl`: this file |
| 176 | // goes on building clean URLs, so check 4a stays green and only the guard against a CALLER's |
| 177 | // bad URL is gone. |
| 178 | unguarded: { |
| 179 | file: 'js/voice.js', |
| 180 | find: "\t\tif (h[HDR] && seen.indexOf(h[HDR]) >= 0) {", |
| 181 | with: "\t\tif (false) {", |
| 182 | }, |
| 183 | // The guard reading the RAW url only, which is what it did until 2026-08-29. Kept apart from |
| 184 | // `unguarded` because it is a different mistake with the same consequence and it is the one |
| 185 | // that actually shipped: the guard is present, reads correctly, and matches nothing, because a |
| 186 | // minted voice ends `=2` and a query carries that as `%3D`. It reddens the URL check alone. |
| 187 | rawonly: { |
| 188 | file: 'js/voice.js', |
| 189 | find: "\t\ttry { seen = url + '\\n' + decodeURIComponent(url); } catch (e) { /* raw only */ }", |
| 190 | with: "\t\tseen = url;", |
| 191 | }, |
| 192 | // An empty header sent when no voice is held. Looks harmless and is not: the gateway's |
| 193 | // `check_secret` refuses an empty value, so every read of a PUBLIC repository would be turned |
| 194 | // into a 400 by a client insisting on saying nothing. |
| 195 | alwayssend: { |
| 196 | file: 'js/voice.js', |
| 197 | find: "\t\tif (!has()) return {};", |
| 198 | with: "\t\tif (!has()) { var e = {}; e[HDR] = ''; return e; }", |
| 199 | }, |
| 200 | // Validation accepts whatever it is given, so a newline in a secret reaches the gateway — and |
| 201 | // a header value with a newline in it is how a second header gets written. |
| 202 | loose: { |
| 203 | file: 'js/voice.js', |
| 204 | find: "\tfunction check(secret) {\n\t\tvar s = String(secret == null ? '' : secret).trim();", |
| 205 | with: "\tfunction check(secret) {\n\t\treturn '';\n\t\tvar s = String(secret == null ? '' : secret).trim();", |
| 206 | }, |
| 207 | }; |
| 208 | |
| 209 | const ok = [], bad = []; |
| 210 | const check = (name, pass, detail) => { |
| 211 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 212 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 213 | }; |
| 214 | |
| 215 | const s = await open({ name: 'voice', signIn: false, connect: false }); |
| 216 | const { page } = s; |
| 217 | |
| 218 | if (BREAK) { |
| 219 | const spec = BREAKS[BREAK]; |
| 220 | if (!spec) { console.error(`no such break: ${BREAK}`); process.exit(2); } |
| 221 | const sites = Array.isArray(spec) ? spec : [spec]; |
| 222 | const edited = new Map(); |
| 223 | for (const site of sites) { |
| 224 | const src = edited.get(site.file) || fs.readFileSync(path.join(WWW, site.file), 'utf8'); |
| 225 | const n = src.split(site.find).length - 1; |
| 226 | // The anchor guard. A break whose anchor drifted lands nowhere, the run goes green, and |
| 227 | // the green reads as proof — which is worse than a red for a bad reason. |
| 228 | if (n !== 1) { |
| 229 | console.error(`break '${BREAK}': the anchor appears ${n} times in ${site.file}, ` |
| 230 | + 'so nothing was broken and the run below would prove nothing.'); |
| 231 | process.exit(2); |
| 232 | } |
| 233 | edited.set(site.file, src.replace(site.find, site.with)); |
| 234 | } |
| 235 | for (const [file, body] of edited) { |
| 236 | await page.route('**/' + file, r => r.fulfill({ |
| 237 | status: 200, contentType: 'application/javascript', body, |
| 238 | })); |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | // Every request the panel would make to the gateway, caught before it leaves and answered as the |
| 243 | // gateway would answer. Caught rather than served, so this file reads what was SENT — the URL and |
| 244 | // the headers — which is the whole of what checks 1, 4 and 5 are about. |
| 245 | let seen = []; |
| 246 | await page.route('**/api/improve**', (route) => { |
| 247 | const req = route.request(); |
| 248 | seen.push({ url: req.url(), headers: req.headers(), method: req.method() }); |
| 249 | return route.fulfill({ |
| 250 | status: 200, |
| 251 | contentType: 'application/json', |
| 252 | body: JSON.stringify({ total: 0, proposals: [] }), |
| 253 | }); |
| 254 | }); |
| 255 | |
| 256 | await page.goto(process.env.DAIMOND_APP || 'http://localhost:8777', { waitUntil: 'domcontentloaded' }); |
| 257 | await signInAs(s, 'voice'); |
| 258 | await page.waitForTimeout(800); |
| 259 | |
| 260 | /// Every byte this origin holds in localStorage, read RAW. |
| 261 | /// |
| 262 | /// Through the unshimmed `Storage.prototype.getItem`: accounts.js replaces `getItem` on the |
| 263 | /// localStorage INSTANCE to namespace every `daimond-*` key, so a dump taken through the app's own |
| 264 | /// view is a dump of one account's names and not of what is on disk. What check 3 needs is the |
| 265 | /// disk. |
| 266 | const storageDump = () => page.evaluate(() => { |
| 267 | const raw = Storage.prototype.getItem; |
| 268 | const out = {}; |
| 269 | for (let i = 0; i < localStorage.length; i++) { |
| 270 | const k = localStorage.key(i); |
| 271 | out[k] = raw.call(localStorage, k); |
| 272 | } |
| 273 | return out; |
| 274 | }); |
| 275 | |
| 276 | try { |
| 277 | const up = await page.evaluate(() => !!(window.DaimondVoice && window.DaimondVoice.set)); |
| 278 | check('voice.js is loaded and attached its global', up === true, String(up)); |
| 279 | |
| 280 | // ── 1. A voice is set, and the header carries exactly it. |
| 281 | const setOk = await page.evaluate(async (sec) => { |
| 282 | try { await DaimondVoice.set(sec); return 'ok'; } catch (e) { return 'ERR ' + e.message; } |
| 283 | }, SECRET); |
| 284 | check('a voice can be set', setOk === 'ok', setOk); |
| 285 | check('and has() says one is held', |
| 286 | (await page.evaluate(() => DaimondVoice.has())) === true); |
| 287 | |
| 288 | const hdr = await page.evaluate(async () => { |
| 289 | try { return await DaimondVoice.header(); } catch (e) { return { ERR: e.message }; } |
| 290 | }); |
| 291 | check(`HEADER() CARRIES EXACTLY THE SECRET, ON ${HDR}`, |
| 292 | hdr[HDR] === SECRET && Object.keys(hdr).length === 1, |
| 293 | JSON.stringify(Object.keys(hdr)) + ' ' + (hdr[HDR] === SECRET ? 'value matches' : 'value differs')); |
| 294 | |
| 295 | seen = []; |
| 296 | const wrote = await page.evaluate(async () => { |
| 297 | try { |
| 298 | const r = await DaimondVoice.send('/api/improve?account=oxedyne&repo=daimond', { |
| 299 | method: 'POST', |
| 300 | headers: { 'content-type': 'application/json' }, |
| 301 | body: JSON.stringify({ title: 'a title', body: 'a body' }), |
| 302 | }); |
| 303 | return 'HTTP ' + r.status; |
| 304 | } catch (e) { return 'ERR ' + e.message; } |
| 305 | }); |
| 306 | check('a write goes out', wrote === 'HTTP 200', wrote); |
| 307 | const w = seen[0] || { url: '', headers: {} }; |
| 308 | const got = w.headers[HDR]; |
| 309 | check('AND THE REQUEST CARRIED EXACTLY THAT SECRET, ON THAT HEADER', |
| 310 | got === SECRET, |
| 311 | got === SECRET ? '' : (got === undefined |
| 312 | ? 'no such header; voice-ish headers sent: ' |
| 313 | + (Object.keys(w.headers).filter(k => /voice/.test(k)).join(',') || '(none)') |
| 314 | : 'a different value arrived')); |
| 315 | |
| 316 | // ── 1b. IT IS AT REST, not in a variable. Two assertions, one property: the voice is still |
| 317 | // there after the page has been thrown away and unlocked again, and what is on disk is |
| 318 | // ciphertext. This is the necessary companion to check 3, which would pass over an |
| 319 | // empty store. |
| 320 | await page.reload({ waitUntil: 'domcontentloaded' }); |
| 321 | await signInAs(s, 'voice'); |
| 322 | await page.waitForTimeout(600); |
| 323 | const after = await page.evaluate(async () => { |
| 324 | try { |
| 325 | const h = await DaimondVoice.header(); |
| 326 | return Object.keys(h).map(k => h[k]).join(''); |
| 327 | } catch (e) { return 'ERR ' + e.message; } |
| 328 | }); |
| 329 | check('THE VOICE SURVIVES A RELOAD AND AN UNLOCK, so it is held at rest', |
| 330 | after === SECRET, after === SECRET ? '' : after.slice(0, 40)); |
| 331 | |
| 332 | // The fixture, restored. A build that failed the check above has no voice held any more, and |
| 333 | // every check below would then be measuring THAT rather than its own property — a red for a |
| 334 | // borrowed reason reads exactly like proof and is not. |
| 335 | await page.evaluate(async (sec) => { |
| 336 | if (!DaimondVoice.has()) { try { await DaimondVoice.set(sec); } catch (e) {} } |
| 337 | }, SECRET); |
| 338 | |
| 339 | const atRest = await storageDump(); |
| 340 | const held = atRest['daimond-voice'] || ''; |
| 341 | check('and what is at rest is ciphertext, longer than the secret it hides', |
| 342 | !!held && held.indexOf(SECRET) < 0 && held.length > SECRET.length, |
| 343 | held ? held.slice(0, 40) : '(nothing was written down)'); |
| 344 | // What was actually written down, so the clear below can be checked against it rather than |
| 345 | // against a guess at what the record looks like. |
| 346 | let cipher = ''; |
| 347 | try { cipher = JSON.parse(held).s || ''; } catch (e) { cipher = held; } |
| 348 | |
| 349 | // ── 3. Nothing in storage is the secret. (While it is held: after the clear this would pass |
| 350 | // over an empty store and say nothing.) |
| 351 | const asText = JSON.stringify(atRest); |
| 352 | check('THE SECRET IS NOT IN STORAGE IN PLAINTEXT', |
| 353 | asText.indexOf(SECRET) < 0, |
| 354 | asText.indexOf(SECRET) < 0 ? Object.keys(atRest).length + ' keys scanned' |
| 355 | : 'found in: ' + Object.keys(atRest).filter(k => String(atRest[k]).indexOf(SECRET) >= 0).join(',')); |
| 356 | |
| 357 | // ── 4c. A URL that already carries the secret is refused, and nothing goes out. |
| 358 | seen = []; |
| 359 | const refused = await page.evaluate(async (sec) => { |
| 360 | try { |
| 361 | await DaimondVoice.send('/api/improve?account=a&repo=b&voice=' + encodeURIComponent(sec), {}); |
| 362 | return 'SENT'; |
| 363 | } catch (e) { return 'refused: ' + e.message; } |
| 364 | }, SECRET); |
| 365 | check('A URL THAT ALREADY CARRIES THE SECRET IS REFUSED', |
| 366 | refused.indexOf('refused:') === 0 && refused.indexOf(SECRET) < 0, |
| 367 | refused.indexOf(SECRET) >= 0 ? 'REFUSED, BUT THE MESSAGE QUOTES THE SECRET' : refused.slice(0, 60)); |
| 368 | check('and nothing went out carrying it', seen.length === 0, |
| 369 | seen.map(x => x.url).join(' ')); |
| 370 | |
| 371 | // ── 2. clear() really removes it — asserted on storage, not on has(). |
| 372 | await page.evaluate(() => DaimondVoice.clear()); |
| 373 | check('after clear(), has() is false', |
| 374 | (await page.evaluate(() => DaimondVoice.has())) === false); |
| 375 | const gone = await storageDump(); |
| 376 | const goneText = JSON.stringify(gone); |
| 377 | check('CLEAR() REALLY REMOVES IT: no trace of the secret in storage', |
| 378 | goneText.indexOf(SECRET) < 0, |
| 379 | goneText.indexOf(SECRET) < 0 ? '' : 'plaintext still stored'); |
| 380 | check('AND THE CIPHERTEXT IS GONE TOO, not merely hidden behind has()', |
| 381 | !!cipher && goneText.indexOf(cipher) < 0, |
| 382 | !cipher ? 'nothing was ever written down — see the at-rest check above' |
| 383 | : (goneText.indexOf(cipher) < 0 ? '' : 'still in: ' |
| 384 | + Object.keys(gone).filter(k => String(gone[k]).indexOf(cipher) >= 0).join(','))); |
| 385 | check('and the record itself is gone from storage', |
| 386 | gone['daimond-voice'] === undefined, |
| 387 | String(gone['daimond-voice'] || '').slice(0, 40)); |
| 388 | |
| 389 | // ── 5. A public read carries no voice at all, and does not fail. |
| 390 | seen = []; |
| 391 | const read = await page.evaluate(async () => { |
| 392 | try { |
| 393 | const h = await DaimondVoice.header(); |
| 394 | const r = await DaimondVoice.send('/api/improve?account=oxedyne&repo=daimond', { method: 'GET' }); |
| 395 | return { keys: Object.keys(h), status: r.status, ok: r.ok }; |
| 396 | } catch (e) { return { keys: ['ERR'], status: 0, ok: false, err: e.message }; } |
| 397 | }); |
| 398 | check('header() answers {} when no voice is held', |
| 399 | read.keys.length === 0, JSON.stringify(read.keys)); |
| 400 | const r0 = seen[0] || { headers: {}, url: '' }; |
| 401 | check('A PUBLIC READ CARRIES NO VOICE HEADER AT ALL', |
| 402 | Object.prototype.hasOwnProperty.call(r0.headers, HDR) === false, |
| 403 | Object.prototype.hasOwnProperty.call(r0.headers, HDR) |
| 404 | ? `sent ${HDR}: '${r0.headers[HDR]}'` : ''); |
| 405 | check('AND THE PUBLIC READ IS NOT REFUSED', read.ok === true && read.status === 200, |
| 406 | 'HTTP ' + read.status + (read.err ? ' ' + read.err : '')); |
| 407 | |
| 408 | // ── 4a/4b. Nothing anywhere put it in a URL or printed it. |
| 409 | const urls = seen.concat(w ? [w] : []).map(x => x.url); |
| 410 | check('THE SECRET IS NEVER IN A URL OR A QUERY STRING', |
| 411 | urls.every(u => u.indexOf(SECRET) < 0 && u.indexOf(encodeURIComponent(SECRET)) < 0), |
| 412 | urls.filter(u => u.indexOf(SECRET) >= 0).join(' ').slice(0, 120)); |
| 413 | const printed = s.logs.filter(l => l.indexOf(SECRET) >= 0); |
| 414 | const thrown = s.errs.filter(l => l.indexOf(SECRET) >= 0); |
| 415 | check('NOR IN A CONSOLE LOG OR AN ERROR', |
| 416 | printed.length === 0 && thrown.length === 0, |
| 417 | (printed[0] || thrown[0] || '').slice(0, 90)); |
| 418 | |
| 419 | // ── 6. Validation, not looser than the gateway's check_secret. LAST, because a `loose` |
| 420 | // build accepts these and would leave rubbish in the store for the checks above. |
| 421 | const cases = [ |
| 422 | ['an empty secret', ''], |
| 423 | ['a secret of spaces', ' '], |
| 424 | ['a secret with a space in it', SECRET.slice(0, 20) + ' ' + SECRET.slice(20)], |
| 425 | ['a secret with a newline', SECRET + '\ninjected: yes'], |
| 426 | ['a secret with a tab', SECRET.slice(0, 10) + '\t' + SECRET.slice(10)], |
| 427 | ['a non-ASCII secret', 'Vz7Kq3Np9Rw2Ty5Uv8Bd4Fg6Hj1Lm0Qs3Xc5Vb7Né'], |
| 428 | ['a truncated paste', SECRET.slice(0, 8)], |
| 429 | ['a secret past 256 bytes', 'V'.repeat(257)], |
| 430 | ]; |
| 431 | for (const [what, value] of cases) { |
| 432 | const r = await page.evaluate(async (v) => ({ |
| 433 | why: DaimondVoice.check(v), |
| 434 | threw: await DaimondVoice.set(v).then(() => false, () => true), |
| 435 | }), value); |
| 436 | check('VALIDATION REFUSES ' + what, |
| 437 | !!r.why && r.threw === true, |
| 438 | r.why ? (r.threw ? '' : 'said why, but set() took it anyway') : 'accepted'); |
| 439 | } |
| 440 | const good = await page.evaluate(async (sec) => ({ |
| 441 | why: DaimondVoice.check(sec), |
| 442 | set: await DaimondVoice.set(sec).then(() => 'ok', (e) => 'ERR ' + e.message), |
| 443 | }), SECRET); |
| 444 | check('and it accepts a real minted voice, so it is not merely refusing everything', |
| 445 | good.why === '' && good.set === 'ok', good.why || good.set); |
| 446 | await page.evaluate(() => DaimondVoice.clear()); |
| 447 | |
| 448 | // ── 6b. THE LABELLED PASTE, which the forge itself produces. |
| 449 | // |
| 450 | // `oregami voice` prints `secret <token>` in two columns, and this app's own |
| 451 | // help text used to say "copy the whole line the forge printed" -- so a person |
| 452 | // who did exactly as told hit "That does not look like a voice." The |
| 453 | // instruction and the validator disagreed and the app took the validator's |
| 454 | // side. The owner, who wrote the app, could not get past it. |
| 455 | // |
| 456 | // THE LABEL COMES OFF ONLY WHEN THE TAIL IS EXACTLY A VOICE LONG, which is what |
| 457 | // keeps this from swallowing a DAMAGED paste: the cases above prove a secret |
| 458 | // with whitespace knocked into the middle of it is still refused, and it must |
| 459 | // stay refused, because storing its second half would earn an `unknown` from |
| 460 | // the forge that says nothing about which of the two things went wrong. |
| 461 | // |
| 462 | // Asked of `tidy` as well as of `check`: a rule that merely ACCEPTED the paste |
| 463 | // while storing the label with it would pass a check that only looked at the |
| 464 | // refusal, and would then send the label on the header. |
| 465 | // EXACTLY WHAT THE FORGE MINTS, DERIVED RATHER THAN TYPED. `SECRET_BYTES` is 32 and |
| 466 | // `ore_store::keys::text_of` prints them with HEMATITE64, which is NOT standard Base64: |
| 467 | // `fe2o3_text/src/base2x.rs:75-79` gives it `padding: Some(('=', ['1'..'5','_']))`, so the |
| 468 | // symbols are followed by `'='` and a marker digit counting the leftover bits. 43 symbols |
| 469 | // carry 258 bits for 256 of secret, hence the `=2` every minted voice ends on. |
| 470 | // |
| 471 | // THIS LINE SAID `'V'.repeat(43)` UNTIL 2026-08-29 AND THAT IS WHY NOTHING CAUGHT THE BUG. |
| 472 | // A fixture no forge has ever issued made every labelled-paste check below agree with a |
| 473 | // `LEN` that was also 43, so the pair was consistent and wrong together, and `tidy` never |
| 474 | // stripped a real `secret ` label in the running app. The number is computed here so it |
| 475 | // cannot be typed wrong in two places again. |
| 476 | const SYMBOLS = Math.ceil((32 * 8) / 6); // 43 |
| 477 | const MINTED = 'V'.repeat(SYMBOLS) + '=' + String(SYMBOLS * 6 - 32 * 8); // …=2, 45 long |
| 478 | const labelled = [ |
| 479 | ['the forge\'s own two-column line', 'secret ' + MINTED], |
| 480 | ['it with a trailing newline', 'secret ' + MINTED + '\n'], |
| 481 | ['a tab between the columns', 'secret\t' + MINTED], |
| 482 | ['leading and trailing space', ' ' + MINTED + ' '], |
| 483 | ]; |
| 484 | for (const [what, value] of labelled) { |
| 485 | const r = await page.evaluate(async (v) => ({ |
| 486 | why: DaimondVoice.check(v), |
| 487 | tidy: DaimondVoice.tidy(v), |
| 488 | }), value); |
| 489 | check('A LABELLED PASTE IS ACCEPTED: ' + what, r.why === '', r.why); |
| 490 | check('and what would be stored is the SECRET alone: ' + what, |
| 491 | r.tidy === MINTED, JSON.stringify(r.tidy).slice(0, 60)); |
| 492 | } |
| 493 | // And the length is the forge's, named once on both sides rather than typed |
| 494 | // twice: a build that changed it here and not there would fold nothing and |
| 495 | // nobody would find out until somebody pasted a labelled line. |
| 496 | const len = await page.evaluate(() => DaimondVoice.LEN); |
| 497 | check('the length it folds on is the length the forge mints (' + MINTED.length + ')', |
| 498 | len === MINTED.length, String(len)); |
| 499 | |
| 500 | } catch (e) { |
| 501 | check('the run completed', false, String(e && e.message || e)); |
| 502 | } finally { |
| 503 | await s.close?.().catch(() => {}); |
| 504 | } |
| 505 | |
| 506 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 507 | if (BREAK) { |
| 508 | console.log(bad.length |
| 509 | ? `\nbreak '${BREAK}' produced failures, as it must.` |
| 510 | : `\nBREAK '${BREAK}' CHANGED NOTHING — the check it targets is not proving anything.`); |
| 511 | } |
| 512 | process.exit(bad.length ? 1 : 0); |