Initial Commit
This commit is contained in:
206
openspec/archive/2026-05-11-enterprise-webauthn/proposal.md
Normal file
206
openspec/archive/2026-05-11-enterprise-webauthn/proposal.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# `@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.
|
||||
@@ -0,0 +1,561 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: webauthn (enterprise-webauthn)
|
||||
|
||||
**Package**: `@suaveplan/webauthn`
|
||||
**Status**: PROPOSED — first delta; becomes canonical spec on archive.
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Registration entry point
|
||||
|
||||
The package SHALL export `register(opts: WebAuthnRegisterOptions): Promise<RegisterResult>` per W3C WebAuthn L3 §5.1.3 and §7.1. The function SHALL build a `PublicKeyCredentialCreationOptions` from the supplied `opts`, invoke the `WebAuthnAdapter.create` method, and return a typed `RegisterResult` containing the parsed credential id, COSE public key, raw attestation object, raw client-data JSON, transports list, parsed authenticator data, parsed attestation statement, and any extension results. The function SHALL forward `opts.signal` to the underlying `navigator.credentials.create` call so caller-side cancellation triggers the native `AbortController` path.
|
||||
|
||||
#### Scenario: returns a typed RegisterResult on success
|
||||
|
||||
- **WHEN** `register({ rp, user, challenge, pubKeyCredParams: [{ alg: -7, type: "public-key" }], expectedOrigin: "https://example.com", expectedRpIdHash: <sha256("example.com")> })` is called
|
||||
- **AND** the adapter returns a valid `PublicKeyCredential` with a `packed` attestation
|
||||
- **THEN** the resolved value contains `credentialId: Uint8Array`, `publicKey: CoseKey`, `attestationObject: Uint8Array`, `clientDataJSON: Uint8Array`, `transports: string[]`, `authenticatorData: ParsedAuthData`, `attestationStatement: ParsedAttestationStmt`, and `extensions` keyed by extension id
|
||||
|
||||
#### Scenario: forwards AbortSignal to the adapter
|
||||
|
||||
- **GIVEN** an `AbortController` whose signal is passed via `opts.signal`
|
||||
- **WHEN** the controller is aborted before the adapter resolves
|
||||
- **THEN** `register` rejects with `WEBAUTHN.ABORTED` and the adapter receives the same signal in its `create` call
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/register/register.test.ts::"returns a typed RegisterResult on packed attestation"` AND `packages/vault/webauthn/src/register/register.test.ts::"forwards AbortSignal to navigator.credentials.create"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Registration client-data validation
|
||||
|
||||
The package SHALL validate the `clientDataJSON` returned by the authenticator before resolving `register`. The validator SHALL enforce `type === "webauthn.create"`, `origin === expectedOrigin`, and that `challenge` (after base64url decode) constant-time-equals the caller-supplied challenge. All challenge and origin comparisons SHALL use `constantTimeEqual` from `@suaveplan/crypto-utils`. Mismatches SHALL throw `WEBAUTHN.MALFORMED_RESPONSE` (when `type` is wrong), `WEBAUTHN.ORIGIN_MISMATCH` (when `origin` is wrong), or `WEBAUTHN.CHALLENGE_MISMATCH` (when the challenge differs).
|
||||
|
||||
#### Scenario: rejects an assertion-typed clientDataJSON
|
||||
|
||||
- **GIVEN** a captured `AuthenticatorAttestationResponse` whose `clientDataJSON.type === "webauthn.get"`
|
||||
- **WHEN** `register` is called against this stub adapter
|
||||
- **THEN** the call rejects with `WEBAUTHN.MALFORMED_RESPONSE`
|
||||
|
||||
#### Scenario: rejects an origin mismatch
|
||||
|
||||
- **GIVEN** a captured response whose `clientDataJSON.origin === "https://attacker.example"`
|
||||
- **WHEN** `register({ ..., expectedOrigin: "https://example.com" })` is called
|
||||
- **THEN** the call rejects with `WEBAUTHN.ORIGIN_MISMATCH`
|
||||
|
||||
#### Scenario: rejects a challenge mismatch
|
||||
|
||||
- **GIVEN** a captured response whose decoded challenge differs from the caller's by a single byte
|
||||
- **WHEN** `register({ ..., challenge: <caller> })` is called
|
||||
- **THEN** the call rejects with `WEBAUTHN.CHALLENGE_MISMATCH`
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/register/register.test.ts::"rejects clientDataJSON.type !== webauthn.create"` AND `packages/vault/webauthn/src/register/register.test.ts::"rejects origin mismatch"` AND `packages/vault/webauthn/src/register/register.test.ts::"rejects challenge mismatch via constant-time compare"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Authenticator-data parser
|
||||
|
||||
The package SHALL export `parseAuthenticatorData(bytes: Uint8Array): ParsedAuthData` from the `./parser` subpath. The parser SHALL split the 37-byte fixed prefix into `rpIdHash[32]`, `flags[1]`, `signCount[4]` per W3C WebAuthn L3 §6.1, then conditionally parse `aaguid[16]`, `credentialIdLength[2]`, `credentialId[L]`, `credentialPublicKey: COSE_Key (CBOR)` when the AT (attested-credential-data) flag is set, and conditionally parse the trailing CBOR `extensions` map when the ED (extensions-present) flag is set. The parser SHALL throw `WEBAUTHN.MALFORMED_RESPONSE` on truncation, on invalid CBOR, or on COSE_Key shape mismatch.
|
||||
|
||||
#### Scenario: parses fixed prefix on a flag-only frame
|
||||
|
||||
- **WHEN** `parseAuthenticatorData(bytes)` is called against a 37-byte frame with flags `0x00`
|
||||
- **THEN** the result has `rpIdHash.byteLength === 32`, `flags === 0x00`, and `signCount` matches the caller-encoded value
|
||||
|
||||
#### Scenario: parses attested-credential-data when AT flag set
|
||||
|
||||
- **WHEN** `parseAuthenticatorData(bytes)` is called against a frame with `flags & 0x40` (AT) set
|
||||
- **THEN** the result has `aaguid.byteLength === 16`, `credentialId.byteLength === credentialIdLength`, and `credentialPublicKey` is a typed `CoseKey`
|
||||
|
||||
#### Scenario: rejects truncated frame
|
||||
|
||||
- **WHEN** `parseAuthenticatorData(bytes.slice(0, 30))` is called
|
||||
- **THEN** the call throws `WEBAUTHN.MALFORMED_RESPONSE`
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/parser/authenticator-data.test.ts::"parses 37-byte fixed prefix"` AND `packages/vault/webauthn/src/parser/authenticator-data.test.ts::"parses attested-credential-data when AT flag set"` AND `packages/vault/webauthn/src/parser/authenticator-data.test.ts::"rejects truncated frame"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Flag-byte decoder
|
||||
|
||||
The package SHALL decode the 1-byte `flags` field of authenticator data into a `ParsedAuthDataFlags` object per W3C WebAuthn L3 §6.1: `userPresent` (UP, bit 0), `userVerified` (UV, bit 2), `backupEligible` (BE, bit 3), `backupState` (BS, bit 4), `attestedCredentialData` (AT, bit 6), `extensionsPresent` (ED, bit 7). Reserved bits SHALL be parsed but not surfaced.
|
||||
|
||||
#### Scenario: decodes UP-only flag byte
|
||||
|
||||
- **WHEN** `parseAuthenticatorData` is called against a frame with `flags === 0x01`
|
||||
- **THEN** the result has `flags.userPresent === true` and every other flag false
|
||||
|
||||
#### Scenario: decodes UP + UV + AT + ED combined
|
||||
|
||||
- **WHEN** the frame uses `flags === 0xC5` (UP | UV | AT | ED)
|
||||
- **THEN** the result has `userPresent`, `userVerified`, `attestedCredentialData`, `extensionsPresent` all `true`, and `backupEligible`, `backupState` both `false`
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/parser/authenticator-data.test.ts::"decodes UP-only flag byte"` AND `packages/vault/webauthn/src/parser/authenticator-data.test.ts::"decodes UP + UV + AT + ED combined flag byte"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Authentication entry point
|
||||
|
||||
The package SHALL export `authenticate(opts: WebAuthnAuthenticateOptions): Promise<AuthenticateResult>` per W3C WebAuthn L3 §5.1.4 and §7.2. The function SHALL build a `PublicKeyCredentialRequestOptions` from `opts`, invoke `WebAuthnAdapter.get`, and return a typed `AuthenticateResult` containing the parsed credential id, signature bytes, parsed authenticator data, raw client-data JSON, and optional `userHandle`. The function SHALL pass `opts.mediation` (`"silent" | "optional" | "conditional" | "required"`) and `opts.signal` through to the adapter unchanged.
|
||||
|
||||
#### Scenario: returns a typed AuthenticateResult on success
|
||||
|
||||
- **WHEN** `authenticate({ challenge, rpId, allowCredentials, expectedOrigin, expectedRpIdHash })` is called against a stub returning a valid assertion
|
||||
- **THEN** the resolved value contains `credentialId: Uint8Array`, `signature: Uint8Array`, `authenticatorData: ParsedAuthData`, `clientDataJSON: Uint8Array`, and an optional `userHandle: Uint8Array | null`
|
||||
|
||||
#### Scenario: forwards conditional mediation flag
|
||||
|
||||
- **WHEN** `authenticate({ ..., mediation: "conditional" })` is called
|
||||
- **THEN** the underlying `navigator.credentials.get` call receives `mediation: "conditional"`
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/authenticate/authenticate.test.ts::"returns a typed AuthenticateResult on success"` AND `packages/vault/webauthn/src/authenticate/authenticate.test.ts::"forwards conditional mediation flag"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Assertion client-data validation
|
||||
|
||||
The package SHALL validate the `clientDataJSON` returned during authentication. The validator SHALL enforce `type === "webauthn.get"`, `origin === expectedOrigin`, and that the decoded `challenge` constant-time-equals the caller-supplied challenge. Mismatches SHALL throw `WEBAUTHN.MALFORMED_RESPONSE`, `WEBAUTHN.ORIGIN_MISMATCH`, or `WEBAUTHN.CHALLENGE_MISMATCH` respectively.
|
||||
|
||||
#### Scenario: rejects a registration-typed clientDataJSON
|
||||
|
||||
- **GIVEN** a stub assertion whose `clientDataJSON.type === "webauthn.create"`
|
||||
- **WHEN** `authenticate` is called
|
||||
- **THEN** the call rejects with `WEBAUTHN.MALFORMED_RESPONSE`
|
||||
|
||||
#### Scenario: rejects challenge mismatch on assertion
|
||||
|
||||
- **GIVEN** a stub assertion whose decoded challenge differs from the caller's
|
||||
- **WHEN** `authenticate({ ..., challenge: <caller> })` is called
|
||||
- **THEN** the call rejects with `WEBAUTHN.CHALLENGE_MISMATCH`
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/authenticate/authenticate.test.ts::"rejects clientDataJSON.type !== webauthn.get"` AND `packages/vault/webauthn/src/authenticate/authenticate.test.ts::"rejects challenge mismatch on assertion"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Assertion signature verification
|
||||
|
||||
The package SHALL export `verifyAssertion(result, storedPublicKey, expectedChallenge, expectedOrigin, expectedRpIdHash, signCounter): VerifyResult` from the `./verifier` subpath. The function SHALL verify the assertion signature over the byte string `authData || sha256(clientDataJSON)` using `@suaveplan/crypto-curve`, with the algorithm derived from the stored `CoseKey.alg` label per RFC 9052 §7. Supported algorithms: ES256 (-7), ES384 (-35), ES512 (-36), EdDSA (-8), RS256 (-257). The function SHALL also verify `result.authenticatorData.rpIdHash` constant-time-equals `expectedRpIdHash` and that the client data validates per the assertion-client-data requirement. The function SHALL return a discriminated-union result `{ ok: true } | { ok: false, code: WebAuthnErrorCode }`.
|
||||
|
||||
#### Scenario: verifies an ES256 signature
|
||||
|
||||
- **GIVEN** a stored ES256 `CoseKey` and a captured assertion signed with the matching private key
|
||||
- **WHEN** `verifyAssertion(result, storedKey, challenge, origin, rpIdHash, 0)` is called
|
||||
- **THEN** the result is `{ ok: true }`
|
||||
|
||||
#### Scenario: rejects a flipped signature
|
||||
|
||||
- **GIVEN** the same stored key but an assertion whose signature has one byte flipped
|
||||
- **WHEN** `verifyAssertion(...)` is called
|
||||
- **THEN** the result is `{ ok: false, code: "WEBAUTHN.SIGNATURE_INVALID" }`
|
||||
|
||||
#### Scenario: rejects an rpIdHash mismatch
|
||||
|
||||
- **GIVEN** an assertion whose `authenticatorData.rpIdHash` differs from `expectedRpIdHash`
|
||||
- **WHEN** `verifyAssertion(...)` is called
|
||||
- **THEN** the result is `{ ok: false, code: "WEBAUTHN.RPID_HASH_MISMATCH" }`
|
||||
|
||||
#### Scenario: verifies EdDSA / Ed25519 signature
|
||||
|
||||
- **GIVEN** a stored `CoseKey` with `alg === -8` and a captured assertion signed with Ed25519
|
||||
- **WHEN** `verifyAssertion(...)` is called
|
||||
- **THEN** the result is `{ ok: true }`
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/verifier/verify-assertion.test.ts::"verifies ES256 signature"` AND `packages/vault/webauthn/src/verifier/verify-assertion.test.ts::"rejects flipped signature"` AND `packages/vault/webauthn/src/verifier/verify-assertion.test.ts::"rejects rpIdHash mismatch"` AND `packages/vault/webauthn/src/verifier/verify-assertion.test.ts::"verifies EdDSA signature"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Sign-counter regression detection
|
||||
|
||||
The package SHALL detect sign-counter regression per W3C WebAuthn L3 §6.1.1. When `result.authenticatorData.signCount` is less than or equal to the caller-supplied `signCounter` AND at least one of the two values is non-zero, `verifyAssertion` SHALL return `{ ok: false, code: "WEBAUTHN.SIGN_COUNTER_REGRESSION" }`. When both counters are zero (authenticators that do not maintain a counter), the check SHALL be skipped and verification proceeds. The caller decides whether to revoke the credential on regression.
|
||||
|
||||
#### Scenario: detects equal-counter regression
|
||||
|
||||
- **GIVEN** a stored `signCounter = 5` and an assertion with `signCount = 5`
|
||||
- **WHEN** `verifyAssertion(...)` is called
|
||||
- **THEN** the result is `{ ok: false, code: "WEBAUTHN.SIGN_COUNTER_REGRESSION" }`
|
||||
|
||||
#### Scenario: detects strict-decrease regression
|
||||
|
||||
- **GIVEN** a stored `signCounter = 10` and an assertion with `signCount = 7`
|
||||
- **WHEN** `verifyAssertion(...)` is called
|
||||
- **THEN** the result is `{ ok: false, code: "WEBAUTHN.SIGN_COUNTER_REGRESSION" }`
|
||||
|
||||
#### Scenario: skips regression check when both counters zero
|
||||
|
||||
- **GIVEN** a stored `signCounter = 0` and an assertion with `signCount = 0`
|
||||
- **WHEN** `verifyAssertion(...)` is called against an otherwise-valid assertion
|
||||
- **THEN** the result is `{ ok: true }`
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/verifier/verify-assertion.test.ts::"detects equal-counter regression"` AND `packages/vault/webauthn/src/verifier/verify-assertion.test.ts::"detects strict-decrease regression"` AND `packages/vault/webauthn/src/verifier/verify-assertion.test.ts::"skips regression check when both counters zero"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Attestation verification dispatcher
|
||||
|
||||
The package SHALL export `verifyAttestation(result, expectedRpIdHash, attestationFormatRegistry): AttestationVerifyResult` from the `./verifier` subpath. The dispatcher SHALL read `result.attestationObject.fmt`, look up the corresponding verifier in `attestationFormatRegistry`, and invoke it. Supported built-in formats per W3C WebAuthn L3 §8 and the IANA WebAuthn registries: `packed`, `fido-u2f`, `tpm`, `android-key`, `android-safetynet`, `apple`, `none`. The dispatcher SHALL throw `WEBAUTHN.ATTESTATION_FORMAT_UNSUPPORTED` when no verifier is registered for `fmt`. Each verifier SHALL return `{ trusted: boolean, attestationType: "basic" | "self" | "attCA" | "anonCA" | "none", trustPath: X509[] }`.
|
||||
|
||||
#### Scenario: dispatches to the registered packed verifier
|
||||
|
||||
- **GIVEN** a registry containing the built-in `packed` verifier
|
||||
- **WHEN** `verifyAttestation(result, expectedRpIdHash, registry)` is called against a packed attestation
|
||||
- **THEN** the registry's `packed` verifier is invoked and the returned `attestationType` is one of `"basic"`, `"self"`, or `"attCA"`
|
||||
|
||||
#### Scenario: throws on unregistered format
|
||||
|
||||
- **GIVEN** a registry without an entry for `fmt === "tpm"`
|
||||
- **WHEN** `verifyAttestation` is called against a TPM attestation
|
||||
- **THEN** the call throws `WEBAUTHN.ATTESTATION_FORMAT_UNSUPPORTED`
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/verifier/verify-attestation.test.ts::"dispatches to the registered packed verifier"` AND `packages/vault/webauthn/src/verifier/verify-attestation.test.ts::"throws WEBAUTHN.ATTESTATION_FORMAT_UNSUPPORTED on unregistered fmt"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Per-format attestation verifiers
|
||||
|
||||
The package SHALL ship verifiers for every format defined by W3C WebAuthn L3 §8 and listed in the IANA WebAuthn registry: `packed` (§8.2), `fido-u2f` (§8.6), `tpm` (§8.3), `android-key` (§8.4), `android-safetynet` (§8.5), `apple` (§8.8), `none` (§8.7). Each verifier SHALL live under its own `./attestation/<fmt>` subpath and SHALL implement the `AttestationFormatVerifier` interface returning `{ trusted, attestationType, trustPath }`. Each verifier SHALL reject signatures that fail to verify against the supplied trust path with `WEBAUTHN.SIGNATURE_INVALID`.
|
||||
|
||||
#### Scenario: packed verifier accepts a Yubikey 5 good vector
|
||||
|
||||
- **WHEN** the `packed` verifier is invoked against the captured Yubikey 5 attestation fixture
|
||||
- **THEN** the result has `trusted: true`, `attestationType: "basic"`, and `trustPath.length >= 1`
|
||||
|
||||
#### Scenario: tpm verifier rejects a flipped signature
|
||||
|
||||
- **GIVEN** the captured Windows Hello TPM attestation fixture with one byte of `attStmt.sig` flipped
|
||||
- **WHEN** the `tpm` verifier is invoked
|
||||
- **THEN** the call throws `WEBAUTHN.SIGNATURE_INVALID`
|
||||
|
||||
#### Scenario: none verifier accepts and reports attestationType "none"
|
||||
|
||||
- **WHEN** the `none` verifier is invoked against the Chrome software authenticator fixture
|
||||
- **THEN** the result has `trusted: true`, `attestationType: "none"`, and `trustPath.length === 0`
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/attestation/packed/packed.test.ts::"accepts Yubikey 5 packed good vector"` AND `packages/vault/webauthn/src/attestation/tpm/tpm.test.ts::"rejects flipped TPM signature"` AND `packages/vault/webauthn/src/attestation/none/none.test.ts::"accepts and reports attestationType none"` AND `packages/vault/webauthn/src/attestation/fido-u2f/fido-u2f.test.ts::"accepts Yubikey 5 fido-u2f good vector"` AND `packages/vault/webauthn/src/attestation/android-key/android-key.test.ts::"accepts Pixel android-key good vector"` AND `packages/vault/webauthn/src/attestation/android-safetynet/android-safetynet.test.ts::"accepts Pixel safetynet good vector"` AND `packages/vault/webauthn/src/attestation/apple/apple.test.ts::"accepts macOS Touch ID good vector"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Pluggable attestation registry
|
||||
|
||||
The package SHALL export `defaultAttestationRegistry()` that returns a registry pre-populated with every built-in format verifier, and `registerAttestationFormat(registry, name, verifier)` that adds or overrides a format. Calling `registerAttestationFormat` with a name already present SHALL replace the previous verifier (last-write-wins) so genesis apps can swap implementations without forking the package.
|
||||
|
||||
#### Scenario: defaultAttestationRegistry contains every built-in format
|
||||
|
||||
- **WHEN** `defaultAttestationRegistry()` is called
|
||||
- **THEN** the returned registry has entries keyed `"packed"`, `"fido-u2f"`, `"tpm"`, `"android-key"`, `"android-safetynet"`, `"apple"`, `"none"`
|
||||
|
||||
#### Scenario: registerAttestationFormat overrides a built-in
|
||||
|
||||
- **GIVEN** a registry from `defaultAttestationRegistry()`
|
||||
- **WHEN** `registerAttestationFormat(registry, "packed", customVerifier)` is called
|
||||
- **AND** `verifyAttestation` is invoked against a packed attestation
|
||||
- **THEN** `customVerifier` is invoked instead of the built-in
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/verifier/verify-attestation.test.ts::"defaultAttestationRegistry contains every built-in format"` AND `packages/vault/webauthn/src/verifier/verify-attestation.test.ts::"registerAttestationFormat overrides a built-in verifier"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Conditional UI mediation
|
||||
|
||||
The package SHALL pass `opts.mediation` through to `navigator.credentials.get` unchanged so callers can request `"silent" | "optional" | "conditional" | "required"` per the Credential Management spec. The package SHALL also export `isConditionalMediationAvailable(): Promise<boolean>` wrapping `PublicKeyCredential.isConditionalMediationAvailable?.()` and returning `false` when the static method is missing on the global `PublicKeyCredential` object.
|
||||
|
||||
#### Scenario: passes mediation: "conditional" to the adapter
|
||||
|
||||
- **WHEN** `authenticate({ ..., mediation: "conditional" })` is called
|
||||
- **THEN** the underlying adapter receives `options.mediation === "conditional"`
|
||||
|
||||
#### Scenario: returns false when global PublicKeyCredential lacks the static check
|
||||
|
||||
- **GIVEN** a test environment where `globalThis.PublicKeyCredential.isConditionalMediationAvailable` is `undefined`
|
||||
- **WHEN** `isConditionalMediationAvailable()` is called
|
||||
- **THEN** the resolved value is `false`
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/authenticate/authenticate.test.ts::"passes mediation conditional to adapter"` AND `packages/vault/webauthn/src/adapter/adapter.test.ts::"isConditionalMediationAvailable returns false when static check missing"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PRF extension support
|
||||
|
||||
The package SHALL accept the `prf` extension input on both `register` and `authenticate` per W3C WebAuthn L3 §10.1.4 and surface the per-credential PRF output as a `Uint8Array` (~32 bytes) on the result's `extensions.prf.results.first` field. When the authenticator supports PRF but the caller did not request an `eval` input, the result SHALL surface `extensions.prf.enabled === true` with no `results`. When the authenticator does not support PRF, the result SHALL surface `extensions.prf.enabled === false` and consumers SHALL NOT assume a secret is present.
|
||||
|
||||
#### Scenario: register surfaces PRF enabled flag
|
||||
|
||||
- **GIVEN** a stub adapter whose registration result includes `extensions.prf.enabled === true`
|
||||
- **WHEN** `register({ ..., extensions: { prf: { eval: { first: salt } } } })` is called
|
||||
- **THEN** the resolved value has `extensions.prf.enabled === true`
|
||||
|
||||
#### Scenario: authenticate yields a 32-byte PRF secret
|
||||
|
||||
- **GIVEN** a stub assertion whose `extensions.prf.results.first` is a 32-byte Uint8Array
|
||||
- **WHEN** `authenticate({ ..., extensions: { prf: { eval: { first: salt } } } })` is called
|
||||
- **THEN** the resolved `extensions.prf.results.first.byteLength === 32`
|
||||
|
||||
#### Scenario: missing PRF surfaces enabled false
|
||||
|
||||
- **GIVEN** a stub assertion whose `extensions.prf.enabled === false`
|
||||
- **WHEN** `authenticate(...)` is called
|
||||
- **THEN** the resolved `extensions.prf.enabled === false` and `extensions.prf.results` is undefined
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/extensions/prf/prf.test.ts::"register surfaces PRF enabled flag"` AND `packages/vault/webauthn/src/extensions/prf/prf.test.ts::"authenticate yields a 32-byte PRF secret"` AND `packages/vault/webauthn/src/extensions/prf/prf.test.ts::"missing PRF surfaces enabled false"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: largeBlob extension support
|
||||
|
||||
The package SHALL accept the `largeBlob` extension input on `register` (`{ support: "required" | "preferred" }`) and `authenticate` (`{ read: true } | { write: Uint8Array }`) per W3C WebAuthn L3 §10.1.3. On registration, the result SHALL surface `extensions.largeBlob.supported: boolean`. On authentication-read, the result SHALL surface `extensions.largeBlob.blob: Uint8Array | undefined`. On authentication-write, the result SHALL surface `extensions.largeBlob.written: boolean`.
|
||||
|
||||
#### Scenario: register surfaces largeBlob supported flag
|
||||
|
||||
- **GIVEN** a stub registration with `extensions.largeBlob.supported === true`
|
||||
- **WHEN** `register({ ..., extensions: { largeBlob: { support: "required" } } })` is called
|
||||
- **THEN** the resolved `extensions.largeBlob.supported === true`
|
||||
|
||||
#### Scenario: authenticate-read returns the blob
|
||||
|
||||
- **GIVEN** a stub assertion with `extensions.largeBlob.blob` set to a known byte string
|
||||
- **WHEN** `authenticate({ ..., extensions: { largeBlob: { read: true } } })` is called
|
||||
- **THEN** the resolved `extensions.largeBlob.blob` equals the known byte string
|
||||
|
||||
#### Scenario: authenticate-write reports success
|
||||
|
||||
- **GIVEN** a stub assertion with `extensions.largeBlob.written === true`
|
||||
- **WHEN** `authenticate({ ..., extensions: { largeBlob: { write: bytes } } })` is called
|
||||
- **THEN** the resolved `extensions.largeBlob.written === true`
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/extensions/large-blob/large-blob.test.ts::"register surfaces largeBlob supported flag"` AND `packages/vault/webauthn/src/extensions/large-blob/large-blob.test.ts::"authenticate-read returns the blob"` AND `packages/vault/webauthn/src/extensions/large-blob/large-blob.test.ts::"authenticate-write reports success"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: WebAuthnAdapter abstraction
|
||||
|
||||
The package SHALL export a `WebAuthnAdapter` interface declaring `create(options): Promise<PublicKeyCredential>`, `get(options): Promise<PublicKeyCredential>`, `isSupported(): boolean`, and `isConditionalMediationAvailable(): Promise<boolean>`. The package SHALL also export `defaultWebAuthnAdapter()` returning the production adapter that binds `globalThis.navigator.credentials`. When `globalThis.navigator?.credentials?.create` is missing, `defaultWebAuthnAdapter()` SHALL throw `WEBAUTHN.NOT_SUPPORTED`. Tests SHALL be able to inject a deterministic stub adapter via every public entry point.
|
||||
|
||||
#### Scenario: defaultWebAuthnAdapter throws when navigator.credentials missing
|
||||
|
||||
- **GIVEN** a test environment where `globalThis.navigator.credentials` is undefined
|
||||
- **WHEN** `defaultWebAuthnAdapter()` is called
|
||||
- **THEN** the call throws `WEBAUTHN.NOT_SUPPORTED`
|
||||
|
||||
#### Scenario: register accepts an injected stub adapter
|
||||
|
||||
- **GIVEN** a stub `WebAuthnAdapter` whose `create` resolves to a captured fixture
|
||||
- **WHEN** `register({ ..., adapter: stub })` is called
|
||||
- **THEN** `stub.create` is invoked exactly once with the assembled `PublicKeyCredentialCreationOptions`
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/adapter/adapter.test.ts::"defaultWebAuthnAdapter throws WEBAUTHN.NOT_SUPPORTED when navigator.credentials missing"` AND `packages/vault/webauthn/src/register/register.test.ts::"register accepts an injected stub adapter"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath layout and tier separation
|
||||
|
||||
The package SHALL declare subpath exports such that `./parser` and `./verifier` import zero DOM types and can be consumed by server-side relying-party verifiers. The browser-tier root barrel (`@suaveplan/webauthn`) MAY reference `navigator`, `PublicKeyCredential`, and `CredentialsContainer`. Each per-format attestation verifier SHALL be reachable via a dedicated subpath (`./attestation/<fmt>`).
|
||||
|
||||
#### Scenario: parser subpath has no DOM type references
|
||||
|
||||
- **WHEN** the dep-graph spec-conformance harness inspects every TypeScript file under `src/parser/`
|
||||
- **THEN** zero references to `Document`, `Window`, `Navigator`, `CredentialsContainer`, or `PublicKeyCredential` are found
|
||||
|
||||
#### Scenario: verifier subpath has no DOM type references
|
||||
|
||||
- **WHEN** the harness inspects `src/verifier/` and `src/attestation/`
|
||||
- **THEN** zero DOM-type references are found
|
||||
|
||||
#### Scenario: every documented subpath resolves
|
||||
|
||||
- **WHEN** every subpath listed in the proposal is imported in a consumer test fixture
|
||||
- **THEN** every import resolves with no `Module not found` errors
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/tests/dep-graph.test.ts::"parser subpath has no DOM type references"` AND `packages/vault/webauthn/tests/dep-graph.test.ts::"verifier subpath has no DOM type references"` AND `packages/vault/webauthn/src/index.test.ts::"every documented subpath export resolves"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: DOMException to typed error mapping
|
||||
|
||||
The package SHALL map every native `DOMException.name` raised by `navigator.credentials.create` / `get` to a typed `WebAuthnError` at the boundary so raw `DOMException` instances never leak to callers. The mapping SHALL be: `NotAllowedError` → `WEBAUTHN.USER_CANCELLED`, `InvalidStateError` → `WEBAUTHN.INVALID_STATE`, `SecurityError` → `WEBAUTHN.SECURITY_ERROR`, `AbortError` → `WEBAUTHN.ABORTED`, `NotSupportedError` → `WEBAUTHN.NOT_SUPPORTED`. Unknown `DOMException.name` values SHALL map to `WEBAUTHN.MALFORMED_RESPONSE` with the original `DOMException` attached as `cause`.
|
||||
|
||||
#### Scenario: maps NotAllowedError to USER_CANCELLED
|
||||
|
||||
- **GIVEN** a stub adapter whose `create` rejects with `new DOMException("denied", "NotAllowedError")`
|
||||
- **WHEN** `register(...)` is called
|
||||
- **THEN** the rejection is a `WebAuthnError` with `code === "WEBAUTHN.USER_CANCELLED"` and `cause` equal to the original `DOMException`
|
||||
|
||||
#### Scenario: maps AbortError to ABORTED
|
||||
|
||||
- **GIVEN** a stub adapter whose `get` rejects with `new DOMException("cancelled", "AbortError")`
|
||||
- **WHEN** `authenticate(...)` is called
|
||||
- **THEN** the rejection is a `WebAuthnError` with `code === "WEBAUTHN.ABORTED"`
|
||||
|
||||
#### Scenario: unknown DOMException name maps to MALFORMED_RESPONSE
|
||||
|
||||
- **GIVEN** a stub adapter whose `create` rejects with `new DOMException("weird", "WeirdError")`
|
||||
- **WHEN** `register(...)` is called
|
||||
- **THEN** the rejection is a `WebAuthnError` with `code === "WEBAUTHN.MALFORMED_RESPONSE"` and `cause` set to the original `DOMException`
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/register/register.test.ts::"maps NotAllowedError to WEBAUTHN.USER_CANCELLED"` AND `packages/vault/webauthn/src/authenticate/authenticate.test.ts::"maps AbortError to WEBAUTHN.ABORTED"` AND `packages/vault/webauthn/src/register/register.test.ts::"maps unknown DOMException to WEBAUTHN.MALFORMED_RESPONSE"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Error code registry
|
||||
|
||||
The package SHALL define every error code listed in the enterprise plan via `defineCodes("WEBAUTHN", [...])`: `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 SHALL be raised by at least one production path covered by tests. Per `openspec/AGENTS.md` §2c, no code in the registry may exist solely to placate the conformance gate.
|
||||
|
||||
#### Scenario: every WEBAUTHN code has a production caller
|
||||
|
||||
- **WHEN** the spec-conformance harness scans `src/` for `throw new WebAuthnError(...)` and `verifyAssertion` discriminated-union returns
|
||||
- **THEN** every code in the registry is referenced by at least one production-side call site
|
||||
|
||||
#### Scenario: UV_NOT_PERFORMED raised when userVerification required and UV flag absent
|
||||
|
||||
- **GIVEN** an assertion whose `authenticatorData.flags.userVerified === false`
|
||||
- **WHEN** `authenticate({ ..., userVerification: "required" })` is called
|
||||
- **THEN** the call rejects with `WEBAUTHN.UV_NOT_PERFORMED`
|
||||
|
||||
#### Scenario: UP_NOT_PERFORMED raised when UP flag absent
|
||||
|
||||
- **GIVEN** an assertion whose `authenticatorData.flags.userPresent === false`
|
||||
- **WHEN** `authenticate(...)` is called
|
||||
- **THEN** the call rejects with `WEBAUTHN.UP_NOT_PERFORMED`
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/errors/errors.test.ts::"every WEBAUTHN code has at least one production caller"` AND `packages/vault/webauthn/src/authenticate/authenticate.test.ts::"raises UV_NOT_PERFORMED when UV required and flag absent"` AND `packages/vault/webauthn/src/authenticate/authenticate.test.ts::"raises UP_NOT_PERFORMED when UP flag absent"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod boundary schemas
|
||||
|
||||
The package SHALL declare Zod schemas at every public surface: `WebAuthnRegisterOptionsSchema`, `WebAuthnAuthenticateOptionsSchema`, `RegisterResultSchema`, `AuthenticateResultSchema`, `ParsedAuthDataSchema`, `ParsedAttestationStmtSchema`, `CoseKeySchema`, `ClientDataJSONSchema`. Inputs that fail validation SHALL throw `WebAuthnError` with code `WEBAUTHN.MALFORMED_RESPONSE` (for parsed bytes) or with the framework's standard `INVALID_OPTIONS` semantics (for caller-supplied options).
|
||||
|
||||
#### Scenario: register rejects malformed options
|
||||
|
||||
- **WHEN** `register({ rp: null as unknown as RpEntity, ...rest })` is called
|
||||
- **THEN** the call rejects with a typed validation error before any adapter call is made
|
||||
|
||||
#### Scenario: parser rejects invalid CoseKey shape
|
||||
|
||||
- **GIVEN** authenticator-data bytes whose embedded CBOR COSE_Key is missing the `kty` label
|
||||
- **WHEN** `parseAuthenticatorData(bytes)` is called
|
||||
- **THEN** the call throws `WEBAUTHN.MALFORMED_RESPONSE`
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/register/register.test.ts::"rejects malformed options before adapter call"` AND `packages/vault/webauthn/src/parser/cose-key.test.ts::"rejects CoseKey missing kty label"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AbortSignal threading
|
||||
|
||||
The package SHALL accept an `AbortSignal` on both `register` and `authenticate` and SHALL forward it into the underlying `navigator.credentials.{create,get}` call. Pre-aborted signals SHALL cause the call to reject with `WEBAUTHN.ABORTED` before any adapter invocation. Mid-flight aborts SHALL surface `AbortError` from the native API and map to `WEBAUTHN.ABORTED` per the DOMException-mapping requirement.
|
||||
|
||||
#### Scenario: pre-aborted signal short-circuits before adapter call
|
||||
|
||||
- **GIVEN** an `AbortController` whose signal is already aborted
|
||||
- **WHEN** `register({ ..., signal: controller.signal })` is called
|
||||
- **THEN** the call rejects with `WEBAUTHN.ABORTED` and the adapter's `create` is never invoked
|
||||
|
||||
#### Scenario: mid-flight abort surfaces ABORTED
|
||||
|
||||
- **GIVEN** an `AbortController` aborted after the adapter's `create` is invoked
|
||||
- **WHEN** the adapter rejects with `AbortError`
|
||||
- **THEN** the `register` call rejects with `WEBAUTHN.ABORTED`
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/register/register.test.ts::"pre-aborted signal short-circuits before adapter call"` AND `packages/vault/webauthn/src/register/register.test.ts::"mid-flight abort surfaces WEBAUTHN.ABORTED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Constant-time comparisons
|
||||
|
||||
The package SHALL compare every challenge, every `rpIdHash`, and every credential id using `constantTimeEqual` from `@suaveplan/crypto-utils`. Naive equality (`===` or `Buffer.equals`) SHALL NOT be used for any comparison whose mismatch would surface a `WEBAUTHN.*` error code.
|
||||
|
||||
#### Scenario: challenge mismatch detected even when only the last byte differs
|
||||
|
||||
- **GIVEN** a 32-byte challenge whose final byte is flipped in the captured response
|
||||
- **WHEN** `register` validates the response
|
||||
- **THEN** the call rejects with `WEBAUTHN.CHALLENGE_MISMATCH` and the comparison takes time independent of the byte index of the difference
|
||||
|
||||
#### Scenario: rpIdHash mismatch detected even when only the first byte differs
|
||||
|
||||
- **GIVEN** an `expectedRpIdHash` whose first byte differs from the assertion's
|
||||
- **WHEN** `verifyAssertion(...)` is called
|
||||
- **THEN** the result is `{ ok: false, code: "WEBAUTHN.RPID_HASH_MISMATCH" }`
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/register/register.test.ts::"detects challenge mismatch when only last byte differs"` AND `packages/vault/webauthn/src/verifier/verify-assertion.test.ts::"detects rpIdHash mismatch when only first byte differs"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Observability surface
|
||||
|
||||
The package SHALL emit OTel metrics on every production path. Required instruments: `webauthn.register_duration_ms{outcome}` histogram, `webauthn.authenticate_duration_ms{outcome}` histogram, `webauthn.outcome_total{op,outcome}` counter (`op` ∈ `register | authenticate`, `outcome` ∈ `ok | error | aborted`), `webauthn.attestation_format_total{fmt}` counter (`fmt` cardinality bounded to the seven IANA-registered formats plus `unknown`), `webauthn.errors_total{code}` counter keyed on the WebAuthn error registry. Each instrument SHALL be assertable against an in-memory `MeterProvider` in tests.
|
||||
|
||||
#### Scenario: register success records duration_ms with outcome=ok
|
||||
|
||||
- **GIVEN** an in-memory `MeterProvider` configured for the package
|
||||
- **WHEN** `register(...)` resolves successfully
|
||||
- **THEN** `webauthn.register_duration_ms{outcome=ok}` records exactly one observation, and `webauthn.outcome_total{op=register,outcome=ok}` increments by 1
|
||||
|
||||
#### Scenario: failed assertion increments errors_total with the code label
|
||||
|
||||
- **GIVEN** an in-memory `MeterProvider`
|
||||
- **WHEN** `authenticate(...)` rejects with `WEBAUTHN.SIGNATURE_INVALID`
|
||||
- **THEN** `webauthn.errors_total{code="WEBAUTHN.SIGNATURE_INVALID"}` increments by 1, and `webauthn.outcome_total{op=authenticate,outcome=error}` increments by 1
|
||||
|
||||
#### Scenario: attestation_format_total increments per dispatched fmt
|
||||
|
||||
- **WHEN** `verifyAttestation` dispatches against a `packed` attestation
|
||||
- **THEN** `webauthn.attestation_format_total{fmt=packed}` increments by 1
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/observability/metrics.test.ts::"register success records duration_ms with outcome=ok"` AND `packages/vault/webauthn/src/observability/metrics.test.ts::"failed assertion increments errors_total with code label"` AND `packages/vault/webauthn/src/observability/metrics.test.ts::"attestation_format_total increments per dispatched fmt"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Test-vector bundle
|
||||
|
||||
The package SHALL ship a bundle of captured authenticator responses under `src/__fixtures__/` covering every attestation format. The bundle SHALL include real-device captures from a Yubikey 5 (`packed` and `fido-u2f`), Windows Hello (`tpm`), macOS Touch ID (`apple`), Chrome Software Authenticator (`none`), and an Android Pixel platform authenticator (`android-key` and `android-safetynet`). Every fixture set SHALL include at least one corruption variant (signature flipped, `rpIdHash` zeroed, or `signCount` frozen) for negative-path tests. Fixtures SHALL be generated offline; CI SHALL NOT depend on a live authenticator.
|
||||
|
||||
#### Scenario: every attestation format has at least one good vector
|
||||
|
||||
- **WHEN** the conformance harness inventories `src/__fixtures__/`
|
||||
- **THEN** at least one good vector exists for each of `packed`, `fido-u2f`, `tpm`, `android-key`, `android-safetynet`, `apple`, `none`
|
||||
|
||||
#### Scenario: every fixture set has a corruption variant
|
||||
|
||||
- **WHEN** the conformance harness inventories `src/__fixtures__/`
|
||||
- **THEN** every format directory contains at least one `*-flipped-sig.bin` or `*-zeroed-rpid.bin` or `*-frozen-counter.bin` fixture
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/tests/dep-graph.test.ts::"every attestation format has at least one good fixture"` AND `packages/vault/webauthn/tests/dep-graph.test.ts::"every fixture set has at least one corruption variant"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Property-based parser tests
|
||||
|
||||
The package SHALL include `fast-check` property tests for the authenticator-data parser and the attestation-object decoder. The properties SHALL include: (a) the parser never throws an untyped error — every rejection is a `WebAuthnError` with code `WEBAUTHN.MALFORMED_RESPONSE`; (b) any byte string the parser accepts can be re-parsed identically (round-trip stability); (c) flipping any single bit in a valid frame either yields a valid different parse or a typed rejection — never a panic.
|
||||
|
||||
#### Scenario: parser never panics on arbitrary byte strings
|
||||
|
||||
- **WHEN** fast-check generates 1,000 arbitrary `Uint8Array` inputs and feeds them to `parseAuthenticatorData`
|
||||
- **THEN** every rejection is a `WebAuthnError` with code `WEBAUTHN.MALFORMED_RESPONSE`; no `TypeError`, `RangeError`, or untyped `Error` escapes
|
||||
|
||||
#### Scenario: round-trip stability holds for accepted frames
|
||||
|
||||
- **GIVEN** a frame that `parseAuthenticatorData` accepts
|
||||
- **WHEN** the parsed result is re-serialised and re-parsed
|
||||
- **THEN** the second parse produces a deeply-equal result
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/src/parser/authenticator-data.property.test.ts::"parser never panics on arbitrary byte strings"` AND `packages/vault/webauthn/src/parser/authenticator-data.property.test.ts::"round-trip stability holds for accepted frames"` AND `packages/vault/webauthn/src/parser/attestation-object.property.test.ts::"attestation-object decoder never panics"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CBOR dependency on `@suaveplan/cbor`
|
||||
|
||||
The package SHALL consume `@suaveplan/cbor` for every CBOR-encoded surface: COSE_Key decoding per RFC 9052 §7, attestation-object envelope decoding per CTAP 2.1 §6.5, and CBOR-encoded authenticator-data extensions per W3C WebAuthn L3 §9. The package SHALL NOT bundle a private CBOR implementation. The implementation tasks for this requirement are gated on the `enterprise-cbor` change archiving first.
|
||||
|
||||
#### Scenario: COSE_Key decoder consumes @suaveplan/cbor
|
||||
|
||||
- **WHEN** the spec-conformance harness inspects `src/parser/cose-key.ts`
|
||||
- **THEN** the file imports from `@suaveplan/cbor` (or one of its subpaths) and contains no inline CBOR major-type / additional-information branching
|
||||
|
||||
#### Scenario: attestation-object decoder consumes @suaveplan/cbor
|
||||
|
||||
- **WHEN** the harness inspects `src/parser/attestation-object.ts`
|
||||
- **THEN** the file imports from `@suaveplan/cbor`
|
||||
|
||||
**Verified by:** `packages/vault/webauthn/tests/dep-graph.test.ts::"COSE_Key decoder consumes @suaveplan/cbor"` AND `packages/vault/webauthn/tests/dep-graph.test.ts::"attestation-object decoder consumes @suaveplan/cbor"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
165
openspec/archive/2026-05-11-enterprise-webauthn/tasks.md
Normal file
165
openspec/archive/2026-05-11-enterprise-webauthn/tasks.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Tasks — `@suaveplan/webauthn` (WebAuthn Level 3 client + parser + verifier)
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened. Do not tick a box for a test that doesn't exist or doesn't pin the requirement it claims to verify.
|
||||
|
||||
## 0. Blocking prerequisite
|
||||
|
||||
- [ ] 0.1 `enterprise-cbor` change archived and `@suaveplan/cbor` published from genesis (COSE / CTAP CBOR profile available). **No implementation task in §2 may start until this lands.** Spec / proposal / fixture-prep tasks (§1, §3) are unblocked.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/webauthn.md` end-to-end
|
||||
- [x] 1.2 Read W3C WebAuthn L3 §5, §6, §7, §8, §9, §10
|
||||
- [x] 1.3 Read CTAP 2.1 §6.5, RFC 9052 §7, IANA WebAuthn registries
|
||||
- [x] 1.4 Read `openspec/AGENTS.md` §13 and §14
|
||||
- [x] 1.5 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.6 Fill in `## Source mapping` table — every requirement cites enterprise-doc lines + a W3C / CTAP / RFC anchor
|
||||
- [x] 1.7 Draft `specs/webauthn/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.8 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
### 2.1 Adapter abstraction
|
||||
|
||||
- [x] 2.1.1 `src/adapter/adapter.ts` — `WebAuthnAdapter` interface (`create`, `get`, `isSupported`, `isConditionalMediationAvailable`)
|
||||
- [x] 2.1.2 `src/adapter/default-adapter.ts` — `defaultWebAuthnAdapter()` binding `globalThis.navigator.credentials`; throws `WEBAUTHN.NOT_SUPPORTED` when missing
|
||||
- [x] 2.1.3 `src/adapter/dom-exception-mapper.ts` — `NotAllowedError` → `WEBAUTHN.USER_CANCELLED`, `InvalidStateError` → `WEBAUTHN.INVALID_STATE`, `SecurityError` → `WEBAUTHN.SECURITY_ERROR`, `AbortError` → `WEBAUTHN.ABORTED`, `NotSupportedError` → `WEBAUTHN.NOT_SUPPORTED`
|
||||
|
||||
### 2.2 Errors + observability
|
||||
|
||||
- [x] 2.2.1 `src/errors/errors.ts` — `WebAuthnError` registry via `defineCodes("WEBAUTHN", [...])` covering every code in the spec delta
|
||||
- [x] 2.2.2 `src/observability/metrics.ts` — `webauthn.register_duration_ms`, `webauthn.authenticate_duration_ms`, `webauthn.outcome_total`, `webauthn.attestation_format_total`, `webauthn.errors_total`
|
||||
|
||||
### 2.3 Parser (universal-tier `./parser` subpath)
|
||||
|
||||
- [x] 2.3.1 `src/parser/client-data.ts` — `parseClientDataJSON`; type pinning + origin + base64url challenge decode + Zod `ClientDataJSONSchema`
|
||||
- [x] 2.3.2 `src/parser/authenticator-data.ts` — `parseAuthenticatorData`; 37-byte fixed-prefix split + flag-byte decoder + attested-credential-data + extensions block
|
||||
- [x] 2.3.3 `src/parser/cose-key.ts` — RFC 9052 §7 COSE_Key decoder consuming `@suaveplan/cbor`; supports ES256 / ES384 / ES512 / EdDSA / RS256 alg labels
|
||||
- [x] 2.3.4 `src/parser/attestation-object.ts` — `parseAttestationObject` per CTAP 2.1 §6.5; extracts `fmt` / `authData` / `attStmt`
|
||||
- [x] 2.3.5 Zod boundary schemas at every export — `MALFORMED_RESPONSE` on mismatch
|
||||
|
||||
### 2.4 Verifier (universal-tier `./verifier` subpath)
|
||||
|
||||
- [x] 2.4.1 `src/verifier/verify-assertion.ts` — signature verify over `authData || sha256(clientDataJSON)` via `@suaveplan/crypto-curve`, dispatched on COSE alg
|
||||
- [x] 2.4.2 `src/verifier/sign-counter.ts` — sign-counter regression detection per W3C L3 §6.1.1
|
||||
- [x] 2.4.3 `src/verifier/verify-attestation.ts` — `verifyAttestation` dispatcher
|
||||
- [x] 2.4.4 `src/verifier/registry.ts` — `defaultAttestationRegistry()` + `registerAttestationFormat(name, verifier)`
|
||||
|
||||
### 2.5 Per-format attestation verifiers
|
||||
|
||||
- [x] 2.5.1 `src/attestation/packed/packed.ts` — W3C L3 §8.2
|
||||
- [x] 2.5.2 `src/attestation/fido-u2f/fido-u2f.ts` — W3C L3 §8.6
|
||||
- [x] 2.5.3 `src/attestation/tpm/tpm.ts` — W3C L3 §8.3
|
||||
- [x] 2.5.4 `src/attestation/android-key/android-key.ts` — W3C L3 §8.4
|
||||
- [x] 2.5.5 `src/attestation/android-safetynet/android-safetynet.ts` — W3C L3 §8.5
|
||||
- [x] 2.5.6 `src/attestation/apple/apple.ts` — W3C L3 §8.8
|
||||
- [x] 2.5.7 `src/attestation/none/none.ts` — W3C L3 §8.7
|
||||
|
||||
### 2.6 Registration (browser-tier root)
|
||||
|
||||
- [x] 2.6.1 `src/register/register.ts` — `register(opts)` building `PublicKeyCredentialCreationOptions`, calling adapter, parsing `AuthenticatorAttestationResponse`
|
||||
- [x] 2.6.2 Client-data validator wired (type / origin / challenge constant-time-equals)
|
||||
- [x] 2.6.3 `expectedRpIdHash` constant-time compare
|
||||
- [x] 2.6.4 AbortSignal forwarded to `navigator.credentials.create`
|
||||
- [x] 2.6.5 OTel `webauthn.register_duration_ms` recorded with `outcome` label
|
||||
|
||||
### 2.7 Authentication (browser-tier root)
|
||||
|
||||
- [x] 2.7.1 `src/authenticate/authenticate.ts` — `authenticate(opts)` building `PublicKeyCredentialRequestOptions`, calling adapter, parsing `AuthenticatorAssertionResponse`
|
||||
- [x] 2.7.2 Client-data validator pinned to `webauthn.get`
|
||||
- [x] 2.7.3 Conditional mediation passthrough; `isConditionalMediationAvailable()` helper
|
||||
- [x] 2.7.4 AbortSignal forwarded to `navigator.credentials.get`
|
||||
- [x] 2.7.5 OTel `webauthn.authenticate_duration_ms` recorded with `outcome` label
|
||||
|
||||
### 2.8 Extensions
|
||||
|
||||
- [x] 2.8.1 `src/extensions/prf/prf.ts` — PRF eval input on register + authenticate; result parsing; `enabled === false` fallback
|
||||
- [x] 2.8.2 `src/extensions/large-blob/large-blob.ts` — `support` on register; `read` / `write` on authenticate; result shape
|
||||
|
||||
### 2.9 Root barrel + subpath exports
|
||||
|
||||
- [x] 2.9.1 `src/index.ts` — explicit named re-exports (no `export *`)
|
||||
- [x] 2.9.2 `package.json` `exports` map covers every subpath listed in the proposal
|
||||
- [x] 2.9.3 `package.json` `browser` field set; `engines` does not pin Node-only
|
||||
|
||||
## 3. Test-vector bundle
|
||||
|
||||
- [ ] 3.1 `src/__fixtures__/yubikey-packed/` — Yubikey 5 packed attestation good vector + corrupted variants
|
||||
- [ ] 3.2 `src/__fixtures__/yubikey-fido-u2f/` — Yubikey 5 fido-u2f good vector
|
||||
- [ ] 3.3 `src/__fixtures__/windows-hello-tpm/` — Windows Hello TPM attestation good vector
|
||||
- [ ] 3.4 `src/__fixtures__/macos-touch-id-apple/` — Apple anonymous attestation good vector
|
||||
- [ ] 3.5 `src/__fixtures__/chrome-software-none/` — Chrome software authenticator `none` format
|
||||
- [ ] 3.6 `src/__fixtures__/android-key/` and `android-safetynet/` — Pixel platform-authenticator vectors
|
||||
- [ ] 3.7 Each fixture set has at least one corrupted variant (signature flipped, rpIdHash zeroed, signCount frozen)
|
||||
- [ ] 3.8 Fixtures generated offline; CI does not depend on a real authenticator
|
||||
|
||||
## 4. Tests (runner-neutral imports per CLAUDE.md §11 — `@suaveplan/testing/runner`)
|
||||
|
||||
- [ ] 4.1 `src/register/register.test.ts` — happy path, client-data mismatches, AbortSignal, DOMException mapping
|
||||
- [ ] 4.2 `src/authenticate/authenticate.test.ts` — happy path, conditional mediation, AbortSignal
|
||||
- [ ] 4.3 `src/parser/authenticator-data.test.ts` — every flag combination, attested-credential-data, extensions block
|
||||
- [ ] 4.4 `src/parser/attestation-object.test.ts` — `fmt` / `authData` / `attStmt` extraction
|
||||
- [ ] 4.5 `src/parser/client-data.test.ts` — type pinning, base64url decode
|
||||
- [ ] 4.6 `src/parser/cose-key.test.ts` — RFC 9052 §7 round-trip per alg
|
||||
- [ ] 4.7 `src/verifier/verify-assertion.test.ts` — every alg, signature-flip negative, sign-counter regression boundaries
|
||||
- [ ] 4.8 `src/verifier/verify-attestation.test.ts` — registry dispatch, unknown-format error
|
||||
- [ ] 4.9 Per-format `src/attestation/<fmt>/<fmt>.test.ts` — good + corrupted vectors for `packed`, `fido-u2f`, `tpm`, `android-key`, `android-safetynet`, `apple`, `none`
|
||||
- [ ] 4.10 `src/extensions/prf/prf.test.ts` — register + authenticate eval round-trip + length assertion + missing-PRF fallback
|
||||
- [ ] 4.11 `src/extensions/large-blob/large-blob.test.ts` — support flag, read, write
|
||||
- [ ] 4.12 `src/adapter/adapter.test.ts` — `NOT_SUPPORTED` when missing, conditional-mediation check
|
||||
- [ ] 4.13 `src/errors/errors.test.ts` — every code thrown by ≥1 production path; DOMException mapping
|
||||
- [ ] 4.14 `src/observability/metrics.test.ts` — every histogram + counter recorded against in-memory `MeterProvider`
|
||||
- [ ] 4.15 `src/parser/authenticator-data.property.test.ts` — fast-check fuzz, parser never panics
|
||||
- [ ] 4.16 `src/parser/attestation-object.property.test.ts` — fast-check fuzz of envelope decoder
|
||||
- [ ] 4.17 `src/index.test.ts` — every documented subpath resolves
|
||||
- [ ] 4.18 `tests/dep-graph.test.ts` — `./parser` and `./verifier` import zero DOM types
|
||||
- [ ] 4.19 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async)
|
||||
|
||||
## 5. Documentation
|
||||
|
||||
- [x] 5.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
|
||||
- [x] 5.2 README.md with Modules table linking every co-located `.md`
|
||||
- [x] 5.3 Update `CLAUDE.md` package listing under the Vault section
|
||||
- [x] 5.4 README cites W3C WebAuthn L3 / CTAP 2.1 / RFC 9052 anchors
|
||||
|
||||
## 6. Benchmarks (mitata)
|
||||
|
||||
- [ ] 6.1 `src/__benchmarks__/parse_authdata.bench.ts`
|
||||
- [ ] 6.2 `src/__benchmarks__/verify_assertion_es256.bench.ts`
|
||||
- [ ] 6.3 `src/__benchmarks__/verify_attestation_packed.bench.ts`
|
||||
- [ ] 6.4 `src/__benchmarks__/verify_attestation_tpm.bench.ts`
|
||||
- [ ] 6.5 Baseline numbers committed in README
|
||||
|
||||
## 7. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 7.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info across the whole monorepo
|
||||
- [ ] 7.2 `bunx turbo lint typecheck test --filter=@suaveplan/webauthn` — green
|
||||
- [ ] 7.3 `bunx turbo test:coverage --filter=@suaveplan/webauthn` — 100/100/100/100
|
||||
- [ ] 7.4 `bun scripts/openspec-conformance.ts @suaveplan/webauthn` — exit 0, no `error` findings
|
||||
- [ ] 7.5 `bunx turbo build --filter=@suaveplan/webauthn` — green
|
||||
|
||||
## 8. Pre-archive 7-row checklist (AGENTS.md §13)
|
||||
|
||||
- [ ] 8.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim (`AttestationType`, `WebAuthnErrorCode`, `CoseAlg`)
|
||||
- [ ] 8.2 Zod schemas wired at every declared boundary (register / authenticate options, parsed structures, COSE_Key)
|
||||
- [ ] 8.3 Every error code in `errors.ts` thrown by at least one production path
|
||||
- [ ] 8.4 Strict-mode behaviors tested both on and off (UV required vs preferred; attestation `direct` vs `none`)
|
||||
- [ ] 8.5 OTel instruments emitted from production paths and asserted with a real in-memory `MeterProvider`
|
||||
- [ ] 8.6 Lifecycle dedup tested where claimed (AbortSignal mid-flight, conditional-mediation availability cache)
|
||||
- [ ] 8.7 No retroactive tick — every box above reflects work done in this change
|
||||
|
||||
## 9. Banned-pattern + barrel + runner-import scans
|
||||
|
||||
- [ ] 9.1 `grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\.skip\|\.only" packages/vault/webauthn/src/ --include="*.ts"` — zero output
|
||||
- [ ] 9.2 `grep -rn "^\s*export\s\+\(type\s\+\)\?\*\s\+from" packages/vault/webauthn/src/ --include="index.ts"` — zero output (root `src/index.ts` permitted; feature barrels must use named exports)
|
||||
- [ ] 9.3 `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/vault/webauthn/src/ --include="*.ts" --include="*.tsx"` — zero output (package is not on the testing-infra exempt allowlist)
|
||||
|
||||
## 10. Archive
|
||||
|
||||
- [ ] 10.1 Fold spec delta into `openspec/specs/webauthn/spec.md`
|
||||
- [ ] 10.2 Move `openspec/changes/enterprise-webauthn/` → `openspec/archive/<yyyy-mm-dd>-enterprise-webauthn/`
|
||||
- [ ] 10.3 Refresh `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`
|
||||
- [ ] 10.4 Refresh `MISSING.yml` via `bun scripts/generate-missing.ts`
|
||||
- [ ] 10.5 Move `@suaveplan/webauthn` row in `STATUS.md` from `Pending` → `Completed`
|
||||
- [ ] 10.6 Update `SUMMARY.md` with per-package paragraph
|
||||
- [ ] 10.7 `bunx markdownlint-cli2 --fix` and stage results
|
||||
Reference in New Issue
Block a user