Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_mailtunnel.mjs

26.6 KiB, 1 run

created by r2519314175:531, 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_mailtunnel.mjs — the blind mail tunnel, from the browser's end.
2//
3// TLS terminates in the page (`src/wasm/mailtls.rs`) and the gateway forwards
4// opaque bytes, so the mail password never leaves the browser. This drives the
5// client half of that — `www/js/mail.js`'s tunnel section — in a real browser,
6// against a real provider's certificate and a real bad one.
7//
8// WHAT IT PROVES, and what each proof is worth:
9//
10// 1. the shipped bundle is the verify-always build, not the testing one. A
11// handshake that succeeds because verification was off is not a handshake;
12// 2. a tunnel reaches `open` against imap.gmail.com:993 — an EXTERNAL oracle,
13// because the certificate chain and the root store are both somebody else's;
14// 3. a self-signed certificate becomes `failed` with rustls's own discriminant,
15// inside a round trip and not by timing out. That is the defect the wasm was
16// fixed for once already: `state` reads `failed` first because rustls
17// abandons a handshake mid-flight, so a client polling for `open` alone hangs;
18// 4. each close-code PAIR gets its own sentence. The PAIR, because 4403 and 4429
19// are overloaded and the reason word is the only discriminator — and `host`
20// against `unresolved` is the pair that once let the gateway's own test pass
21// with the check it was named after switched off;
22// 5. THE ONE THIS FEATURE EXISTS FOR: a secret written into a tunnel appears in
23// no request the browser makes and in no byte the relay forwards. Recorded
24// first, greped second, and each grep shown to go red.
25//
26// WHAT IT DOES NOT PROVE. `mail_imap` and `mail_smtp_send` do not exist, so no
27// IMAP conversation happens here and mail still travels the old bridge. The
28// password check below writes a marker through `tun.write` — the same door the
29// protocol layer will use — which proves the transport encrypts what is written to
30// it. It cannot prove that the unwritten protocol layer writes the password there
31// rather than somewhere else. See the seam in mail.js.
32//
33// node dev/verify_mailtunnel.mjs
34//
35// Brings up its own world: a dev server on :8785 whose /api hop points at a stub
36// relay on :9421 rather than the gateway. The relay is a DUMB PIPE, as the gateway
37// is: it dials plain TCP and copies bytes, so the TLS is genuinely end to end.
38// Needs outbound network for the real-provider checks, which are reported as
39// skipped rather than passed when it is absent.
40import fs from 'node:fs';
41import net from 'node:net';
42import os from 'node:os';
43import http from 'node:http';
44import path from 'node:path';
45import tls from 'node:tls';
46import crypto from 'node:crypto';
47import { execFileSync, spawn } from 'node:child_process';
48import { fileURLToPath } from 'node:url';
49
50const HERE = path.dirname(fileURLToPath(import.meta.url));
51const APP = path.resolve(HERE, '..');
52// NOT /tmp: tmpfs pages are RAM charged to whoever wrote them, and this lane's
53// artefacts go under its own named subdirectory of a shared root it does not own.
54const WORK = path.join(os.homedir(), '.cache', 'daimond', 'laneC');
55
56const PORT = 8785; // the dev server for this run
57const RELAY = 9421; // the stub /api hop the dev server proxies to
58const TLS_FIX = 8796; // a local TLS server with a self-signed certificate
59const CLEAR_FIX = 8797; // a local plain server that answers nothing
60const CA_FIX = 8798; // a local TLS server presenting a CA as its own leaf
61
62const ok = [], bad = [], skip = [];
63const check = (name, pass, detail) => {
64 (pass ? ok : bad).push(name);
65 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
66};
67const note = (name, why) => {
68 skip.push(name);
69 console.log(' skip ' + name + (why ? ' — ' + why : ''));
70};
71
72fs.mkdirSync(WORK, { recursive: true });
73
74// ── The fixtures ────────────────────────────────────────────────────────────
75
76/// Two bad certificates, made locally so the refusal checks need no network.
77///
78/// TWO, and the second one is the reason this function is longer than it was. The
79/// obvious fixture — `openssl req -x509` — is a CA certificate, because that is what
80/// `-x509` writes, and rustls refuses it as `CaUsedAsEndEntity` rather than
81/// `UnknownIssuer`. So the check named for an untrusted ISSUER was passing on a
82/// different refusal entirely and its sentence assertion failed, which is how the
83/// mistake was found. `leaf` is a real end-entity certificate signed by a CA no root
84/// store carries, which is the case a user actually meets; `ca` keeps the other one,
85/// because it is a real refusal class too and it must still reach a sentence.
86function badCerts() {
87 const f = (n) => path.join(WORK, n);
88 if (!fs.existsSync(f('leaf.crt'))) {
89 const ext = f('leaf.ext');
90 fs.writeFileSync(ext, 'subjectAltName=DNS:localhost\nbasicConstraints=CA:FALSE\n');
91 execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes',
92 '-keyout', f('ca.key'), '-out', f('ca.crt'), '-days', '30',
93 '-subj', '/CN=laneC-test-ca'], { stdio: 'ignore' });
94 execFileSync('openssl', ['req', '-newkey', 'rsa:2048', '-nodes',
95 '-keyout', f('leaf.key'), '-out', f('leaf.csr'),
96 '-subj', '/CN=localhost'], { stdio: 'ignore' });
97 execFileSync('openssl', ['x509', '-req', '-in', f('leaf.csr'),
98 '-CA', f('ca.crt'), '-CAkey', f('ca.key'), '-days', '30',
99 '-extfile', ext, '-out', f('leaf.crt')], { stdio: 'ignore' });
100 }
101 return {
102 leaf: { key: fs.readFileSync(f('leaf.key')), cert: fs.readFileSync(f('leaf.crt')) },
103 ca: { key: fs.readFileSync(f('ca.key')), cert: fs.readFileSync(f('ca.crt')) },
104 };
105}
106
107/// Where a host name the page asks for actually goes, and what happens instead of
108/// a dial.
109///
110/// Keyed on the host because that is all a client may put in the query, and it
111/// keeps each case's intent in the test rather than in a mutable mode on the
112/// relay: two cases can never be reading each other's state.
113const ROUTES = {
114 // Refusals. Each is a DISTINCT code/reason pair, so no case can pass by
115 // walking another's path.
116 'c4401.test': { close: [4401, 'auth'] },
117 'c4402.test': { close: [4402, 'pro'] },
118 'c4403port.test': { close: [4403, 'port'] },
119 'c4403host.test': { close: [4403, 'host'] },
120 'c4403unres.test': { close: [4403, 'unresolved'] },
121 'c4429cred.test': { close: [4429, 'credits'] },
122 'c4429conc.test': { close: [4429, 'concurrent'] },
123 'c1009.test': { close: [1009, 'toobig'] },
124 'c1013.test': { close: [1013, 'unreachable'] },
125 'c1000done.test': { close: [1000, 'done'] },
126 'c1000idle.test': { close: [1000, 'idle'] },
127 // A local TLS server whose certificate is signed by a CA no root store carries.
128 'localhost': { dial: ['127.0.0.1', TLS_FIX] },
129 // And one presenting a CA certificate as its own leaf, which is a different
130 // refusal and must still reach a sentence rather than a raw discriminant.
131 'ca-as-leaf.test': { dial: ['127.0.0.1', CA_FIX] },
132 // A plain server, for the clear phase of a STARTTLS tunnel: whatever is
133 // written before the promotion goes across in the open, which is what makes
134 // the ciphertext grep below able to go red.
135 'clear.test': { dial: ['127.0.0.1', CLEAR_FIX] },
136 // The real thing, and the external oracle: a certificate chain and a root
137 // store that are both somebody else's.
138 'imap.gmail.com': { dial: ['imap.gmail.com', 993] },
139 // The same socket under a name the certificate does not carry.
140 'wrongname.test': { dial: ['imap.gmail.com', 993] },
141 // A certificate from a real issuer that expired years ago. The browser's own
142 // clock is the expiry oracle here, which mailtls.rs discloses.
143 'expired.badssl.com': { dial: ['expired.badssl.com', 443] },
144};
145
146/// Every byte the relay forwarded from the browser towards a provider, which is
147/// exactly what the gateway would hold. The recorder for check 5.
148let upBytes = [];
149
150const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
151
152/// One WebSocket frame out, unmasked, as a server sends them.
153function frame(opcode, payload) {
154 const len = payload.length;
155 let head;
156 if (len < 126) { head = Buffer.from([0x80 | opcode, len]); }
157 else if (len < 65536) { head = Buffer.alloc(4); head[0] = 0x80 | opcode; head[1] = 126; head.writeUInt16BE(len, 2); }
158 else { head = Buffer.alloc(10); head[0] = 0x80 | opcode; head[1] = 127; head.writeBigUInt64BE(BigInt(len), 2); }
159 return Buffer.concat([head, payload]);
160}
161
162/// Pull whole frames out of a growing buffer. Client frames are masked.
163function frames(buf) {
164 const out = [];
165 let i = 0;
166 for (;;) {
167 if (buf.length - i < 2) break;
168 const op = buf[i] & 0x0f, masked = (buf[i + 1] & 0x80) !== 0;
169 let len = buf[i + 1] & 0x7f, j = i + 2;
170 if (len === 126) { if (buf.length - j < 2) break; len = buf.readUInt16BE(j); j += 2; }
171 else if (len === 127) { if (buf.length - j < 8) break; len = Number(buf.readBigUInt64BE(j)); j += 8; }
172 let mask = null;
173 if (masked) { if (buf.length - j < 4) break; mask = buf.subarray(j, j + 4); j += 4; }
174 if (buf.length - j < len) break;
175 const body = Buffer.from(buf.subarray(j, j + len));
176 if (mask) for (let k = 0; k < body.length; k++) body[k] ^= mask[k & 3];
177 out.push({ op, body });
178 i = j + len;
179 }
180 return { got: out, rest: buf.subarray(i) };
181}
182
183/// The stub /api hop: a WebSocket-to-TCP pipe on the tunnel path, and a
184/// permissive stand-in for the gateway's other routes so the app boots.
185function startRelay() {
186 const srv = http.createServer((req, res) => {
187 res.writeHead(200, { 'content-type': 'application/json' });
188 // Deliberately generous: this file is not testing the gateway's routes, and
189 // a bootstrap that fails would leave the panel saying so instead of saying
190 // what the tunnel said.
191 res.end(JSON.stringify({ ok: true, credits_minor: 100000, currency: 'usd',
192 unlocked: true, max_accounts: 10, accounts: [] }));
193 });
194 srv.on('upgrade', (req, sock) => {
195 const u = new URL(req.url, 'http://x');
196 const host = u.searchParams.get('host') || '';
197 const key = req.headers['sec-websocket-key'] || '';
198 const acc = crypto.createHash('sha1').update(key + WS_GUID).digest('base64');
199 sock.write('HTTP/1.1 101 Switching Protocols\r\n'
200 + 'Upgrade: websocket\r\nConnection: Upgrade\r\n'
201 + 'Sec-WebSocket-Accept: ' + acc + '\r\n\r\n');
202 const route = ROUTES[host];
203 if (!route) { sock.end(frame(8, Buffer.from([0x0f, 0xa3, ...Buffer.from('host')]))); return; }
204 if (route.close) {
205 const [code, reason] = route.close;
206 const p = Buffer.alloc(2 + Buffer.byteLength(reason));
207 p.writeUInt16BE(code, 0);
208 p.write(reason, 2);
209 sock.end(frame(8, p));
210 return;
211 }
212 const far = net.connect(route.dial[1], route.dial[0]);
213 far.on('error', () => sock.destroy());
214 far.on('data', (d) => { try { sock.write(frame(2, d)); } catch (e) { /* gone */ } });
215 far.on('close', () => sock.destroy());
216 let buf = Buffer.alloc(0);
217 sock.on('data', (d) => {
218 buf = Buffer.concat([buf, d]);
219 const { got, rest } = frames(buf);
220 buf = rest;
221 for (const f of got) {
222 if (f.op === 8) { far.destroy(); sock.destroy(); return; }
223 if (f.op !== 2) continue;
224 // The recorder. This is the gateway's whole view of the payload.
225 upBytes.push(f.body);
226 far.write(f.body);
227 }
228 });
229 sock.on('error', () => far.destroy());
230 sock.on('close', () => far.destroy());
231 });
232 return new Promise((res) => srv.listen(RELAY, '127.0.0.1', () => res(srv)));
233}
234
235function startFixtures() {
236 const certs = badCerts();
237 const greet = (sock) => sock.write('* OK fixture ready\r\n');
238 const lsrv = tls.createServer(certs.leaf, greet);
239 const asrv = tls.createServer(certs.ca, greet);
240 const csrv = net.createServer(() => { /* accept and say nothing */ });
241 return Promise.all([
242 new Promise((r) => lsrv.listen(TLS_FIX, '127.0.0.1', () => r(lsrv))),
243 new Promise((r) => asrv.listen(CA_FIX, '127.0.0.1', () => r(asrv))),
244 new Promise((r) => csrv.listen(CLEAR_FIX, '127.0.0.1', () => r(csrv))),
245 ]);
246}
247
248function startServer() {
249 const p = spawn(process.execPath, [path.join(HERE, 'serve.mjs')], {
250 cwd: APP,
251 env: { ...process.env, DAIMOND_PORT: String(PORT), DAIMOND_GW_PORT: String(RELAY) },
252 stdio: ['ignore', 'pipe', 'pipe'],
253 });
254 return new Promise((res, rej) => {
255 const t = setTimeout(() => rej(new Error('the dev server never bound :' + PORT)), 8000);
256 p.stdout.on('data', (d) => {
257 if (String(d).includes('http://localhost:' + PORT)) { clearTimeout(t); res(p); }
258 });
259 });
260}
261
262/// Is a real host reachable at all? The real-provider checks are skipped rather
263/// than failed when it is not, because "no network" is not "rustls is broken".
264function reachable(host, port) {
265 return new Promise((res) => {
266 const s = net.connect({ host, port });
267 const done = (v) => { try { s.destroy(); } catch (e) {} res(v); };
268 s.setTimeout(6000);
269 s.on('connect', () => done(true));
270 s.on('error', () => done(false));
271 s.on('timeout', () => done(false));
272 });
273}
274
275// ── The run ─────────────────────────────────────────────────────────────────
276
277const relay = await startRelay();
278const fixes = await startFixtures();
279const serve = await startServer();
280
281// The recorder, armed BEFORE anything navigates.
282//
283// Every request the browser makes, and every WebSocket URL and frame it sends. The
284// harness's `route` hook runs BEFORE the navigation, which is the whole reason it
285// exists: a recorder attached after the traffic reports silence, and silence reads
286// as absence.
287const seen = { reqs: [], sockets: [], sent: [] };
288const record = (pg) => {
289 pg.on('request', (r) => {
290 let post = '';
291 try { post = r.postData() || ''; } catch (e) { post = ''; }
292 seen.reqs.push({ url: r.url(), method: r.method(), post });
293 });
294 pg.on('websocket', (w) => {
295 seen.sockets.push(w.url());
296 w.on('framesent', (f) => {
297 try { seen.sent.push(Buffer.from(f.payload)); } catch (e) { /* text frame */ }
298 });
299 });
300};
301
302// Through the harness, so the passphrase gate is passed and the wasm is instantiated
303// the way the app does it. `connect: false` because nothing here needs a model. The
304// env is set before the import because harness.mjs reads it at load.
305process.env.DAIMOND_PORT = String(PORT);
306process.env.DAIMOND_APP = 'http://localhost:' + PORT;
307process.env.DAIMOND_SCRATCH = WORK;
308const { open } = await import('./harness.mjs');
309const s = await open({ name: 'mailtunnel', connect: false, route: record });
310const { page, errs } = s;
311
312try {
313 await page.waitForFunction(() => !!window.DaimondMail && !!window.DaimondI18n,
314 null, { timeout: 20000 });
315
316 // ── 1. The bundle is the one that always verifies ──────────────────
317 const flavour = await page.evaluate(() => window.DaimondMail.flavour());
318 check('the shipped bundle is the verify-always build, not the testing one',
319 flavour === 'mailtls/verify-always', flavour);
320 check('mail.js offers the tunnel it is supposed to own',
321 await page.evaluate(() => typeof window.DaimondMail.tunnel === 'function'));
322
323 // The page's own driver, so every check below runs the SAME `openTunnel` the
324 // sync path will call rather than a simpler one written for the test.
325 const drive = async (spec, plan) => page.evaluate(async ([spec, plan]) => {
326 const out = { err: '', state: '', version: '', fault: '', closed: null };
327 let tun;
328 try {
329 tun = await window.DaimondMail.tunnel(spec);
330 } catch (e) {
331 out.err = e.message;
332 return out;
333 }
334 try {
335 await tun.ready(plan.want || 'open');
336 out.state = tun.state();
337 out.version = tun.version();
338 if (plan.write) tun.write(new TextEncoder().encode(plan.write));
339 // A beat, so what was written reaches the socket before it is closed.
340 if (plan.write) await new Promise((r) => setTimeout(r, 400));
341 } catch (e) {
342 out.err = e.message;
343 }
344 out.state = out.state || tun.state();
345 out.fault = tun.fault();
346 out.closed = tun.closed();
347 tun.close();
348 return out;
349 }, [spec, plan]);
350
351 // ── 2. A real provider, which is somebody else's certificate ───────
352 const netUp = await reachable('imap.gmail.com', 993);
353 if (!netUp) {
354 note('a tunnel reaches `open` against imap.gmail.com:993', 'no outbound network');
355 note('a certificate for the wrong host is refused by name', 'no outbound network');
356 } else {
357 const good = await drive({ host: 'imap.gmail.com', port: 993, security: 'tls' }, {});
358 check('a tunnel reaches `open` against imap.gmail.com:993, verified against the bundled roots',
359 good.state === 'open' && !good.err, `state=${good.state} version=${good.version} err=${good.err}`);
360 check('and it negotiated a real TLS version rather than nothing',
361 /TLSv1_[23]/.test(good.version || ''), good.version);
362
363 // ── 3a. The same socket under a name the certificate lacks ──
364 const wrong = await drive({ host: 'wrongname.test', port: 993, security: 'tls' }, {});
365 check('a certificate valid for another host is refused, and refused BY NAME',
366 wrong.state === 'failed' && /NotValidForName/.test(wrong.fault || ''),
367 `state=${wrong.state} fault=${wrong.fault}`);
368 check('and the refusal reaches the user as the wrong-host sentence',
369 /different server/.test(wrong.err) && wrong.err.includes('wrongname.test'),
370 wrong.err.slice(0, 90));
371 }
372
373 // ── 3b. A certificate from an untrusted issuer, no network needed ──
374 const t0 = Date.now();
375 const self = await drive({ host: 'localhost', port: TLS_FIX, security: 'tls' }, {});
376 const took = Date.now() - t0;
377 check('a certificate from an issuer no root store carries is refused',
378 self.state === 'failed', `state=${self.state} fault=${self.fault}`);
379 check('and the fault is rustls’s own discriminant, not a guess',
380 /UnknownIssuer/.test(self.fault || ''), self.fault);
381 check('and the refusal reaches the user as the untrusted-issuer sentence',
382 /issuer/.test(self.err), self.err.slice(0, 90));
383 // A HANG is the failure this is really about: rustls abandons a handshake
384 // mid-flight, so a client that polled for `open` alone would never return. The
385 // bound is deliberately far below the 20s handshake deadline — a check that
386 // allowed 19s would pass on a client that was in fact waiting for the deadline.
387 check('and it comes back inside a round trip rather than on the handshake deadline',
388 took < 3000, `${took}ms, deadline is 20000ms`);
389
390 // ── 3c. A CA certificate offered as a leaf, which is a DIFFERENT class ──
391 //
392 // Here because it is what `openssl req -x509` produces, so it is what anybody
393 // pointing this at a hand-made fixture will meet — and because it proves the
394 // generic arm of `certWords` reaches a sentence. Without it that arm was dead
395 // code that nothing had ever run.
396 const caLeaf = await drive({ host: 'ca-as-leaf.test', port: CA_FIX, security: 'tls' }, {});
397 check('a CA certificate offered as a leaf is refused too',
398 caLeaf.state === 'failed' && /CaUsedAsEndEntity/.test(caLeaf.fault || ''),
399 `state=${caLeaf.state} fault=${caLeaf.fault}`);
400 check('and a refusal class with no sentence of its own still gets the general one, carrying the fault',
401 /could not verify/.test(caLeaf.err) && caLeaf.err.includes('CaUsedAsEndEntity'),
402 caLeaf.err.slice(0, 100));
403
404 // ── 3d. An expired certificate from a real issuer ──────────────────
405 if (await reachable('expired.badssl.com', 443)) {
406 const exp = await drive({ host: 'expired.badssl.com', port: 443, security: 'tls' }, {});
407 check('an expired certificate is refused, and refused AS expired',
408 exp.state === 'failed' && /Expired/.test(exp.fault || ''),
409 `state=${exp.state} fault=${exp.fault}`);
410 check('and the expiry sentence mentions this machine’s clock, which is the oracle',
411 /clock/.test(exp.err), exp.err.slice(0, 90));
412 } else {
413 note('an expired certificate is refused, and refused AS expired', 'no outbound network');
414 note('and the expiry sentence mentions this machine’s clock', 'no outbound network');
415 }
416
417 // ── 4. Every close pair gets its own sentence ──────────────────────
418 //
419 // THE PAIR, not the code. Each case names a unique pair, so a case cannot pass
420 // by walking another's path — which is how the gateway's own unbound-host test
421 // once passed with the binding check switched off.
422 const pairs = [
423 ['c4401.test', 4401, 'auth', /signed this device out/],
424 ['c4402.test', 4402, 'pro', /part of Pro/],
425 ['c4403port.test', 4403, 'port', /993, 143, 465 and 587/],
426 ['c4403host.test', 4403, 'host', /has bound/],
427 ['c4403unres.test', 4403, 'unresolved', /does not resolve/],
428 ['c4429cred.test', 4429, 'credits', /credits ran out/],
429 ['c4429conc.test', 4429, 'concurrent', /four mail connections/],
430 ['c1009.test', 1009, 'toobig', /more at once than the gateway/],
431 ['c1013.test', 1013, 'unreachable', /could not be reached/],
432 ['c1000done.test', 1000, 'done', /ended the connection/],
433 ['c1000idle.test', 1000, 'idle', /stopped answering/],
434 ];
435 const said = new Map();
436 for (const [host, code, reason, want] of pairs) {
437 const r = await drive({ host, port: 993, security: 'tls' }, {});
438 check(`close ${code}/${reason} says its own sentence`,
439 want.test(r.err), `${code}/${reason} — ${r.err.slice(0, 90)}`);
440 said.set(`${code}/${reason}`, r.err);
441 }
442 check('and no two of the eleven pairs say the same thing',
443 new Set(said.values()).size === said.size,
444 `${new Set(said.values()).size} distinct of ${said.size}`);
445 // The overloaded codes, asserted as the thing that matters: the halves differ.
446 check('4403 host and 4403 unresolved are DIFFERENT sentences, which is the pair that hid a dead check',
447 said.get('4403/host') !== said.get('4403/unresolved')
448 && !!said.get('4403/host') && !!said.get('4403/unresolved'));
449 check('4429 credits and 4429 concurrent are different sentences too',
450 said.get('4429/credits') !== said.get('4429/concurrent')
451 && !!said.get('4429/credits'));
452
453 // ── 5. The password appears nowhere the browser sends it ───────────
454 //
455 // The assertion this whole feature exists for. A marker is written into an OPEN
456 // tunnel through `tun.write`, which is the door the protocol layer will use.
457 const SECRET = 'laneC-app-password-' + crypto.randomBytes(6).toString('hex');
458 let ciphered = null;
459 if (netUp) {
460 upBytes = [];
461 seen.sent = [];
462 ciphered = await drive({ host: 'imap.gmail.com', port: 993, security: 'tls' },
463 { write: SECRET });
464 const relayHeld = Buffer.concat(upBytes);
465 const browserPut = Buffer.concat(seen.sent);
466 check('the secret was actually written down a tunnel that had reached `open`',
467 ciphered.state === 'open' && relayHeld.length > 0,
468 `state=${ciphered.state} relay held ${relayHeld.length}B`);
469 check('and the gateway’s whole view of the payload does not contain it',
470 !relayHeld.includes(SECRET), `${relayHeld.length} bytes forwarded`);
471 check('nor does any frame the browser sent, read from the browser’s own side',
472 browserPut.length > 0 && !browserPut.includes(SECRET),
473 `${browserPut.length} bytes in ${seen.sent.length} frame(s)`);
474 } else {
475 note('a secret written into an open tunnel is ciphertext on the wire', 'no outbound network');
476 }
477
478 // THE RED PROOF for that grep. The clear phase of a STARTTLS tunnel passes bytes
479 // through untouched, by design, so the same write on the same recorder must be
480 // FOUND. A grep that cannot find a secret it is looking straight at is a grep
481 // that proves nothing about the case where it finds none.
482 upBytes = [];
483 const clear = await drive({ host: 'clear.test', port: CLEAR_FIX, security: 'starttls' },
484 { want: 'clear', write: SECRET });
485 const clearHeld = Buffer.concat(upBytes);
486 check('RED PROOF: the same write over an unpromoted STARTTLS tunnel IS found in the clear',
487 clearHeld.includes(SECRET),
488 `state=${clear.state}, ${clearHeld.length} bytes forwarded`);
489
490 // ── The request recorder, proved to work ──────────────────────────
491 const inReq = (needle) => seen.reqs.some((r) =>
492 r.url.includes(needle) || (r.post || '').includes(needle));
493 check('the password is in no request URL and no request body',
494 !inReq(SECRET), `${seen.reqs.length} request(s) recorded`);
495 check('and in no WebSocket URL either — a URL is the worst place for a secret',
496 !seen.sockets.some((u) => u.includes(SECRET)),
497 `${seen.sockets.length} socket(s): ${seen.sockets.slice(0, 1).join(' ')}`);
498 // A recorder that records nothing passes both of those. So put the secret
499 // somewhere it should not be, on purpose, and watch the same grep find it.
500 const CANARY = 'laneC-canary-' + crypto.randomBytes(4).toString('hex');
501 await page.evaluate(async (c) => {
502 try {
503 await fetch('/api/laneC-canary?probe=' + encodeURIComponent(c),
504 { method: 'POST', body: JSON.stringify({ password: c }) });
505 } catch (e) { /* the answer does not matter; the recording does */ }
506 }, CANARY);
507 await page.waitForTimeout(300);
508 check('RED PROOF: the same recorder finds a secret deliberately put in a body and a URL',
509 inReq(CANARY), `${seen.reqs.length} request(s) recorded`);
510
511 // ── 6. The release fact: mail still travels the old bridge ─────────
512 //
513 // The tunnel is inert in this release, and a file that CLAIMED otherwise while
514 // the bridge carried the password would be the worst outcome available. Asserted
515 // on the source, because that is where the claim would live.
516 const src = fs.readFileSync(path.join(APP, 'www', 'js', 'mail.js'), 'utf8');
517 check('syncOne still posts to /api/mail/sync, so mail works in this release',
518 src.includes("post('/api/mail/sync', body)"));
519 check('and the header says so rather than claiming the tunnel carries mail',
520 /NOT\s*\n\s*\*\s*yet carrying anything/.test(src));
521 check('the seam fails loudly rather than answering an empty mailbox',
522 src.includes("mail.err.protocol_pending"));
523
524 // PAGE ERRORS, not console errors. The app is booting against a stub for the
525 // gateway's other routes, so console noise is this harness's own doing; an
526 // uncaught exception is not, and is the only one of the two that means the
527 // tunnel code threw somewhere nothing caught it.
528 const thrown = errs.filter((e) => /^pageerror:/.test(e));
529 check('nothing threw uncaught in the page while any of that ran',
530 thrown.length === 0, thrown.slice(0, 2).join(' | ') || `${errs.length} console line(s)`);
531} finally {
532 await s.close().catch(() => {});
533 serve.kill();
534 relay.close();
535 fixes.forEach((f) => f.close());
536 // A Playwright profile per run: left on tmpfs it took the fleet down three times,
537 // and left on disk it is still a directory per run.
538 try { fs.rmSync(path.join(WORK, 'pw'), { recursive: true, force: true }); } catch (e) {}
539}
540
541console.log(`\nverify_mailtunnel: ${ok.length} ok, ${bad.length} failed, ${skip.length} skipped.`);
542if (bad.length) { bad.forEach((b) => console.log(' FAILED: ' + b)); process.exit(1); }
543process.exit(0);