Oregami
Repositories/oxedyne/daimond

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
77import fs from 'node:fs';
78import path from 'node:path';
79import { fileURLToPath } from 'node:url';
80import { open, signInAs } from './harness.mjs';
81
82const HERE = path.dirname(fileURLToPath(import.meta.url));
83const 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.
89const 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
100const SECRET = 'Vz7Kq3Np9Rw2Ty5Uv8Bd4Fg6Hj1Lm0Qs3Xc5Vb7Nm2P=2';
101
102const BREAK = (() => {
103 const i = process.argv.indexOf('--break');
104 return i > 0 ? String(process.argv[i + 1] || '') : '';
105})();
106
107const 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
209const ok = [], bad = [];
210const check = (name, pass, detail) => {
211 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
212 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
213};
214
215const s = await open({ name: 'voice', signIn: false, connect: false });
216const { page } = s;
217
218if (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.
245let seen = [];
246await 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
256await page.goto(process.env.DAIMOND_APP || 'http://localhost:8777', { waitUntil: 'domcontentloaded' });
257await signInAs(s, 'voice');
258await 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.
266const 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
276try {
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
506console.log(`\n${ok.length} passed, ${bad.length} failed`);
507if (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}
512process.exit(bad.length ? 1 : 0);