Passkeys (WebAuthn)
Passkeys are Phaze’s first-class passwordless login, and the textbook case for the Rust runtime: WebAuthn verification is ECDSA-signature-verify + SHA-256 + CBOR/COSE decode — genuinely crypto- and parsing-heavy, sub-millisecond, exactly the work that justifies a Rust worker. The browser does only the light ceremony; the server does all verification.
The crypto floor — phaze::crypto::p256
Section titled “The crypto floor — phaze::crypto::p256”phaze-crypto ships ES256 with the WebAuthn shapes baked in:
Signature::from_der— WebAuthn assertion signatures are ASN.1 DER (not raw r‖s)- ES256 = SHA-256 prehash —
verifyhashes internally; the signed message isauthenticatorData ‖ SHA-256(clientDataJSON) cose_key_to_public— parses the COSE_Key (EC2 P-256 x/y) stored at registration
It’s pure-Rust RustCrypto (p256 + ciborium), verified to compile to wasm32 (workerd) — so the whole flow runs in-framework, no webauthn-rs dependency.
The WebAuthn logic built on that primitive — parse_attestation_object (register) and verify_assertion (login) — lives in the phaze-auth crate (phaze::auth::webauthn, re-exported via phaze with features = ["auth"]), alongside the TOTP and session cores. It’s pure-Rust with no Worker dependency, so it unit-tests natively (cargo test); the Phaze Auth library covers the crate. The handlers below wire it to KV/D1 in the consumer’s rust-api/src/lib.rs.
The four endpoints
Section titled “The four endpoints”POST /api/auth/passkey/register/start → PublicKeyCredentialCreationOptions (challenge, rp, user, ES256 + RS256)POST /api/auth/passkey/register/finish → verify attestation → store the credentialPOST /api/auth/passkey/login/start → PublicKeyCredentialRequestOptions (challenge)POST /api/auth/passkey/login/finish → verify assertion → mint a session- Challenges live in KV (
webauthn:chal:*, ~5-min TTL, single-use — deleted on finish). - Credentials live in D1:
credentials(cred_id PK, user_id, public_key, sign_count, transports, aaguid).public_keyis the COSE_Key bytes;cose_key_to_publicreads it at login. - A good
login/finishmints the session —__Secure-session; Domain=neuralkit.ai; HttpOnly; Secure; SameSite=Laxin prod (session; HttpOnly; SameSite=Laxfor local httpwrangler dev; the__Secure-prefix needs HTTPS). The name is env-driven from the origin scheme, matching the restricted-route guard’sgetSessionAndUserread.
Verification — the security-critical core
Section titled “Verification — the security-critical core”The two parsing/verify steps are isolated, unit-tested modules built on p256:
verify_assertion (login) checks, in order:
- clientDataJSON —
type == "webauthn.get",challengematches (constant-time),originmatches - authenticatorData —
rpIdHash == SHA-256(rpId), user-present flag set - the signature — ES256 (DER) over
authenticatorData ‖ SHA-256(clientDataJSON)via the stored COSE key - sign-count — regression rejected (clone detection; skipped when either side reports 0)
…and returns the new sign-count to persist. Every failure is a 401.
parse_attestation_object (registration) decodes the CBOR attestationObject, requires the AT flag, and slices the attestedCredentialData (aaguid ‖ credIdLen ‖ credId ‖ COSE key) → the credential to store.
Domain scoping — RP ID
Section titled “Domain scoping — RP ID”The RP ID is the registrable parent domain (neuralkit.ai), not a subdomain. A passkey scoped to neuralkit.ai works for assertions from neuralkit.ai and dash.neuralkit.ai (the RP ID must be a registrable suffix of the origin) — one passkey, every app. This mirrors the Domain=.neuralkit.ai cookie scope: one passkey, one session, all subdomains.
RP ID, expected origin, and cookie domain are env-driven — localhost / http://localhost:8788 for local wrangler dev, the production domains via vars — so the same code runs in both without a rebuild.
The browser half
Section titled “The browser half”navigator.credentials.create() / .get() run in an ordinary Phaze component via the createPasskey / getPasskey ceremony helpers from @madenowhere/phaze-auth/browser — they base64url-encode the challenge/IDs (the package’s b64urlToBuf / bufToB64url), call the authenticator, and return the finish payload to post back. No crypto module ships to the browser — the authenticator and OS do the signing; the helpers just relay, and Rust does the verification. See Phaze Auth → Browser ceremony for the helper surface and the end-to-end page wiring.