carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

1// The browser crypto, in WebKit.
2//
3// Safari is the engine this product can least afford to be wrong about —
4// PROTOCOL.md pins 64 MiB of Argon2id memory specifically for iOS Safari's WASM
5// heap — and it is the one Chromium-based CI never touches. This suite exists
6// because a signup that worked in Chromium, in node, and in Playwright's
7// WebKit still failed in a real Safari, on an operation none of those three
8// objected to.
9//
10// It is skipped when Playwright is not installed, and that skip is visible
11// rather than silent: `make test-webkit` says so. It is not part of `make ci`
12// for that reason — a gate that cannot run everywhere is a gate that teaches
13// people to ignore red.
14//
15// npm install playwright && node --test test/webkit.test.mjs
16
17import { test, before, after } from "node:test";
18import assert from "node:assert/strict";
19import { createServer } from "node:http";
20import { readFileSync } from "node:fs";
21import { fileURLToPath } from "node:url";
22import { dirname, join } from "node:path";
23
24const here = dirname(fileURLToPath(import.meta.url));
25const cryptoDir = join(here, "..", "internal", "contra", "static", "crypto");
26const V = JSON.parse(readFileSync(join(here, "..", "vectors", "contra-v1.json"), "utf8"));
27
28let webkit;
29try {
30 ({ webkit } = await import("playwright"));
31} catch {
32 webkit = null;
33}
34
35let browser;
36let page;
37let server;
38
39// WebCrypto only exists in a secure context, and about:blank is not one —
40// crypto.subtle is simply undefined there, which makes every probe against it
41// a meaningless TypeError. http://localhost IS potentially trustworthy by
42// spec, so the modules are served from one: no network, no deployment, and
43// the code under test is the code on disk.
44before(async () => {
45 if (!webkit) return;
46 server = createServer((req, res) => {
47 const name = (req.url || "").replace(/^\/|\?.*$/g, "");
48 if (!/^[a-z0-9]+\.js$/.test(name)) {
49 res.writeHead(404).end();
50 return;
51 }
52 try {
53 const body = readFileSync(join(cryptoDir, name));
54 res.writeHead(200, { "Content-Type": "text/javascript" }).end(body);
55 } catch {
56 res.writeHead(404).end();
57 }
58 });
59 await new Promise((r) => server.listen(0, "127.0.0.1", r));
60 browser = await webkit.launch();
61 page = await browser.newPage();
62 await page.goto(`http://localhost:${server.address().port}/`);
63 const secure = await page.evaluate(() => window.isSecureContext && !!crypto.subtle);
64 assert.ok(secure, "the page is not a secure context, so WebCrypto is absent");
65});
66
67after(async () => {
68 if (browser) await browser.close();
69 if (server) await new Promise((r) => server.close(r));
70});
71
72function skipUnlessWebKit(t) {
73 if (!webkit) {
74 t.skip("playwright is not installed: npm install playwright");
75 return true;
76 }
77 return false;
78}
79
80// Run fn inside the page against the real module graph, imported the way a
81// browser imports it — relative specifiers and all.
82async function inPage(fn, arg) {
83 return page.evaluate(async ({ fnSource, arg }) => {
84 const mod = await import("/p256.js");
85 return (0, eval)(`(${fnSource})`)(mod, arg);
86 }, { fnSource: fn.toString(), arg });
87}
88
89// The regression this whole file exists for.
90//
91// Safari rejects a PKCS#8 EC private key whose optional public half is
92// omitted — the shape deriveP256 used to build. Chromium, node and
93// Playwright's newer WebKit all accept it by recomputing the point, which is
94// why nothing caught it until a real Safari refused a real signup.
95test("WebKit refuses the PKCS#8 shape we used to rely on", async (t) => {
96 if (skipUnlessWebKit(t)) return;
97
98 const outcome = await page.evaluate(async () => {
99 const prefix = new Uint8Array([
100 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48,
101 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03,
102 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20,
103 ]);
104 const pkcs8 = new Uint8Array(prefix.length + 32);
105 pkcs8.set(prefix);
106 pkcs8.set(new Uint8Array(32).fill(9), prefix.length);
107 try {
108 await crypto.subtle.importKey("pkcs8", pkcs8,
109 { name: "ECDSA", namedCurve: "P-256" }, true, ["sign"]);
110 return "accepted";
111 } catch (e) {
112 return `${e.name}`;
113 }
114 });
115
116 // Playwright's WebKit is ahead of shipping Safari and accepts it. Either
117 // answer is fine; what must never happen is us depending on it again.
118 t.diagnostic(`this WebKit ${outcome} the omitted-public-half PKCS#8`);
119 const source = readFileSync(join(cryptoDir, "p256.js"), "utf8");
120 assert.ok(!source.includes("PKCS8_PREFIX"),
121 "p256.js is importing a PKCS#8 again — that is the shape Safari refuses");
122 assert.ok(source.includes('"jwk"'),
123 "p256.js should import a full JWK, which every engine accepts");
124});
125
126test("the login keypair matches the golden vectors in WebKit", async (t) => {
127 if (skipUnlessWebKit(t)) return;
128
129 for (const c of V.p256_from_seed.cases) {
130 const got = await inPage(async (mod, seedHex) => {
131 const bytes = new Uint8Array(
132 seedHex.match(/../g).map((h) => parseInt(h, 16)));
133 const k = await mod.deriveP256(bytes);
134 const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
135 return { d: hex(k.scalar), pub: hex(k.publicKey), tried: k.candidatesTried };
136 }, c.seed_hex);
137
138 assert.equal(got.d, c.d_hex, `${c.name}: scalar`);
139 assert.equal(got.pub, c.public_uncompressed_hex, `${c.name}: public key`);
140 assert.equal(got.tried, c.candidates_tried, `${c.name}: candidates tried`);
141 }
142});
143
144// The absent HKDF salt — the other candidate in that same step — needs no
145// test of its own here. deriveP256 calls hkdf on every candidate it tries, so
146// a WebKit that refused our salt could not have produced the vectors' public
147// keys above at all. The authoritative HKDF check is test/crypto.test.mjs.
148