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
Check · Review · Land · Live done — Landed and live in production.
`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
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:
- (High, fix this first) A caller cannot require user verification. The pull says callers can check the UV flag later, but
verifyAssertionreturns only{ok, signCount}andverifyRegistrationreturns no flags. To enforce UV, a caller would have to parseauthenticatorDataagain. #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 explicituserVerification: "preferred". Add a test where the software authenticator sends flags 0x01 and the assertion is refused.
- (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.typeceremony check, or thecrossOriginrefusal, and every test would still pass. The garbage test also covers onlyverifyRegistration. Add these cases to the software authenticator: awebauthn.createclientData sent toverifyAssertion,crossOrigin: true, flags without UP, and garbage input toverifyAssertion.
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.
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
verifyRegistrationandverifyAssertionnow 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 returnuv: false.
Refusals with no test (the Medium point)
- A test clears the UP flag in each ceremony and asserts "user presence missing".
- A
webauthn.createclientData sent toverifyAssertionand awebauthn.getone sent toverifyRegistrationare each refused as the wrong ceremony type. crossOrigin: trueis refused in both ceremonies.- Garbage given to
verifyAssertionis refused and never thrown. I covered a bad clientData, a shortauthenticatorData, 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.
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 returnuv. That makes it secure with nothing to toggle, as pillar 0 asks. - Tests: the user-presence, ceremony-type and
crossOriginrefusals each have a test with its own error string, so removing any one check turns a test red.verifyAssertionis now tested with garbage input. - Test run:
bun test test/webauthn.test.tspassed 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
noneattestation, the spec saysattStmtshould be empty. It is not checked yet, and a one-line check would make it strict.
src/auth/webauthn.tsnow 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 --noEmitis clean, andtest/webauthn.test.tsplustest/check-list.test.tspass (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 JWKkey,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 requiressignCountto 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.Tests
signCountthat goes backwards or stays equal is refused, and so is a signature from another key or a tampered one.noneattestation and garbage input are also covered.test/webauthn.test.tsis added to GATE inscripts/gate.ts.Left out
noneattestation is accepted, and the key is trusted on first use; packed, TPM and other attestation formats are refused.Try it Run
bun test test/webauthn.test.ts.🤖 Generated with Claude Code
Closes #1693