// The browser crypto, in WebKit. // // Safari is the engine this product can least afford to be wrong about — // PROTOCOL.md pins 64 MiB of Argon2id memory specifically for iOS Safari's WASM // heap — and it is the one Chromium-based CI never touches. This suite exists // because a signup that worked in Chromium, in node, and in Playwright's // WebKit still failed in a real Safari, on an operation none of those three // objected to. // // It is skipped when Playwright is not installed, and that skip is visible // rather than silent: `make test-webkit` says so. It is not part of `make ci` // for that reason — a gate that cannot run everywhere is a gate that teaches // people to ignore red. // // npm install playwright && node --test test/webkit.test.mjs import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import { createServer } from "node:http"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; const here = dirname(fileURLToPath(import.meta.url)); const cryptoDir = join(here, "..", "internal", "contra", "static", "crypto"); const V = JSON.parse(readFileSync(join(here, "..", "vectors", "contra-v1.json"), "utf8")); let webkit; try { ({ webkit } = await import("playwright")); } catch { webkit = null; } let browser; let page; let server; // WebCrypto only exists in a secure context, and about:blank is not one — // crypto.subtle is simply undefined there, which makes every probe against it // a meaningless TypeError. http://localhost IS potentially trustworthy by // spec, so the modules are served from one: no network, no deployment, and // the code under test is the code on disk. before(async () => { if (!webkit) return; server = createServer((req, res) => { const name = (req.url || "").replace(/^\/|\?.*$/g, ""); if (!/^[a-z0-9]+\.js$/.test(name)) { res.writeHead(404).end(); return; } try { const body = readFileSync(join(cryptoDir, name)); res.writeHead(200, { "Content-Type": "text/javascript" }).end(body); } catch { res.writeHead(404).end(); } }); await new Promise((r) => server.listen(0, "127.0.0.1", r)); browser = await webkit.launch(); page = await browser.newPage(); await page.goto(`http://localhost:${server.address().port}/`); const secure = await page.evaluate(() => window.isSecureContext && !!crypto.subtle); assert.ok(secure, "the page is not a secure context, so WebCrypto is absent"); }); after(async () => { if (browser) await browser.close(); if (server) await new Promise((r) => server.close(r)); }); function skipUnlessWebKit(t) { if (!webkit) { t.skip("playwright is not installed: npm install playwright"); return true; } return false; } // Run fn inside the page against the real module graph, imported the way a // browser imports it — relative specifiers and all. async function inPage(fn, arg) { return page.evaluate(async ({ fnSource, arg }) => { const mod = await import("/p256.js"); return (0, eval)(`(${fnSource})`)(mod, arg); }, { fnSource: fn.toString(), arg }); } // The regression this whole file exists for. // // Safari rejects a PKCS#8 EC private key whose optional public half is // omitted — the shape deriveP256 used to build. Chromium, node and // Playwright's newer WebKit all accept it by recomputing the point, which is // why nothing caught it until a real Safari refused a real signup. test("WebKit refuses the PKCS#8 shape we used to rely on", async (t) => { if (skipUnlessWebKit(t)) return; const outcome = await page.evaluate(async () => { const prefix = new Uint8Array([ 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20, ]); const pkcs8 = new Uint8Array(prefix.length + 32); pkcs8.set(prefix); pkcs8.set(new Uint8Array(32).fill(9), prefix.length); try { await crypto.subtle.importKey("pkcs8", pkcs8, { name: "ECDSA", namedCurve: "P-256" }, true, ["sign"]); return "accepted"; } catch (e) { return `${e.name}`; } }); // Playwright's WebKit is ahead of shipping Safari and accepts it. Either // answer is fine; what must never happen is us depending on it again. t.diagnostic(`this WebKit ${outcome} the omitted-public-half PKCS#8`); const source = readFileSync(join(cryptoDir, "p256.js"), "utf8"); assert.ok(!source.includes("PKCS8_PREFIX"), "p256.js is importing a PKCS#8 again — that is the shape Safari refuses"); assert.ok(source.includes('"jwk"'), "p256.js should import a full JWK, which every engine accepts"); }); test("the login keypair matches the golden vectors in WebKit", async (t) => { if (skipUnlessWebKit(t)) return; for (const c of V.p256_from_seed.cases) { const got = await inPage(async (mod, seedHex) => { const bytes = new Uint8Array( seedHex.match(/../g).map((h) => parseInt(h, 16))); const k = await mod.deriveP256(bytes); const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); return { d: hex(k.scalar), pub: hex(k.publicKey), tried: k.candidatesTried }; }, c.seed_hex); assert.equal(got.d, c.d_hex, `${c.name}: scalar`); assert.equal(got.pub, c.public_uncompressed_hex, `${c.name}: public key`); assert.equal(got.tried, c.candidates_tried, `${c.name}: candidates tried`); } }); // The absent HKDF salt — the other candidate in that same step — needs no // test of its own here. deriveP256 calls hkdf on every candidate it tries, so // a WebKit that refused our salt could not have produced the vectors' public // keys above at all. The authoritative HKDF check is test/crypto.test.mjs.