Files
2026-05-27 23:29:53 +01:00

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 a PublicKeyCredentialCreationOptions per W3C WebAuthn L3 §5.1.3, calls navigator.credentials.create({ publicKey }) through the injectable WebAuthnAdapter, and parses the returned AuthenticatorAttestationResponse into a typed RegisterResult ({ credentialId, publicKey: CoseKey, attestationObject, clientDataJSON, transports[], authenticatorData: ParsedAuthData, attestationStatement: ParsedAttestationStmt, extensions }).
  • ADDED: WebAuthnRegisterOptions — strict Zod-validated input shape covering rp, user, challenge, pubKeyCredParams, timeout, excludeCredentials, authenticatorSelection, attestation, extensions, signal: AbortSignal, expectedOrigin, expectedRpIdHash.
  • ADDED: client-data validator — type === "webauthn.create", origin === expectedOrigin, and challenge constant-time-equals the caller-supplied challenge. All comparisons use constantTimeEqual from @suaveplan/crypto-utils. Mismatches throw WEBAUTHN.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 optional aaguid[16] + credentialIdLength[2] + credentialId[L] + credentialPublicKey: COSE_Key (CBOR) + optional extensions: CBOR per 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 a PublicKeyCredentialRequestOptions per W3C L3 §5.1.4, calls navigator.credentials.get, and parses the returned AuthenticatorAssertionResponse into a typed AuthenticateResult ({ credentialId, signature, authenticatorData: ParsedAuthData, clientDataJSON, userHandle? }).
  • ADDED: WebAuthnAuthenticateOptions — Zod-validated input shape covering challenge, 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 over authData || sha256(clientDataJSON) using @suaveplan/crypto-curve per the COSE_Key alg label (ES256 / ES384 / ES512 / EdDSA / RS256), and surfaces a discriminated-union result so callers can branch on ok: true | false plus code on failure.
  • ADDED: sign-counter regression detection per W3C L3 §6.1.1 — if result.authenticatorData.signCount ≤ stored counter (and either side is non-zero), verifyAssertion returns { 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 on fmt to 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) and defaultAttestationRegistry() 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 placing autocomplete="username webauthn" on the input.
  • ADDED: isConditionalMediationAvailable(): Promise<boolean> — wraps PublicKeyCredential.isConditionalMediationAvailable?.(), returns false when the static method is missing.

PRF extension (W3C L3 §10.1.4)

  • ADDED: register({ extensions: { prf: { eval: { first: Uint8Array, second?: Uint8Array } } } }) — surfaces result.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 report result.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" } } }) — surfaces result.extensions.largeBlob.supported so the caller knows whether the credential can carry blob data.
  • ADDED: authenticate({ extensions: { largeBlob: { read: true } | { write: Uint8Array } } }) — surfaces result.extensions.largeBlob.blob (Uint8Array) on read or result.extensions.largeBlob.written (boolean) on write.

Adapter abstraction

  • ADDED: WebAuthnAdapter interface — declares create(options): Promise<PublicKeyCredential>, get(options): Promise<PublicKeyCredential>, isSupported(): boolean, isConditionalMediationAvailable(): Promise<boolean>. Production wires globalThis.navigator.credentials; tests wire a deterministic stub that emits known good / bad responses (no real authenticator on CI).
  • ADDED: defaultWebAuthnAdapter() — returns the production adapter when globalThis.navigator?.credentials?.create exists, throws WEBAUTHN.NOT_SUPPORTED otherwise.

Errors / observability / boundaries

  • ADDED: WebAuthnError registry via defineCodes("WEBAUTHN", [...]) covering NOT_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. Raw DOMException instances never leak to callers.
  • ADDED: Zod schemas at every public surface — WebAuthnRegisterOptionsSchema, WebAuthnAuthenticateOptionsSchema, RegisterResultSchema, AuthenticateResultSchema, ParsedAuthDataSchema, ParsedAttestationStmtSchema, CoseKeySchema. Mismatches throw WEBAUTHN.MALFORMED_RESPONSE.
  • ADDED: AbortSignal threaded into register and authenticate and forwarded into navigator.credentials.{create,get} so caller-side cancellation fires the native AbortController path.
  • ADDED: constant-time compare on every challenge and rpIdHash comparison via constantTimeEqual.
  • 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__/ — captured AuthenticatorAttestationResponse and AuthenticatorAssertionResponse byte 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.ts and verify_attestation_tpm.bench.ts — per-format verifier throughput.

React hook (consumer-side)

  • ADDED: usePasskeyAutofill({ rpId, challenge }) — exported from a @suaveplan/react-auth consumer hook in a follow-up; the underlying authenticate({ mediation: "conditional" }) surface is in scope of this change so the hook has a stable target.

Subpath exports

  • . — root barrel; browser-tier (re-exports register, authenticate, isConditionalMediationAvailable, defaultWebAuthnAdapter, every type, every error code).
  • ./adapter — WebAuthnAdapter interface + 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 until enterprise-cbor archives.
  • @suaveplan/crypto-curve workspace:^ — ES256 / ES384 / ES512 / EdDSA / RS256 signature verify.
  • @suaveplan/crypto-hash workspace:^ — SHA-256 of clientDataJSON, SHA-256 for rpIdHash compare.
  • @suaveplan/crypto-utils workspace:^ — base64url encode/decode (challenges, credential IDs), constantTimeEqual.
  • @suaveplan/error workspace:^ — defineCodes, SuaveplanError base.
  • @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 — verifyAttestation returns the trustPath: X509[] and attestationType so 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-u2f attestation 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-auth usePasskeyAutofill hook implementation — declared as a target consumer but lives in the react-auth change folder; this package only ships the underlying authenticate({ 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 / attStmt extraction, every format produces the expected shape.
  • src/parser/client-data.test.ts — webauthn.create / webauthn.get type 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, read returns blob, write reports written: true.
  • src/adapter/adapter.test.ts — defaultWebAuthnAdapter throws NOT_SUPPORTED when navigator.credentials missing; conditional-mediation availability check.
  • src/errors/errors.test.ts — every WEBAUTHN.* 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-memory MeterProvider.
  • 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's exports map.
  • tests/dep-graph.test.ts — ./parser and ./verifier subpaths 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 — usePasskeyAutofill hook (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.