21 KiB
@suaveplan/webauthn — WebAuthn Level 3 client + parser + verifier
Package: @suaveplan/webauthn (NEW)
Status: PROPOSED
Type: new-package (greenfield migration into genesis)
Date: 2026-05-10
Path: packages/vault/webauthn/
Why
The Vaulted plugin requires passkey-as-master-key-unlock to land. Today the genesis monorepo has no WebAuthn surface: there is no way to register a passkey at vault-creation time, no way to use a passkey as the second factor (or sole factor) for Vault.unlock, and no way to materialise a per-credential symmetric secret (PRF extension output) from an authenticator. Without @suaveplan/webauthn the Vaulted unlock FSM cannot be wired and @suaveplan/vault ships incomplete against its own enterprise plan.
The package is also a first-class building block for non-Vault consumers. Any genesis app that wants modern phishing-resistant login (FIDO2 passkeys, platform authenticators, security keys, conditional UI / autofill) imports @suaveplan/webauthn directly — no Vault dependency — and gets the full registration, authentication, and attestation-verification surface. The browser-tier registration / authentication entry points and the universal-tier parsing / verification subpaths are designed so that server-side relying-party verifiers can import @suaveplan/webauthn/verifier without dragging DOM types.
This change has a hard dependency on @suaveplan/cbor (concurrent enterprise-cbor change). COSE_Key parsing per RFC 9052 §7 and the CTAP 2.1 §6.5 attestation-object envelope are both CBOR-encoded; without the COSE / CTAP CBOR profile the package cannot decode authenticator-data credential public keys or attestation statements. The cbor change must land before the webauthn implementation tasks unblock — captured in tasks.md as an explicit blocking gate on task 2.1.
What changes
New package: @suaveplan/webauthn
Browser-tier package at packages/vault/webauthn/. The DOM-bound register / authenticate entry points sit on the package root; the framework-agnostic parsing and verification surface lives under ./parser and ./verifier subpaths so server consumers can import them without a window reference.
Registration ("create")
- ADDED:
register(opts: WebAuthnRegisterOptions): Promise<RegisterResult>— builds aPublicKeyCredentialCreationOptionsper W3C WebAuthn L3 §5.1.3, callsnavigator.credentials.create({ publicKey })through the injectableWebAuthnAdapter, and parses the returnedAuthenticatorAttestationResponseinto a typedRegisterResult({ credentialId, publicKey: CoseKey, attestationObject, clientDataJSON, transports[], authenticatorData: ParsedAuthData, attestationStatement: ParsedAttestationStmt, extensions }). - ADDED:
WebAuthnRegisterOptions— strict Zod-validated input shape coveringrp,user,challenge,pubKeyCredParams,timeout,excludeCredentials,authenticatorSelection,attestation,extensions,signal: AbortSignal,expectedOrigin,expectedRpIdHash. - ADDED: client-data validator —
type === "webauthn.create",origin === expectedOrigin, andchallengeconstant-time-equals the caller-supplied challenge. All comparisons useconstantTimeEqualfrom@suaveplan/crypto-utils. Mismatches throwWEBAUTHN.CHALLENGE_MISMATCH/WEBAUTHN.ORIGIN_MISMATCH. - ADDED:
parseAuthenticatorData(bytes: Uint8Array): ParsedAuthData(under./parser) — splits the 37-byte fixed prefix (rpIdHash[32]+flags[1]+signCount[4]) from optionalaaguid[16]+credentialIdLength[2]+credentialId[L]+credentialPublicKey: COSE_Key (CBOR)+ optionalextensions: CBORper W3C L3 §6.1. - ADDED: flag-byte decoder — yields
{ userPresent, userVerified, backupEligible, backupState, attestedCredentialData, extensionsPresent }per W3C L3 §6.1 (UP / UV / BE / BS / AT / ED bits).
Authentication ("get")
- ADDED:
authenticate(opts: WebAuthnAuthenticateOptions): Promise<AuthenticateResult>— builds aPublicKeyCredentialRequestOptionsper W3C L3 §5.1.4, callsnavigator.credentials.get, and parses the returnedAuthenticatorAssertionResponseinto a typedAuthenticateResult({ credentialId, signature, authenticatorData: ParsedAuthData, clientDataJSON, userHandle? }). - ADDED:
WebAuthnAuthenticateOptions— Zod-validated input shape coveringchallenge,rpId,allowCredentials,timeout,userVerification,mediation: "silent" | "optional" | "conditional" | "required",extensions,signal: AbortSignal,expectedOrigin,expectedRpIdHash. - ADDED: assertion client-data validator — same structural rules as registration, type pinned to
"webauthn.get". - ADDED:
verifyAssertion(result, storedPublicKey, expectedChallenge, expectedOrigin, expectedRpIdHash, signCounter): VerifyResult— verifies the signature overauthData || sha256(clientDataJSON)using@suaveplan/crypto-curveper the COSE_Keyalglabel (ES256 / ES384 / ES512 / EdDSA / RS256), and surfaces a discriminated-union result so callers can branch onok: true | falsepluscodeon failure. - ADDED: sign-counter regression detection per W3C L3 §6.1.1 — if
result.authenticatorData.signCount≤ stored counter (and either side is non-zero),verifyAssertionreturns{ ok: false, code: "WEBAUTHN.SIGN_COUNTER_REGRESSION" }. The caller decides whether to revoke the credential.
Attestation verification (./verifier subpath)
- ADDED:
verifyAttestation(result, expectedRpIdHash, attestationFormatRegistry): AttestationVerifyResult— dispatches onfmtto a per-format verifier; returns{ trusted: boolean, attestationType: "basic" | "self" | "attCA" | "anonCA" | "none", trustPath: X509[] }per W3C L3 §6.5.4 / §8. - ADDED: per-format verifiers under
./attestation/<fmt>subpaths covering every IANA-registered format expected by the enterprise plan:packed(W3C L3 §8.2),fido-u2f(§8.6),tpm(§8.3),android-key(§8.4),android-safetynet(§8.5),apple(§8.8),none(§8.7). - ADDED: pluggable attestation registry —
registerAttestationFormat(name, verifier)anddefaultAttestationRegistry()so genesis apps can register additional IANA-listed formats (e.g.apple-appattest) without forking.
Conditional UI
- ADDED:
authenticate({ mediation: "conditional", ... })passthrough — caller is responsible for placingautocomplete="username webauthn"on the input. - ADDED:
isConditionalMediationAvailable(): Promise<boolean>— wrapsPublicKeyCredential.isConditionalMediationAvailable?.(), returnsfalsewhen the static method is missing.
PRF extension (W3C L3 §10.1.4)
- ADDED:
register({ extensions: { prf: { eval: { first: Uint8Array, second?: Uint8Array } } } })— surfacesresult.extensions.prf.results.first(~32 bytes) usable as a vault-key wrap key. - ADDED:
authenticate({ extensions: { prf: { eval: ... } | { evalByCredential: { ... } } } })— same on the authentication side. Authenticators that lack PRF reportresult.extensions.prf.enabled === false; consumers MUST check before assuming a secret is present.
largeBlob extension (W3C L3 §10.1.3)
- ADDED:
register({ extensions: { largeBlob: { support: "required" | "preferred" } } })— surfacesresult.extensions.largeBlob.supportedso the caller knows whether the credential can carry blob data. - ADDED:
authenticate({ extensions: { largeBlob: { read: true } | { write: Uint8Array } } })— surfacesresult.extensions.largeBlob.blob(Uint8Array) on read orresult.extensions.largeBlob.written(boolean) on write.
Adapter abstraction
- ADDED:
WebAuthnAdapterinterface — declarescreate(options): Promise<PublicKeyCredential>,get(options): Promise<PublicKeyCredential>,isSupported(): boolean,isConditionalMediationAvailable(): Promise<boolean>. Production wiresglobalThis.navigator.credentials; tests wire a deterministic stub that emits known good / bad responses (no real authenticator on CI). - ADDED:
defaultWebAuthnAdapter()— returns the production adapter whenglobalThis.navigator?.credentials?.createexists, throwsWEBAUTHN.NOT_SUPPORTEDotherwise.
Errors / observability / boundaries
- ADDED:
WebAuthnErrorregistry viadefineCodes("WEBAUTHN", [...])coveringNOT_SUPPORTED,USER_CANCELLED,INVALID_STATE,SECURITY_ERROR,NOT_ALLOWED,MALFORMED_RESPONSE,CHALLENGE_MISMATCH,ORIGIN_MISMATCH,RPID_HASH_MISMATCH,SIGN_COUNTER_REGRESSION,SIGNATURE_INVALID,ATTESTATION_FORMAT_UNSUPPORTED,UV_NOT_PERFORMED,UP_NOT_PERFORMED,ABORTED,TIMEOUT. Every code is thrown by at least one production path. - ADDED: DOMException-name mapping at the boundary —
NotAllowedError→WEBAUTHN.USER_CANCELLED,InvalidStateError→WEBAUTHN.INVALID_STATE,SecurityError→WEBAUTHN.SECURITY_ERROR,AbortError→WEBAUTHN.ABORTED,NotSupportedError→WEBAUTHN.NOT_SUPPORTED. RawDOMExceptioninstances never leak to callers. - ADDED: Zod schemas at every public surface —
WebAuthnRegisterOptionsSchema,WebAuthnAuthenticateOptionsSchema,RegisterResultSchema,AuthenticateResultSchema,ParsedAuthDataSchema,ParsedAttestationStmtSchema,CoseKeySchema. Mismatches throwWEBAUTHN.MALFORMED_RESPONSE. - ADDED:
AbortSignalthreaded intoregisterandauthenticateand forwarded intonavigator.credentials.{create,get}so caller-side cancellation fires the nativeAbortControllerpath. - ADDED: constant-time compare on every challenge and
rpIdHashcomparison viaconstantTimeEqual. - ADDED: OTel surface —
webauthn.register_duration_ms{outcome}histogram,webauthn.authenticate_duration_ms{outcome}histogram,webauthn.outcome_total{op,outcome}counter,webauthn.attestation_format_total{fmt}counter,webauthn.errors_total{code}counter.
Test-vector bundle
- ADDED:
src/__fixtures__/— capturedAuthenticatorAttestationResponseandAuthenticatorAssertionResponsebyte strings sourced offline from a Yubikey 5 (packed + fido-u2f), Windows Hello (tpm), macOS Touch ID (apple), Chrome Software Authenticator (none), Android Pixel platform authenticator (android-key + android-safetynet). Every attestation format covered in the spec has at least one good vector and one corruption (signature flipped, rpIdHash zeroed, signCount frozen) for negative tests.
Mitata benchmarks
- ADDED:
src/__benchmarks__/parse_authdata.bench.ts— packed auth-data parse throughput. - ADDED:
src/__benchmarks__/verify_assertion_es256.bench.ts— ES256 assertion verify against a stored COSE_Key. - ADDED:
src/__benchmarks__/verify_attestation_packed.bench.tsandverify_attestation_tpm.bench.ts— per-format verifier throughput.
React hook (consumer-side)
- ADDED:
usePasskeyAutofill({ rpId, challenge })— exported from a@suaveplan/react-authconsumer hook in a follow-up; the underlyingauthenticate({ mediation: "conditional" })surface is in scope of this change so the hook has a stable target.
Subpath exports
.— root barrel; browser-tier (re-exportsregister,authenticate,isConditionalMediationAvailable,defaultWebAuthnAdapter, every type, every error code)../adapter—WebAuthnAdapterinterface +defaultWebAuthnAdapter../parser— universal-tier;parseAuthenticatorData,parseAttestationObject,parseClientDataJSON, every parser type, every parser-side Zod schema../verifier— universal-tier;verifyAssertion,verifyAttestation,defaultAttestationRegistry,registerAttestationFormat../attestation/packed,./attestation/fido-u2f,./attestation/tpm,./attestation/android-key,./attestation/android-safetynet,./attestation/apple,./attestation/none— per-format verifier subpaths../extensions/prf,./extensions/large-blob— extension passthrough + result parsing../errors— error code registry +WebAuthnError.
Dependencies
@suaveplan/cbor workspace:^— HARD BLOCKER, gates this change. COSE_Key (RFC 9052 §7) and CTAP 2.1 §6.5 attestation-object envelope decoding both consume CBOR. Implementation tasks cannot start untilenterprise-cborarchives.@suaveplan/crypto-curve workspace:^— ES256 / ES384 / ES512 / EdDSA / RS256 signature verify.@suaveplan/crypto-hash workspace:^— SHA-256 ofclientDataJSON, SHA-256 forrpIdHashcompare.@suaveplan/crypto-utils workspace:^— base64url encode/decode (challenges, credential IDs),constantTimeEqual.@suaveplan/error workspace:^—defineCodes,SuaveplanErrorbase.@suaveplan/types workspace:^—Clock,LoggerLike, shared option bundles.@opentelemetry/api catalog:observability— histograms + counters.tslib catalog:build.zod catalog:forms— boundary validation.
Out of scope
- Server-side relying-party state machine (challenge generation, credential storage, user-handle binding, attestation-trust-store curation). The package exposes the verification primitives a server needs, but the persistence and challenge-issuance flow belongs to consumers (e.g.
@suaveplan/vault,@suaveplan/api-schemas/auth). - MDS3 (FIDO Metadata Service) integration —
verifyAttestationreturns thetrustPath: X509[]andattestationTypeso callers can perform MDS3 lookups against their own metadata source. Bundling MDS3 fetch + caching expands scope into a separate package and is a follow-up. - WebAuthn Level 2 polyfill — the package targets WebAuthn L3 only. Browsers without L3 support (e.g. older Safari) surface
WEBAUTHN.NOT_SUPPORTED. - CTAP1 / U2F legacy protocol — only the CTAP2 mapping is in scope. The
fido-u2fattestation format is supported because L3 still defines it for backward compatibility; raw U2F register / sign is not. - Hardware security key driver code — the package operates entirely through
navigator.credentials; it does not speak USB HID, NFC, or BLE directly. @suaveplan/react-authusePasskeyAutofillhook implementation — declared as a target consumer but lives in thereact-authchange folder; this package only ships the underlyingauthenticate({ mediation: "conditional" })surface.
Source mapping
This change derives from ../suaveplan/enterprise/webauthn.md.
| Requirement | Source lines | Notes |
|---|---|---|
| Package purpose, plugin assignment, mixed-tier layout | L1-L15 | Verbatim |
| Current-state assessment + spec anchors | L16-L26 | Verbatim |
register + parse + flag decode |
L29-L34 | Verbatim; flag list pinned to W3C L3 §6.1 |
authenticate + assertion parse + verifyAssertion + sign-counter regression |
L36-L41 | Verbatim; W3C L3 §6.1.1 anchor |
Attestation verifyAttestation + per-format verifiers + pluggable registry + return shape |
L43-L47 | Verbatim; W3C L3 §6.5.4 / §8 anchors |
Conditional UI passthrough + isConditionalMediationAvailable |
L49-L52 | Verbatim |
| Cross-cutting: tier split, Zod boundaries, error registry, DOMException mapping, OTel, AbortSignal, constant-time compare, 100% coverage, adapter | L54-L64 | Verbatim |
| PRF extension | L68-L69 | W3C L3 §10.1.4 |
| largeBlob extension | L70 | W3C L3 §10.1.3 |
| Test-vector bundle | L71 | Verbatim |
| Mitata benchmarks | L72 | Verbatim |
| Conditional UI React hook target | L67 | Verbatim |
WebAuthnAdapter abstraction |
L63 + L79 | Verbatim |
| Error code registry contents | L59 | Verbatim list |
| OTel histogram + counter shapes | L60 | Verbatim |
Dependency on @suaveplan/cbor |
L102 | Verbatim |
External standard anchors (every requirement in the spec delta cites at least one):
- W3C WebAuthn L3 §5 "Web Authentication API" (
PublicKeyCredentialCreationOptions,PublicKeyCredentialRequestOptions). - W3C WebAuthn L3 §6 "WebAuthn Authenticator Model" (auth-data layout, flag bits §6.1, sign-counter regression §6.1.1, attestation §6.5).
- W3C WebAuthn L3 §7 "WebAuthn Relying Party Operations" (register / authenticate algorithms).
- W3C WebAuthn L3 §8 "Defined Attestation Statement Formats" (per-format verifiers).
- W3C WebAuthn L3 §9 "WebAuthn Extensions" (extension processing model).
- W3C WebAuthn L3 §10 "Defined Extensions" (PRF §10.1.4, largeBlob §10.1.3).
- CTAP 2.1 §6.5 "Attestation Object" (attestation envelope CBOR layout, fmt / authData / attStmt fields).
- RFC 9052 §7 "COSE Key Objects" (COSE_Key map, alg label, kty, crv, x/y/n/e fields).
- IANA WebAuthn registries (attestation formats, extension identifiers).
Requirements with no source line
| Requirement | Justification |
|---|---|
Subpath split between browser-tier root and universal-tier ./parser / ./verifier |
Server-side relying-party verifiers must import the verification primitives without a window reference; the enterprise doc names this at L56 but the per-subpath listing is genesis-specific. |
Per-attestation-format subpath (./attestation/<fmt>) |
Genesis subpath-export convention (openspec/AGENTS.md §6); enables tree-shaking of unused formats. |
defaultAttestationRegistry() factory |
The enterprise doc specifies pluggable registration; the factory shape is the genesis convention for "ships with all built-ins, accepts overrides". |
WEBAUTHN.ABORTED and WEBAUTHN.TIMEOUT distinct codes |
Enterprise list groups under USER_CANCELLED; genesis convention separates AbortSignal-driven cancellation from authenticator-side timeout for OTel errors_total{code} slicing. |
Verification approach
Each requirement in the spec delta carries a **Verified by:** line pointing at concrete test-file paths. Test files:
src/register/register.test.ts— register happy path, client-data type / origin / challenge mismatch, AbortSignal cancellation, DOMException mapping for every native name.src/authenticate/authenticate.test.ts— authenticate happy path, client-data validation, conditional mediation, AbortSignal.src/parser/authenticator-data.test.ts— fixed-prefix split, every flag-byte combination, attested-credential-data parse, extensions block presence/absence, malformed-truncation rejection.src/parser/attestation-object.test.ts—fmt/authData/attStmtextraction, every format produces the expected shape.src/parser/client-data.test.ts—webauthn.create/webauthn.gettype pinning, base64url challenge decode, origin equality.src/parser/cose-key.test.ts— RFC 9052 §7 round-trip per supported alg.src/verifier/verify-assertion.test.ts— ES256 / ES384 / ES512 / EdDSA / RS256 happy path, signature-flip negative, rpIdHash mismatch, sign-counter regression at every boundary (equal, less-than, both-zero acceptance).src/verifier/verify-attestation.test.ts— registry dispatch, unknown-format error, every built-in format trusted on good vector, every format rejects flipped signature.src/attestation/packed/packed.test.ts,src/attestation/fido-u2f/fido-u2f.test.ts,src/attestation/tpm/tpm.test.ts,src/attestation/android-key/android-key.test.ts,src/attestation/android-safetynet/android-safetynet.test.ts,src/attestation/apple/apple.test.ts,src/attestation/none/none.test.ts— per-format good + corrupted vectors, attestationType + trustPath shape.src/extensions/prf/prf.test.ts— PRF eval round-trip on register and authenticate, missing-PRF-result fallback, secret length assertion.src/extensions/large-blob/large-blob.test.ts—support: "required"flag surfaces,readreturns blob,writereportswritten: true.src/adapter/adapter.test.ts—defaultWebAuthnAdapterthrowsNOT_SUPPORTEDwhennavigator.credentialsmissing; conditional-mediation availability check.src/errors/errors.test.ts— everyWEBAUTHN.*code is thrown by at least one production path; DOMException-name mapping table covered.src/observability/metrics.test.ts— every histogram + counter records on its production path against an in-memoryMeterProvider.src/parser/authenticator-data.property.test.ts— fast-check fuzz of arbitrary auth-data byte strings; parser never panics, only throws typed errors.src/parser/attestation-object.property.test.ts— fast-check fuzz of attestation-object envelopes.src/index.test.ts— every documented subpath resolves through the package'sexportsmap.tests/dep-graph.test.ts—./parserand./verifiersubpaths import zero DOM types (verified with the genesis dep-graph spec-conformance harness).
Migration impact
Additive only — no existing genesis package depends on a prior WebAuthn surface. Downstream consumers that will adopt this package in follow-up changes:
@suaveplan/vault— passkey-as-master-key-unlock flow (PRF extension materialises the wrap key for the AUK).@suaveplan/react-auth—usePasskeyAutofillhook (conditional-mediation autofill).@suaveplan/api-schemas/auth— WebAuthn challenge / credential schemas extend the existing auth schemas.
Impact
Affected dependents
This is a NEW package — no existing dependents. Run bunx turbo ls --filter=...@suaveplan/webauthn after landing to confirm. Downstream adopters listed under "Migration impact" land in their own subsequent changes.
Dependency on concurrent change
This change is blocked on enterprise-cbor archiving first. Implementation task 2.1 (CBOR-decode COSE_Key + attestation-object) cannot start until @suaveplan/cbor is published from genesis. Spec authoring (proposal + tasks + spec delta) and test-vector bundle prep are unblocked and may proceed in parallel.
Migration
No prior surface — additive only. There is no obsolete contract for consumers to migrate from.