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

39 KiB

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"