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. |
| 40 | import fs from 'node:fs'; |
| 41 | import net from 'node:net'; |
| 42 | import os from 'node:os'; |
| 43 | import http from 'node:http'; |
| 44 | import path from 'node:path'; |
| 45 | import tls from 'node:tls'; |
| 46 | import crypto from 'node:crypto'; |
| 47 | import { execFileSync, spawn } from 'node:child_process'; |
| 48 | import { fileURLToPath } from 'node:url'; |
| 49 | |
| 50 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 51 | const 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. |
| 54 | const WORK = path.join(os.homedir(), '.cache', 'daimond', 'laneC'); |
| 55 | |
| 56 | const PORT = 8785; // the dev server for this run |
| 57 | const RELAY = 9421; // the stub /api hop the dev server proxies to |
| 58 | const TLS_FIX = 8796; // a local TLS server with a self-signed certificate |
| 59 | const CLEAR_FIX = 8797; // a local plain server that answers nothing |
| 60 | const CA_FIX = 8798; // a local TLS server presenting a CA as its own leaf |
| 61 | |
| 62 | const ok = [], bad = [], skip = []; |
| 63 | const check = (name, pass, detail) => { |
| 64 | (pass ? ok : bad).push(name); |
| 65 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 66 | }; |
| 67 | const note = (name, why) => { |
| 68 | skip.push(name); |
| 69 | console.log(' skip ' + name + (why ? ' — ' + why : '')); |
| 70 | }; |
| 71 | |
| 72 | fs.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. |
| 86 | function 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. |
| 113 | const 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. |
| 148 | let upBytes = []; |
| 149 | |
| 150 | const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; |
| 151 | |
| 152 | /// One WebSocket frame out, unmasked, as a server sends them. |
| 153 | function 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. |
| 163 | function 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. |
| 185 | function 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 | |
| 235 | function 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 | |
| 248 | function 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". |
| 264 | function 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 | |
| 277 | const relay = await startRelay(); |
| 278 | const fixes = await startFixtures(); |
| 279 | const 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. |
| 287 | const seen = { reqs: [], sockets: [], sent: [] }; |
| 288 | const 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. |
| 305 | process.env.DAIMOND_PORT = String(PORT); |
| 306 | process.env.DAIMOND_APP = 'http://localhost:' + PORT; |
| 307 | process.env.DAIMOND_SCRATCH = WORK; |
| 308 | const { open } = await import('./harness.mjs'); |
| 309 | const s = await open({ name: 'mailtunnel', connect: false, route: record }); |
| 310 | const { page, errs } = s; |
| 311 | |
| 312 | try { |
| 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 | |
| 541 | console.log(`\nverify_mailtunnel: ${ok.length} ok, ${bad.length} failed, ${skip.length} skipped.`); |
| 542 | if (bad.length) { bad.forEach((b) => console.log(' FAILED: ' + b)); process.exit(1); } |
| 543 | process.exit(0); |