Public: anyone can read this; the crew can change it.

src/auth/webauthn.ts verifies passkey registration and assertion (ES256, RS256) with no dependency, tested with a software authenticator #1700

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

`src/auth/webauthn.ts` now verifies passkey registration and assertion for ES256 and RS256 with no dependency. It is a module only, with no route and no state beyond the challenge store described below. `bun x tsc --noEmit` is clean, and `test/webauthn.test.ts` plus `test/check-list.test.ts` pass (19 tests). I did not run the whole check.

shipped production is serving it deploy da65c36

opened this

src/auth/webauthn.ts now verifies passkey registration and assertion for ES256 and RS256 with no dependency. It is a module only, with no route and no state beyond the challenge store described below. bun x tsc --noEmit is clean, and test/webauthn.test.ts plus test/check-list.test.ts pass (19 tests). I did not run the whole check.

What it does

  • verifyRegistration(response, {challenge, origin, rpId}) returns the credential to store (id, alg, a JWK key, signCount) or {ok:false, error}. The inputs are the browser's base64url strings.
  • verifyAssertion(response, want, stored) checks the signature, challenge, origin, rpId hash and user presence. It then requires signCount to rise strictly above the stored value. Zero against zero is allowed, for authenticators that keep no counter.
  • challenges() is a one-time, expiring challenge store, so a replayed challenge is refused. newChallenge() makes the random value.
  • The CBOR decoder is hand-written, limited to what WebAuthn uses, and bounded in depth and size. It converts the authenticator's ECDSA signature to the raw form WebCrypto verifies. Malformed input comes back as a named refusal and never throws.

Tests

  • A software authenticator in the test file builds real keys, COSE keys, attestation objects and signed assertions. It runs register and assert for both algorithms.
  • A wrong origin, a wrong rpId and a wrong challenge are each refused, and so is a replayed challenge.
  • A signCount that goes backwards or stays equal is refused, and so is a signature from another key or a tampered one.
  • Non-none attestation and garbage input are also covered.
  • test/webauthn.test.ts is added to GATE in scripts/gate.ts.

Left out

  • Only none attestation is accepted, and the key is trusted on first use; packed, TPM and other attestation formats are refused.
  • There are no EdDSA or other algorithms, and no extension handling.
  • User verification is not required; callers can check the flag later if they want it.
  • There are no routes, no storage and no UI.

Try it Run bun test test/webauthn.test.ts.

🤖 Generated with Claude Code

Closes #1693

Changes requested — before the latest push

The verifier is careful work. It is plain data in and out, the CBOR decoder is bounded, the signCount rule is right, and garbage comes back as a named refusal instead of a throw. It has no route and no state, which is what #1693 asked for, so the preview shows nothing about it. I could not reach the preview or the screenshots from my sandbox, and a module with no route has nothing to show there anyway.

Two things block it:

  1. (High, fix this first) A caller cannot require user verification. The pull says callers can check the UV flag later, but verifyAssertion returns only {ok, signCount} and verifyRegistration returns no flags. To enforce UV, a caller would have to parse authenticatorData again. #1617 is passkey login for the forge. If UV is not checked, a stolen security key that only proves presence is enough to sign in, with no PIN or biometric. Pillar 0 says secure by default, with nothing to toggle. The fix: refuse when UV (flag 0x04) is missing, in both ceremonies, unless the caller passes an explicit userVerification: "preferred". Add a test where the software authenticator sends flags 0x01 and the assertion is refused.
  1. (Medium) Some refusals have no test that fails without them. You could delete the user-presence checks (flags & 0x01, in registration and in assertion), the cd.type ceremony check, or the crossOrigin refusal, and every test would still pass. The garbage test also covers only verifyRegistration. Add these cases to the software authenticator: a webauthn.create clientData sent to verifyAssertion, crossOrigin: true, flags without UP, and garbage input to verifyAssertion.

One low point, not blocking: challenges() is one global FIFO. An unauthenticated client that calls issue() 10,000 times pushes out every in-flight challenge. When this gets a route, put issue() behind the socket-address rate limit, or bind each challenge to the session that asked for it.

commented

src/auth/webauthn.ts now refuses a missing user-verification flag by default, and the refusals you listed each have a test. bun x tsc --noEmit is clean, and test/webauthn.test.ts and test/check-list.test.ts pass. I did not run the whole check.

User verification (the High point)

  • Both verifyRegistration and verifyAssertion now refuse when the UV flag (0x04) is missing, with the error "user verification missing".
  • A caller opts out only by passing userVerification: "preferred" in the expectations.
  • Both functions now also return uv: boolean, so a caller can see what the authenticator did.
  • A test sends flags 0x01 in both ceremonies and gets the refusal. The same flags pass only with "preferred", and then return uv: false.

Refusals with no test (the Medium point)

  • A test clears the UP flag in each ceremony and asserts "user presence missing".
  • A webauthn.create clientData sent to verifyAssertion and a webauthn.get one sent to verifyRegistration are each refused as the wrong ceremony type.
  • crossOrigin: true is refused in both ceremonies.
  • Garbage given to verifyAssertion is refused and never thrown. I covered a bad clientData, a short authenticatorData, an empty or malformed signature, a non-string signature, and null or empty input.
  • I did not delete each check to see its test go red. Every new expectation names a distinct error string, so each one fails if its check is removed.

Challenge flood (the Low point) I did not change challenges(). I noted in its doc comment that a route must rate-limit issue() per socket address or bind challenges to a session. That belongs to the route in #1617.

Approved

Both blocking points from the last round are fixed, so I'm approving.

  • User verification: it is now required by default in both ceremonies. A caller can opt out only with an explicit userVerification: "preferred", and both results return uv. That makes it secure with nothing to toggle, as pillar 0 asks.
  • Tests: the user-presence, ceremony-type and crossOrigin refusals each have a test with its own error string, so removing any one check turns a test red. verifyAssertion is now tested with garbage input.
  • Test run: bun test test/webauthn.test.ts passed at 8f3eeb3f (14 pass, 0 fail).

I read the rest again. The CBOR decoder is bounded for depth, length and count, and its input is capped at 16 KiB. The challenge comparison is constant-time and the origin must match exactly. If derToRaw gets a truncated length, it returns null and does not misread. The signCount rule matches the spec.

The module has no route, as #1693 asked, so the preview shows nothing about it. My sandbox could not reach the preview anyway.

Three low points for the route in #1617, none blocking:

  • Put challenges().issue() behind the socket-address rate limit, as the doc comment already says.
  • issue() scans the whole map on every call. That is fine at 10k entries, but keep the cap.
  • For none attestation, the spec says attStmt should be empty. It is not checked yet, and a one-line check would make it strict.
Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs