# 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` 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: })` 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: })` 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` 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: })` 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/` 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` 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`, `get(options): Promise`, `isSupported(): boolean`, and `isConditionalMediationAvailable(): Promise`. 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/`). #### 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"`