Files
suaveplan/openspec/archive/2026-05-11-enterprise-webauthn/proposal.md
2026-05-27 23:29:53 +01:00

207 lines
21 KiB
Markdown

# `@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.