Skip to content

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.

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 prehashverify hashes internally; the signed message is authenticatorData ‖ 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.

POST /api/auth/passkey/register/start → PublicKeyCredentialCreationOptions (challenge, rp, user, ES256 + RS256)
POST /api/auth/passkey/register/finish → verify attestation → store the credential
POST /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_key is the COSE_Key bytes; cose_key_to_public reads it at login.
  • A good login/finish mints the session__Secure-session; Domain=neuralkit.ai; HttpOnly; Secure; SameSite=Lax in prod (session; HttpOnly; SameSite=Lax for local http wrangler dev; the __Secure- prefix needs HTTPS). The name is env-driven from the origin scheme, matching the restricted-route guard’s getSessionAndUser read.

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:

  1. clientDataJSONtype == "webauthn.get", challenge matches (constant-time), origin matches
  2. authenticatorDatarpIdHash == SHA-256(rpId), user-present flag set
  3. the signature — ES256 (DER) over authenticatorData ‖ SHA-256(clientDataJSON) via the stored COSE key
  4. 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.

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-drivenlocalhost / http://localhost:8788 for local wrangler dev, the production domains via vars — so the same code runs in both without a rebuild.

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.