Skip to content

Spike: wasm32 crypto and WebAuthn on Workers - #23

Merged
ParallelEntrepreneur merged 4 commits into
mainfrom
spike/wasm-compile
Sep 6, 2026
Merged

ParallelEntrepreneur merged 4 commits into
mainfrom
spike/wasm-compile

Conversation

@ParallelEntrepreneur

Copy link
Copy Markdown
Contributor

Closes #2. Time-boxed spike, four questions answered with evidence. Not to be merged (spike output only).

Q1 — does webauthn-rs main build for wasm32? No.

master@be696b7 (2026-06-02, still versioned 0.5.5): cargo build --target wasm32-unknown-unknown fails in openssl-sys v0.9.117; cargo tree -i openssl-sys shows openssl as a hard dependency of webauthn-rs-core and webauthn-attestation-ca. No feature disables it; default-features = false does not help.

Q2 — pure-Rust verification path. Adopted for passkeys.

~340 lines on webauthn-rs-proto 0.5.5 (wire types) + ciborium/coset (CBOR/COSE) + p256/sha2 (ES256), scoped to registration + assertion with attestation none accepted and other formats stored unverified. Proven in wrangler dev: the recorded passkey fixture verifies (verified:true, sign_count:2); tampered signature, wrong challenge, wrong origin and replay are rejected (native tests + wasm run). The fixture is a software authenticator (no hardware key on the spike machine) with real ES256 math and spec-shaped bytes — limitation recorded in the ADR.

Q3 — openidconnect through the harness HttpClient port. Works.

openidconnect 4.0.1 / oauth2 5.0.0 build to wasm32; oauth2's AsyncHttpClient is implemented over the port (copied verbatim from the harness), which runs over worker::Fetch. Inside wrangler dev: discovery runs live against the real Google document; token exchange and RS256 ID-token verification (signature, issuer, audience, expiry, nonce) run from recorded fixtures — no Google client credential exists on this machine, so recorded in the ADR. Integration finding for the harness: the port's Send-future bound requires a spawn_local + oneshot bridge over worker::Fetch.

Q4 — argon2 on Workers. Measured; m=19456/t=2/p=1 recommended.

8 Argon2id sets in wrangler dev (local workerd): 118–931 ms per 3×(hash+verify), external wall 0.12–0.93 s (full table in the ADR). Recommendation m=19456 KiB, t=2, p=1 (~40 ms hash+verify locally), three orders of magnitude under the paid 30 s CPU limit; free-tier 10 ms CPU cannot fit a single verify. Deployed staging not measured (no Cloudflare credentials) — re-measure in auth-password.

Hard constraint

cargo tree --target wasm32-unknown-unknown: openssl, openssl-sys, reqwest, mio absent from the tree; tokio present only via the worker SDK itself, zero from auth dependencies.

DoD all green: cargo fmt --all --check, cargo clippy --all-targets -- -D warnings, cargo test (10 tests), cargo build --target wasm32-unknown-unknown, worker-build --release. One commit per question, spike(auth): prefixed. Full answers and risks: docs/adr/0100-wasm-crypto-and-webauthn.md; how to reproduce: spikes/wasm-auth/README.md.

Closes #2

Workspace scaffold plus the spike crate skeleton. webauthn-rs master at
be696b79800bd1953df78e87d0215571733cc26f still has openssl/openssl-sys as
hard dependencies of webauthn-rs-core and webauthn-attestation-ca; the
wasm32 build fails in openssl-sys' build script and default-features =
false changes nothing. Evidence commands in spikes/wasm-auth/README.md.
Registration and assertion verification on webauthn-rs-proto wire types,
ciborium/coset for CBOR+COSE, p256 + sha2 for the crypto: rpId hash,
UP/UV flags, client-data checks, credential-id match, signature-counter
advance, ES256 over authenticatorData || sha256(clientDataJSON).
Attestation format none accepted, others stored unverified.

