carlos / contra Public

Signup fails on Safari with a WebCrypto DataError β€” not reproduced in WebKit 2336 Issue closed

πŸ€– Open bug, not reproduced. Reported by Paul from Safari on macOS against the canary, 2026-09-02.

What happened

Signup with paul@tito.io. After pressing Create account, Safari's password sheet appeared and the page showed:

> Data provided to an operation does not meet requirements

Nothing else happened. That string is WebKit's wording for a WebCrypto DataError, so it is a DOMException from crypto.subtle β€” but by itself it names none of the dozen operations in the signup ceremony that could have raised it.

The console screenshot alongside it is entirely 1Password's Safari extension β€” cache.agilebits.com 404s and safari-web-extension:// frame-origin blocks. None of it is ours, and none of it is the error.

What I ruled out

Playwright's WebKit 2336 completes this signup, headless, against the live canary. I checked the specific operation I had already flagged as the least portable thing we do β€” importing a raw P-256 scalar as a 67-byte PKCS#8 with the public half omitted β€” and WebKit accepts it.

One thing does fail in WebKit with exactly the reported message: importing a JWK that carries d but no x/y. We do not do that anywhere, so it is not the cause β€” but it does confirm the message is a WebKit DataError and narrows what shape of input produces one.

The passkey sheet is almost certainly Safari's Automatic Strong Password offer, triggered by autocomplete="new-password" on the signup form. We never call navigator.credentials.

What I changed, since I could not reproduce it

The failure now says which step it died on, and puts the whole error object in the console. An opaque DOMException message is a bad thing to show anyone, and it is the difference between a bug report and a guess.

A worse bug turned up beside it. The Account Key was cached with a bare accountKeys(storage).set(...), after the account had been created and before the kit was shown. Safari refuses localStorage outright in some configurations, and merely touching window.localStorage throws there β€” so that line could take down the one screen on which the recovery code is ever displayed, leaving an account that can never be recovered, for the sake of an optimisation. Caching is best-effort now and the access is probed rather than assumed.

A 409 gets its own message. If the first attempt got as far as POST /auth/signup returning 201 and then failed at sign-in or provisioning, the account exists and the kit was never shown β€” and the retry lands on a conflict. That is a plausible reading of "nothing else happened", and it now says so in words instead of returning a status code.

Live at c0596f7.

What would settle it

A retry on the canary. The message will now be one of:

  • "There is already an account for that address…" β€” the first attempt created it, and the failure was after /auth/signup. That also means an account exists with no kit, which is its own thing to fix.
  • "Failed while &lt;step&gt;. &lt;Name&gt;: &lt;message&gt;" β€” and the console line CONTRA signup failed while <step> <error> carries the stack.

The gap this sits in

The browser rig is Chromium, and Playwright's WebKit is not Safari β€” it is WebKit at a version that is often ahead of the shipping browser, without Safari's extension surface. DESIGN.md picks 64 MiB for iOS Safari's WASM heap, so Safari is the engine this product can least afford to be wrong about, and it is the one nothing in CI exercises.

Playwright is now installed here, so a WebKit walk alongside the Chromium one is cheap to add. It would not have caught this β€” it passes β€” but the class of bug it does catch is the one we are most exposed to.

πŸ€– 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 ci green; 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.