π€ Found and fixed. Live at 2f2a3d9.
The cause
The step-level error reporting did its job on the first attempt:
> Could not build your keys. DataError: Data provided to an operation does not > meet requirements
"Build your keys" is the only step that turns a scalar into a keypair, and that is where the unportable thing was. WebCrypto has no door for importing a private scalar, 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 Safari's importer.
That is also the answer to why I could not reproduce it: I was testing against a WebKit that is ahead of the shipping browser. test/webkit.test.mjs now records that in its own diagnostic output β "this WebKit accepted the omitted-public-half PKCS#8" β so the next person does not spend an hour concluding the same thing.
The fix
Compute the point ourselves and import a JWK carrying d, x and y, which every engine accepts. That meant writing P-256 scalar multiplication: internal/contra/static/crypto/p256math.js, about 150 lines of BigInt arithmetic, Jacobian coordinates so the ladder needs one inversion rather than 256.
BigInt is the right trade here and the wrong one in argon2id.js: this runs twice per ceremony beside a two-second Argon2id, where that runs two hundred thousand times. The ladder is not constant time and the comment says so plainly rather than implying otherwise β every alternative a browser offers, WebCrypto's own PKCS#8 path included, has the same property.
The golden vectors are what made this safe to write. They already carried seed β public key for three seeds, generated by OpenSSL, including the one that forces the rejection branch β so an elliptic-curve implementation written from scratch is checked against arithmetic nobody in this repo did. It passed on the first run, which is the vectors earning their keep rather than me being careful.
The other candidate, fixed because it was free
An absent HKDF salt was passed as an empty Uint8Array. RFC 5869 Β§2.2 defines an absent salt as HashLen zeros, and HMAC pads short keys with zeros, so the two are byte-identical β the vectors prove it, having been generated with a zero-length salt and still passing after the change. It is now 32 explicit zero bytes, because an empty BufferSource is precisely the kind of edge case an engine is entitled to refuse, and this derivation is where one just did.
Verified
- The golden vectors pass in real WebKit, through the new JWK path, including the rejecting seed.
- The full crypto sequence of step 2 runs clean in WebKit against the live canary: params, Argon2id, HKDF with an absent salt,
deriveP256, HKDF salted, the recovery branch, and a signature that verifies. - Step 1 completes in WebKit on the deployed build.
make cigreen; all four Chromium walks green.
What this leaves
make test-webkit is new and deliberately not in make ci: it needs Playwright, and a gate that cannot run everywhere is a gate that teaches people to ignore red. It fails if p256.js ever imports a PKCS#8 again.
The wider lesson is the one worth keeping. Chromium, node and Playwright's WebKit are three rigs and one opinion β none of them is Safari, and Safari is the browser DESIGN.md already singles out as the constraint (64 MiB of Argon2id memory exists for iOS Safari's WASM heap). Until a real Safari is in the loop somewhere, the honest position is that this product is tested on engines that are not the risky one.
Closing this. Reopen if it recurs.