fixtures/passkey.json is recorded by examples/mint_passkey.rs, a
software authenticator with deterministic RFC 6979 signing (no hardware
authenticator on the spike machine). Native tests cover tampered
signature, wrong challenge, wrong origin and replay. The fixture
verifies inside wrangler dev (wasm) via POST /q2/verify-passkey; the
tampered variant returns verified:false.
…er dev

src/port.rs is the harness HttpClient port copied verbatim; the adapter
implements it over worker::Fetch with fixture interception on Google's
token and JWKS endpoints. oauth2's AsyncHttpClient is implemented over
the port (OidcHttpClient). In wrangler dev: discovery runs live against
the real Google document (issuer accounts.google.com, real token
endpoint); token exchange and RS256 ID-token verification (signature,
issuer, audience, expiry, nonce) run from fixtures minted by
examples/mint_id_token.rs because no Google client credential exists on
this machine.

Send-future finding: the port's async_trait requires Send futures while
worker handles are !Send; the adapter bridges via spawn_local plus a
Send oneshot channel. Recorded for the harness adapter.
POST /q4/argon2?m&t&p&n and GET /q4/argon2/matrix run Argon2id v19 in
the Worker and report internal (Date.now) and external wall time.
Measured 8 parameter sets in local workerd: 118-931 ms per 3x
(hash+verify), external 0.12-0.93 s. Recommendation: m=19456 KiB,
t=2, p=1 (OWASP minimal, ~40 ms per hash+verify locally); free tier
10 ms CPU cannot fit a single verify. Deployed staging not measured
(no Cloudflare credentials).

docs/adr/0100 records all four answers, the adopted passkey path
(webauthn-rs-proto + ciborium/coset + p256/sha2, sizes and risks),
the openidconnect adapter and its Send-future finding, and the
dependency proof: openssl/openssl-sys/reqwest/mio absent from the
wasm tree, tokio only via the worker SDK.
@ParallelEntrepreneur

Copy link
Copy Markdown
Contributor Author

Reviewed and verified independently: cargo fmt --check clean, clippy -D warnings clean, 10 tests pass, cargo build --target wasm32-unknown-unknown succeeds, and openssl, reqwest and mio are all absent from the wasm tree (0 matches each).

The ADR is unusually honest about what was proven versus recorded, which is what a spike is for. Three things to carry forward, none blocking:

1. The !Send bridge is already solved upstream. The ADR reports that the port's async_trait needs Send futures while every worker handle is !Send, and bridges with spawn_local plus a oneshot channel. The harness already handles this: factory0-runtime-cloudflare's FetchClient uses workers-rs's .into_send() (see crates/runtime-cloudflare/src/ports/http.rs). The spike copied the port into src/port.rs rather than depending on the crate, so it reinvented the bridge. Production auth-oidc should depend on factory0-runtime-cloudflare and use FetchClient directly; no port change is needed.

2. The chrono::Utc::now finding generalises. IdTokenVerifier::set_time_fn backed by worker::Date is exactly right, and it is the same class of bug as std::time::Instant::now(), which panicked on every request until Cratefield/harness#50 fixed it. Anything reaching for a clock on wasm32 must go through the Clock port or worker::Date.

3. The paid-plan finding is the one with consequences outside this repo. Argon2id at any sane parameters cannot fit the free tier's 10 ms CPU limit, so password login requires the paid Workers plan. Recorded here so it is not rediscovered during #19.

Merging: a spike's bar is a reproducible result and an honest write-up, and this clears both. The production follow-ups (challenge storage in D1 with delete-on-use, per-client origin allow-list, hardware-authenticator fixture, one live Google code exchange, JWKS caching) belong to #12, #13 and #14 and are listed in the ADR's risk sections.

@ParallelEntrepreneur
ParallelEntrepreneur merged commit 8c42890 into main Sep 6, 2026
@ParallelEntrepreneur
ParallelEntrepreneur deleted the spike/wasm-compile branch September 6, 2026 09:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Spike: compile webauthn-rs and openidconnect to wasm32 for Workers

1 participant