39 KiB
Capability Spec: webauthn
Package: @suaveplan/webauthn
Status: ACTIVE — canonical (folded from enterprise-webauthn change 2026-05-11).
Every requirement below carries a
**Verified by:**line at authoring time. (Seeopenspec/AGENTS.mdSection 13.)
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
PublicKeyCredentialwith apackedattestation - THEN the resolved value contains
credentialId: Uint8Array,publicKey: CoseKey,attestationObject: Uint8Array,clientDataJSON: Uint8Array,transports: string[],authenticatorData: ParsedAuthData,attestationStatement: ParsedAttestationStmt, andextensionskeyed by extension id
Scenario: forwards AbortSignal to the adapter
- GIVEN an
AbortControllerwhose signal is passed viaopts.signal - WHEN the controller is aborted before the adapter resolves
- THEN
registerrejects withWEBAUTHN.ABORTEDand the adapter receives the same signal in itscreatecall
Verified by: packages/crypto/webauthn/src/register/register.test.ts::"returns a typed RegisterResult on packed attestation" AND packages/crypto/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
AuthenticatorAttestationResponsewhoseclientDataJSON.type === "webauthn.get" - WHEN
registeris 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/crypto/webauthn/src/register/register.test.ts::"rejects clientDataJSON.type !== webauthn.create" AND packages/crypto/webauthn/src/register/register.test.ts::"rejects origin mismatch" AND packages/crypto/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 flags0x00 - THEN the result has
rpIdHash.byteLength === 32,flags === 0x00, andsignCountmatches the caller-encoded value
Scenario: parses attested-credential-data when AT flag set
- WHEN
parseAuthenticatorData(bytes)is called against a frame withflags & 0x40(AT) set - THEN the result has
aaguid.byteLength === 16,credentialId.byteLength === credentialIdLength, andcredentialPublicKeyis a typedCoseKey
Scenario: rejects truncated frame
- WHEN
parseAuthenticatorData(bytes.slice(0, 30))is called - THEN the call throws
WEBAUTHN.MALFORMED_RESPONSE
Verified by: packages/crypto/webauthn/src/parser/authenticator-data.test.ts::"parses 37-byte fixed prefix" AND packages/crypto/webauthn/src/parser/authenticator-data.test.ts::"parses attested-credential-data when AT flag set" AND packages/crypto/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
parseAuthenticatorDatais called against a frame withflags === 0x01 - THEN the result has
flags.userPresent === trueand 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,extensionsPresentalltrue, andbackupEligible,backupStatebothfalse
Verified by: packages/crypto/webauthn/src/parser/authenticator-data.test.ts::"decodes UP-only flag byte" AND packages/crypto/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 optionaluserHandle: Uint8Array | null
Scenario: forwards conditional mediation flag
- WHEN
authenticate({ ..., mediation: "conditional" })is called - THEN the underlying
navigator.credentials.getcall receivesmediation: "conditional"
Verified by: packages/crypto/webauthn/src/authenticate/authenticate.test.ts::"returns a typed AuthenticateResult on success" AND packages/crypto/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
authenticateis 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/crypto/webauthn/src/authenticate/authenticate.test.ts::"rejects clientDataJSON.type !== webauthn.get" AND packages/crypto/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
CoseKeyand 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.rpIdHashdiffers fromexpectedRpIdHash - WHEN
verifyAssertion(...)is called - THEN the result is
{ ok: false, code: "WEBAUTHN.RPID_HASH_MISMATCH" }
Scenario: verifies EdDSA / Ed25519 signature
- GIVEN a stored
CoseKeywithalg === -8and a captured assertion signed with Ed25519 - WHEN
verifyAssertion(...)is called - THEN the result is
{ ok: true }
Verified by: packages/crypto/webauthn/src/verifier/verify-assertion.test.ts::"verifies ES256 signature" AND packages/crypto/webauthn/src/verifier/verify-assertion.test.ts::"rejects flipped signature" AND packages/crypto/webauthn/src/verifier/verify-assertion.test.ts::"rejects rpIdHash mismatch" AND packages/crypto/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 = 5and an assertion withsignCount = 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 = 10and an assertion withsignCount = 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 = 0and an assertion withsignCount = 0 - WHEN
verifyAssertion(...)is called against an otherwise-valid assertion - THEN the result is
{ ok: true }
Verified by: packages/crypto/webauthn/src/verifier/verify-assertion.test.ts::"detects equal-counter regression" AND packages/crypto/webauthn/src/verifier/verify-assertion.test.ts::"detects strict-decrease regression" AND packages/crypto/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
packedverifier - WHEN
verifyAttestation(result, expectedRpIdHash, registry)is called against a packed attestation - THEN the registry's
packedverifier is invoked and the returnedattestationTypeis one of"basic","self", or"attCA"
Scenario: throws on unregistered format
- GIVEN a registry without an entry for
fmt === "tpm" - WHEN
verifyAttestationis called against a TPM attestation - THEN the call throws
WEBAUTHN.ATTESTATION_FORMAT_UNSUPPORTED
Verified by: packages/crypto/webauthn/src/verifier/verify-attestation.test.ts::"dispatches to the registered packed verifier" AND packages/crypto/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
packedverifier is invoked against the captured Yubikey 5 attestation fixture - THEN the result has
trusted: true,attestationType: "basic", andtrustPath.length >= 1
Scenario: tpm verifier rejects a flipped signature
- GIVEN the captured Windows Hello TPM attestation fixture with one byte of
attStmt.sigflipped - WHEN the
tpmverifier is invoked - THEN the call throws
WEBAUTHN.SIGNATURE_INVALID
Scenario: none verifier accepts and reports attestationType "none"
- WHEN the
noneverifier is invoked against the Chrome software authenticator fixture - THEN the result has
trusted: true,attestationType: "none", andtrustPath.length === 0
Verified by: packages/crypto/webauthn/src/attestation/packed/packed.test.ts::"accepts Yubikey 5 packed good vector" AND packages/crypto/webauthn/src/attestation/tpm/tpm.test.ts::"rejects flipped TPM signature" AND packages/crypto/webauthn/src/attestation/none/none.test.ts::"accepts and reports attestationType none" AND packages/crypto/webauthn/src/attestation/fido-u2f/fido-u2f.test.ts::"accepts Yubikey 5 fido-u2f good vector" AND packages/crypto/webauthn/src/attestation/android-key/android-key.test.ts::"accepts Pixel android-key good vector" AND packages/crypto/webauthn/src/attestation/android-safetynet/android-safetynet.test.ts::"accepts Pixel safetynet good vector" AND packages/crypto/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
verifyAttestationis invoked against a packed attestation - THEN
customVerifieris invoked instead of the built-in
Verified by: packages/crypto/webauthn/src/verifier/verify-attestation.test.ts::"defaultAttestationRegistry contains every built-in format" AND packages/crypto/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.isConditionalMediationAvailableisundefined - WHEN
isConditionalMediationAvailable()is called - THEN the resolved value is
false
Verified by: packages/crypto/webauthn/src/authenticate/authenticate.test.ts::"passes mediation conditional to adapter" AND packages/crypto/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.firstis 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 === falseandextensions.prf.resultsis undefined
Verified by: packages/crypto/webauthn/src/extensions/prf/prf.test.ts::"register surfaces PRF enabled flag" AND packages/crypto/webauthn/src/extensions/prf/prf.test.ts::"authenticate yields a 32-byte PRF secret" AND packages/crypto/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.blobset to a known byte string - WHEN
authenticate({ ..., extensions: { largeBlob: { read: true } } })is called - THEN the resolved
extensions.largeBlob.blobequals 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/crypto/webauthn/src/extensions/large-blob/large-blob.test.ts::"register surfaces largeBlob supported flag" AND packages/crypto/webauthn/src/extensions/large-blob/large-blob.test.ts::"authenticate-read returns the blob" AND packages/crypto/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.credentialsis undefined - WHEN
defaultWebAuthnAdapter()is called - THEN the call throws
WEBAUTHN.NOT_SUPPORTED
Scenario: register accepts an injected stub adapter
- GIVEN a stub
WebAuthnAdapterwhosecreateresolves to a captured fixture - WHEN
register({ ..., adapter: stub })is called - THEN
stub.createis invoked exactly once with the assembledPublicKeyCredentialCreationOptions
Verified by: packages/crypto/webauthn/src/adapter/adapter.test.ts::"defaultWebAuthnAdapter throws WEBAUTHN.NOT_SUPPORTED when navigator.credentials missing" AND packages/crypto/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, orPublicKeyCredentialare found
Scenario: verifier subpath has no DOM type references
- WHEN the harness inspects
src/verifier/andsrc/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 founderrors
Verified by: packages/crypto/webauthn/tests/dep-graph.test.ts::"parser subpath has no DOM type references" AND packages/crypto/webauthn/tests/dep-graph.test.ts::"verifier subpath has no DOM type references" AND packages/crypto/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
createrejects withnew DOMException("denied", "NotAllowedError") - WHEN
register(...)is called - THEN the rejection is a
WebAuthnErrorwithcode === "WEBAUTHN.USER_CANCELLED"andcauseequal to the originalDOMException
Scenario: maps AbortError to ABORTED
- GIVEN a stub adapter whose
getrejects withnew DOMException("cancelled", "AbortError") - WHEN
authenticate(...)is called - THEN the rejection is a
WebAuthnErrorwithcode === "WEBAUTHN.ABORTED"
Scenario: unknown DOMException name maps to MALFORMED_RESPONSE
- GIVEN a stub adapter whose
createrejects withnew DOMException("weird", "WeirdError") - WHEN
register(...)is called - THEN the rejection is a
WebAuthnErrorwithcode === "WEBAUTHN.MALFORMED_RESPONSE"andcauseset to the originalDOMException
Verified by: packages/crypto/webauthn/src/register/register.test.ts::"maps NotAllowedError to WEBAUTHN.USER_CANCELLED" AND packages/crypto/webauthn/src/authenticate/authenticate.test.ts::"maps AbortError to WEBAUTHN.ABORTED" AND packages/crypto/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/forthrow new WebAuthnError(...)andverifyAssertiondiscriminated-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/crypto/webauthn/src/errors/errors.test.ts::"every WEBAUTHN code has at least one production caller" AND packages/crypto/webauthn/src/authenticate/authenticate.test.ts::"raises UV_NOT_PERFORMED when UV required and flag absent" AND packages/crypto/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
ktylabel - WHEN
parseAuthenticatorData(bytes)is called - THEN the call throws
WEBAUTHN.MALFORMED_RESPONSE
Verified by: packages/crypto/webauthn/src/register/register.test.ts::"rejects malformed options before adapter call" AND packages/crypto/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
AbortControllerwhose signal is already aborted - WHEN
register({ ..., signal: controller.signal })is called - THEN the call rejects with
WEBAUTHN.ABORTEDand the adapter'screateis never invoked
Scenario: mid-flight abort surfaces ABORTED
- GIVEN an
AbortControlleraborted after the adapter'screateis invoked - WHEN the adapter rejects with
AbortError - THEN the
registercall rejects withWEBAUTHN.ABORTED
Verified by: packages/crypto/webauthn/src/register/register.test.ts::"pre-aborted signal short-circuits before adapter call" AND packages/crypto/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
registervalidates the response - THEN the call rejects with
WEBAUTHN.CHALLENGE_MISMATCHand 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
expectedRpIdHashwhose 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/crypto/webauthn/src/register/register.test.ts::"detects challenge mismatch when only last byte differs" AND packages/crypto/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
MeterProviderconfigured for the package - WHEN
register(...)resolves successfully - THEN
webauthn.register_duration_ms{outcome=ok}records exactly one observation, andwebauthn.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 withWEBAUTHN.SIGNATURE_INVALID - THEN
webauthn.errors_total{code="WEBAUTHN.SIGNATURE_INVALID"}increments by 1, andwebauthn.outcome_total{op=authenticate,outcome=error}increments by 1
Scenario: attestation_format_total increments per dispatched fmt
- WHEN
verifyAttestationdispatches against apackedattestation - THEN
webauthn.attestation_format_total{fmt=packed}increments by 1
Verified by: packages/crypto/webauthn/src/observability/metrics.test.ts::"register success records duration_ms with outcome=ok" AND packages/crypto/webauthn/src/observability/metrics.test.ts::"failed assertion increments errors_total with code label" AND packages/crypto/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.binor*-zeroed-rpid.binor*-frozen-counter.binfixture
Verified by: packages/crypto/webauthn/tests/dep-graph.test.ts::"every attestation format has at least one good fixture" AND packages/crypto/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
Uint8Arrayinputs and feeds them toparseAuthenticatorData - THEN every rejection is a
WebAuthnErrorwith codeWEBAUTHN.MALFORMED_RESPONSE; noTypeError,RangeError, or untypedErrorescapes
Scenario: round-trip stability holds for accepted frames
- GIVEN a frame that
parseAuthenticatorDataaccepts - WHEN the parsed result is re-serialised and re-parsed
- THEN the second parse produces a deeply-equal result
Verified by: packages/crypto/webauthn/src/parser/authenticator-data.property.test.ts::"parser never panics on arbitrary byte strings" AND packages/crypto/webauthn/src/parser/authenticator-data.property.test.ts::"round-trip stability holds for accepted frames" AND packages/crypto/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/crypto/webauthn/tests/dep-graph.test.ts::"COSE_Key decoder consumes @suaveplan/cbor" AND packages/crypto/webauthn/tests/dep-graph.test.ts::"attestation-object decoder consumes @suaveplan/cbor"