web: compute the public point ourselves, because Safari will not
Signup died in "build your keys" with a bare DataError. That step does two things no other step does, and both are places engines differ. The first was the culprit. Deriving the login keypair means turning a scalar into a keypair, and WebCrypto has no door for that — so we built a 67-byte PKCS#8 with the optional public half omitted and let the browser recompute the point. Chromium recomputes it. Node recomputes it. Playwright's WebKit recomputes it, which is exactly why three separate rigs called this fine while a real Safari refused it: the field is optional in the ASN.1 and mandatory in practice to whoever wrote that importer. So the point is computed here now, in about 150 lines of BigInt arithmetic, and the key imported as a JWK carrying d, x and y — which every engine accepts. BigInt is the right trade: this runs twice per ceremony, beside an Argon2id that takes two seconds. The ladder is not constant time and the comment says so rather than implying otherwise. The golden vectors are what make this safe to write. They already carried seed to public key for three seeds, generated by OpenSSL, so an EC implementation written from scratch at one in the morning is checked against arithmetic nobody here did — including the seed that forces the rejection branch. The second candidate is fixed too, since it was in the same step and cost nothing: an absent HKDF salt is now passed as 32 zero bytes rather than an empty array. RFC 5869 defines the two as identical and HMAC pads short keys with zeros, so the derived bytes do not move — the vectors prove it, having been generated with a zero-LENGTH salt and still passing. An empty BufferSource is simply the kind of edge case an engine may refuse, and this derivation is where one just did. test/webkit.test.mjs is new and runs the modules in real WebKit over a localhost server, because about:blank is not a secure context and crypto.subtle is not there at all. It pins the vectors through the new path and fails if p256.js ever imports a PKCS#8 again. Its diagnostic records the thing that made this hard to find: this WebKit accepts the shape Safari rejects. Not in `make ci` — it needs Playwright, and a gate that cannot run everywhere teaches people to ignore red. `make test-webkit`, before touching the crypto modules.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+367
−58
.gitignore+1 −0Makefile+14 −2internal/contra/static/crypto/kdf.js+16 −4internal/contra/static/crypto/p256.js+43 −50internal/contra/static/crypto/p256math.js+143 −0internal/contratest/jsmodules_test.go+3 −2test/webkit.test.mjs+147 −0
diff --git a/.gitignore b/.gitignore| index 163deec..8314bab 100644 |
| --- a/.gitignore |
| +++ b/.gitignore |
| @@ -9,3 +9,4 @@ |
| /contra.db |
| /contra.db-wal |
| /contra.db-shm |
| +/node_modules/ |
diff --git a/Makefile b/Makefile| index 88f413b..0133681 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -22,7 +22,7 @@ RELEASE_BIN := releases/$(APP)-$(RELEASE_GOOS)-$(RELEASE_GOARCH) |
| # make release VERSION=v0.1.0 |
| VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null) |
| -.PHONY: build release version-check test test-js vet fmt-check migration-check ci |
| +.PHONY: build release version-check test test-js test-webkit vet fmt-check migration-check ci |
| # build is the compile check: with more than one package matched, go |
| # build discards the output, so this catches a broken package without |
| @@ -96,7 +96,19 @@ test-js: |
| echo " This gate is not optional -- it is what proves the browser's"; \ |
| echo " crypto matches the server's. Install node, do not skip it."; \ |
| exit 1; } |
| - node --test test/*.test.mjs |
| + node --test test/crypto.test.mjs test/items.test.mjs test/qr.test.mjs test/kit.test.mjs |
| + |
| +# The browser crypto in WebKit, which is the engine this product can least |
| +# afford to be wrong about and the one the Chromium rig never touches. |
| +# |
| +# NOT part of `make ci`, on purpose. It needs Playwright, which not every |
| +# machine will have, and a gate that cannot run everywhere is a gate that |
| +# teaches people to ignore red. It skips visibly rather than silently, and it |
| +# is what you run before trusting a change to the crypto modules. |
| +# |
| +# npm install playwright && make test-webkit |
| +test-webkit: |
| + node --test test/webkit.test.mjs |
| vet: |
| go vet ./... |
diff --git a/internal/contra/static/crypto/kdf.js b/internal/contra/static/crypto/kdf.js| index 7b9c7ab..69bdfc5 100644 |
| --- a/internal/contra/static/crypto/kdf.js |
| +++ b/internal/contra/static/crypto/kdf.js |
| @@ -57,16 +57,28 @@ export function checkParams(p) { |
| } |
| // hkdf is RFC 5869 extract-then-expand over SHA-256 — the one mode this |
| -// protocol uses. An absent salt is a zero-length salt, which RFC 5869 turns |
| -// into HashLen zero bytes; WebCrypto does the same, so an empty Uint8Array |
| -// is the honest spelling of "no salt". |
| +// protocol uses. |
| +// |
| +// An absent salt is passed as 32 zero bytes rather than as an empty array. |
| +// RFC 5869 §2.2 defines the two as the same thing ("if not provided, it is |
| +// set to a string of HashLen zeros"), and HMAC pads any key shorter than its |
| +// block size with zeros, so the derived bytes are identical either way — the |
| +// golden vectors prove it, since they were generated with a zero-LENGTH salt |
| +// and still pass. |
| +// |
| +// The reason to prefer the explicit form is engine behaviour, not |
| +// correctness: an empty BufferSource is the kind of edge case a WebCrypto |
| +// implementation is entitled to refuse, and Safari refusing something in this |
| +// derivation is exactly the failure that sent us looking. |
| +const ZERO_SALT = new Uint8Array(32); |
| + |
| export async function hkdf(ikm, salt, info, length = 32) { |
| const key = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]); |
| const bits = await crypto.subtle.deriveBits( |
| { |
| name: "HKDF", |
| hash: "SHA-256", |
| - salt: salt ?? new Uint8Array(0), |
| + salt: salt && salt.length ? salt : ZERO_SALT, |
| info: typeof info === "string" ? enc.encode(info) : info, |
| }, |
| key, |
diff --git a/internal/contra/static/crypto/p256.js b/internal/contra/static/crypto/p256.js| index 1c26b78..edd331b 100644 |
| --- a/internal/contra/static/crypto/p256.js |
| +++ b/internal/contra/static/crypto/p256.js |
| @@ -1,50 +1,40 @@ |
| // The deterministic P-256 login keypair, and the signed challenge. |
| // |
| -// Sign-in is a signature, not a verifier: the client derives this keypair |
| -// from the login branch and the server stores only the public half. A fully |
| -// compromised server therefore holds something that cannot decrypt, cannot |
| -// be replayed to impersonate, and adds no offline attack surface it did not |
| +// Sign-in is a signature, not a verifier: the client derives this keypair from |
| +// the login branch and the server stores only the public half. A fully |
| +// compromised server therefore holds something that cannot decrypt, cannot be |
| +// replayed to impersonate, and adds no offline attack surface it did not |
| // already have via the wraps. |
| +// |
| +// # Why the key is imported as a JWK |
| +// |
| +// There is no "import a private scalar" door in WebCrypto. The obvious trick |
| +// is a 67-byte PKCS#8 with the optional public half left out, which Chromium, |
| +// node and recent WebKit all accept by recomputing the point — and which |
| +// Safari rejects with a bare `DataError`, because the field is optional in |
| +// the ASN.1 and mandatory in practice to whoever wrote that importer. |
| +// |
| +// So the point is computed here (p256math.js) and the key imported as a JWK |
| +// carrying d, x and y, which every engine accepts. That cost about 150 lines |
| +// of arithmetic and bought a signup that works in the browser this product |
| +// can least afford to be wrong about. |
| import { concat } from "./bytes.js"; |
| import { hkdf, LABEL_EC_P256 } from "./kdf.js"; |
| - |
| -// The P-256 group order n, big-endian. Used for the range check only — a |
| -// byte-wise comparison, so nothing here needs a bignum. |
| -const N = new Uint8Array([ |
| - 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, |
| - 0xff, 0xff, 0xff, 0xff, 0xff, 0xbc, 0xe6, 0xfa, 0xad, 0xa7, 0x17, |
| - 0x9e, 0x84, 0xf3, 0xb9, 0xca, 0xc2, 0xfc, 0x63, 0x25, 0x51, |
| -]); |
| - |
| -// PKCS#8 PrivateKeyInfo for an EC key on prime256v1, with the public half |
| -// omitted — WebCrypto recomputes it. Fixed length, so the whole prefix is a |
| -// constant and the scalar is simply appended. |
| -const PKCS8_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, |
| -]); |
| +import { N, publicKeyFor } from "./p256math.js"; |
| // inRange is the rejection test: a candidate is usable only when |
| // 1 <= d <= n-1. A uniform 32-byte string is not a uniform scalar mod n, and |
| // clamping instead of rejecting would bias the key. |
| function inRange(c) { |
| - let nonzero = false; |
| - for (const b of c) { |
| - if (b) { |
| - nonzero = true; |
| - break; |
| - } |
| - } |
| - if (!nonzero) return false; |
| - for (let i = 0; i < 32; i++) { |
| - if (c[i] < N[i]) return true; |
| - if (c[i] > N[i]) return false; |
| - } |
| - return false; // exactly n |
| + let d = 0n; |
| + for (const b of c) d = (d << 8n) | BigInt(b); |
| + return d > 0n && d < N; |
| } |
| +const b64url = (b) => btoa(String.fromCharCode(...b)) |
| + .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| + |
| function b64urlToBytes(s) { |
| const b64 = s.replace(/-/g, "+").replace(/_/g, "/"); |
| const raw = atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, "=")); |
| @@ -53,10 +43,10 @@ function b64urlToBytes(s) { |
| return out; |
| } |
| -// deriveP256 turns a 32-byte seed into the keypair, rejection-sampling |
| -// exactly as the Go and Swift sides do. A rejection is a ~2^-32 event, so |
| -// the loop past i=0 is only ever reached by the searched seed in the vector |
| -// file — which is why that seed exists. |
| +// deriveP256 turns a 32-byte seed into the keypair, rejection-sampling exactly |
| +// as the Go and Swift sides do. A rejection is a ~2^-32 event, so the loop |
| +// past i=0 is only ever reached by the searched seed in the vector file — |
| +// which is why that seed exists. |
| export async function deriveP256(seed) { |
| for (let i = 0; i < 256; i++) { |
| const info = new Uint8Array(LABEL_EC_P256.length + 1); |
| @@ -66,27 +56,30 @@ export async function deriveP256(seed) { |
| const candidate = await hkdf(seed, null, info, 32); |
| if (!inRange(candidate)) continue; |
| + const { x, y } = publicKeyFor(candidate); |
| const privateKey = await crypto.subtle.importKey( |
| - "pkcs8", |
| - concat(PKCS8_PREFIX, candidate), |
| + "jwk", |
| + { |
| + kty: "EC", crv: "P-256", ext: true, |
| + d: b64url(candidate), x: b64url(x), y: b64url(y), |
| + }, |
| { name: "ECDSA", namedCurve: "P-256" }, |
| - true, |
| + false, |
| ["sign"], |
| ); |
| - const jwk = await crypto.subtle.exportKey("jwk", privateKey); |
| - const publicKey = concat( |
| - new Uint8Array([0x04]), |
| - b64urlToBytes(jwk.x), |
| - b64urlToBytes(jwk.y), |
| - ); |
| - return { scalar: candidate, privateKey, publicKey, candidatesTried: i + 1 }; |
| + return { |
| + scalar: candidate, |
| + privateKey, |
| + publicKey: concat(new Uint8Array([0x04]), x, y), |
| + candidatesTried: i + 1, |
| + }; |
| } |
| throw new Error("contra: no valid P-256 scalar in 256 candidates"); |
| } |
| // newP256 generates a fresh keypair and returns the raw scalar alongside the |
| -// SEC1 public point — for d_acct and for a vault's drop box, both of which |
| -// are random rather than derived from anything. |
| +// SEC1 public point — for d_acct and for a vault's drop box, both of which are |
| +// random rather than derived. |
| export async function newP256() { |
| const pair = await crypto.subtle.generateKey( |
| { name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); |
diff --git a/internal/contra/static/crypto/p256math.js b/internal/contra/static/crypto/p256math.js| new file mode 100644 |
| index 0000000..3d99d83 |
| --- /dev/null |
| +++ b/internal/contra/static/crypto/p256math.js |
| @@ -0,0 +1,143 @@ |
| +// P-256 scalar multiplication, so we never have to ask a browser to guess. |
| +// |
| +// # Why this exists |
| +// |
| +// Deriving the login keypair means turning a scalar into a keypair, and |
| +// WebCrypto has no "import a private scalar" door. The trick we used instead |
| +// was a 67-byte PKCS#8 with the public half omitted, which Chromium, node and |
| +// newer WebKit all accept by recomputing the point — and which Safari rejects |
| +// with a bare DataError, because the field is optional in the ASN.1 and |
| +// required in practice by whoever wrote that importer. |
| +// |
| +// A JWK carrying d, x AND y is accepted everywhere. It just means computing |
| +// the point ourselves, which is this file. |
| +// |
| +// BigInt is fine here: this runs about twice per ceremony, next to an |
| +// Argon2id that takes two seconds. The one place arithmetic had to be fast |
| +// (argon2id.js) pays for its 32-bit word pairs; this does not. |
| +// |
| +// Verified against the golden vectors, which carry seed → public key for |
| +// three seeds including one that forces the rejection branch. |
| + |
| +const P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn; |
| +const B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn; |
| +const GX = 0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296n; |
| +const GY = 0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5n; |
| + |
| +export const N = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n; |
| + |
| +const mod = (x) => ((x % P) + P) % P; |
| + |
| +// Fermat's little theorem: the curve's prime means x^(p-2) is x's inverse. |
| +// Slower than an extended Euclid and branch-free, which is the right trade |
| +// for something that touches a private scalar. |
| +function inverse(x) { |
| + let result = 1n; |
| + let base = mod(x); |
| + let e = P - 2n; |
| + while (e > 0n) { |
| + if (e & 1n) result = mod(result * base); |
| + base = mod(base * base); |
| + e >>= 1n; |
| + } |
| + return result; |
| +} |
| + |
| +// Points are Jacobian: (X, Y, Z) meaning (X/Z², Y/Z³). Z = 0 is infinity. |
| +// Jacobian rather than affine so the whole ladder needs one inversion at the |
| +// end instead of one per step. |
| +const INFINITY = { x: 0n, y: 1n, z: 0n }; |
| + |
| +function double(p) { |
| + if (p.z === 0n) return INFINITY; |
| + const delta = mod(p.z * p.z); |
| + const gamma = mod(p.y * p.y); |
| + const beta = mod(p.x * gamma); |
| + // a = -3 on this curve, which is what collapses alpha to this form. |
| + const alpha = mod(3n * mod(p.x - delta) * mod(p.x + delta)); |
| + const x = mod(alpha * alpha - 8n * beta); |
| + const z = mod(mod(p.y + p.z) * mod(p.y + p.z) - gamma - delta); |
| + const y = mod(alpha * mod(4n * beta - x) - 8n * mod(gamma * gamma)); |
| + return { x, y, z }; |
| +} |
| + |
| +function add(p, q) { |
| + if (p.z === 0n) return q; |
| + if (q.z === 0n) return p; |
| + |
| + const z1z1 = mod(p.z * p.z); |
| + const z2z2 = mod(q.z * q.z); |
| + const u1 = mod(p.x * z2z2); |
| + const u2 = mod(q.x * z1z1); |
| + const s1 = mod(p.y * q.z * z2z2); |
| + const s2 = mod(q.y * p.z * z1z1); |
| + |
| + if (u1 === u2) { |
| + // Same x: either the same point (double it) or a point and its negation |
| + // (which sum to infinity). Getting this wrong is the classic way an |
| + // otherwise correct implementation returns garbage on one input in 2^128. |
| + return s1 === s2 ? double(p) : INFINITY; |
| + } |
| + |
| + const h = mod(u2 - u1); |
| + const i = mod(4n * h * h); |
| + const j = mod(h * i); |
| + const r = mod(2n * (s2 - s1)); |
| + const v = mod(u1 * i); |
| + const x = mod(r * r - j - 2n * v); |
| + const y = mod(r * mod(v - x) - 2n * s1 * j); |
| + const z = mod(mod(mod(p.z + q.z) * mod(p.z + q.z) - z1z1 - z2z2) * h); |
| + return { x, y, z }; |
| +} |
| + |
| +function toAffine(p) { |
| + if (p.z === 0n) throw new Error("contra: the point at infinity has no affine form"); |
| + const zInv = inverse(p.z); |
| + const zInv2 = mod(zInv * zInv); |
| + return { x: mod(p.x * zInv2), y: mod(p.y * zInv2 * zInv) }; |
| +} |
| + |
| +function bytesToBigInt(b) { |
| + let n = 0n; |
| + for (const byte of b) n = (n << 8n) | BigInt(byte); |
| + return n; |
| +} |
| + |
| +function bigIntTo32(n) { |
| + const out = new Uint8Array(32); |
| + for (let i = 31; i >= 0; i--) { |
| + out[i] = Number(n & 0xffn); |
| + n >>= 8n; |
| + } |
| + return out; |
| +} |
| + |
| +// publicKeyFor returns the affine coordinates of d·G as 32-byte big-endian |
| +// values — x and y, ready for a JWK. |
| +// |
| +// The ladder is a plain left-to-right double-and-add. It is not constant |
| +// time, and that is worth stating plainly rather than implying otherwise: the |
| +// scalar it multiplies is derived from the passphrase, so a local attacker |
| +// who could time this precisely would learn something about it. Every |
| +// alternative available in a browser has the same property, WebCrypto's own |
| +// PKCS#8 path included, and the threat model that matters here — a hostile |
| +// server, a stolen bucket — does not include one. |
| +export function publicKeyFor(scalar) { |
| + const d = bytesToBigInt(scalar); |
| + if (d <= 0n || d >= N) throw new Error("contra: scalar out of range"); |
| + |
| + let result = INFINITY; |
| + const base = { x: GX, y: GY, z: 1n }; |
| + for (let i = 255; i >= 0; i--) { |
| + result = double(result); |
| + if ((d >> BigInt(i)) & 1n) result = add(result, base); |
| + } |
| + const { x, y } = toAffine(result); |
| + |
| + // A sanity check that costs one multiplication and catches a whole class of |
| + // silent arithmetic error: the point must actually be on the curve. |
| + if (mod(y * y) !== mod(x * x * x - 3n * x + B)) { |
| + throw new Error("contra: derived point is not on the curve"); |
| + } |
| + return { x: bigIntTo32(x), y: bigIntTo32(y) }; |
| +} |
diff --git a/internal/contratest/jsmodules_test.go b/internal/contratest/jsmodules_test.go| index 73761ab..0936de2 100644 |
| --- a/internal/contratest/jsmodules_test.go |
| +++ b/internal/contratest/jsmodules_test.go |
| @@ -32,10 +32,11 @@ var jsHighWater = map[string]int{ |
| "bytes.js": 61, |
| "code.js": 165, |
| "items.js": 97, |
| - "kdf.js": 117, |
| + "kdf.js": 129, |
| "kit.js": 203, |
| "net.js": 35, |
| - "p256.js": 114, |
| + "p256.js": 107, |
| + "p256math.js": 143, |
| "pdf.js": 180, |
| "qr.js": 297, |
| "qrgf.js": 59, |
diff --git a/test/webkit.test.mjs b/test/webkit.test.mjs| new file mode 100644 |
| index 0000000..ae2b624 |
| --- /dev/null |
| +++ b/test/webkit.test.mjs |
| @@ -0,0 +1,147 @@ |
| +// The browser crypto, in WebKit. |
| +// |
| +// Safari is the engine this product can least afford to be wrong about — |
| +// DESIGN.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. |