562 lines
39 KiB
Markdown
562 lines
39 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec Delta: webauthn (enterprise-webauthn)
|
|
|
|
**Package**: `@suaveplan/webauthn`
|
|
**Status**: PROPOSED — first delta; becomes canonical spec on archive.
|
|
|
|
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
|
|
|
|
## ADDED Requirements
|
|
|
|
### Requirement: Registration entry point
|
|
|
|
The package SHALL export `register(opts: WebAuthnRegisterOptions): Promise<RegisterResult>` per W3C WebAuthn L3 §5.1.3 and §7.1. The function SHALL build a `PublicKeyCredentialCreationOptions` from the supplied `opts`, invoke the `WebAuthnAdapter.create` method, and return a typed `RegisterResult` containing the parsed credential id, COSE public key, raw attestation object, raw client-data JSON, transports list, parsed authenticator data, parsed attestation statement, and any extension results. The function SHALL forward `opts.signal` to the underlying `navigator.credentials.create` call so caller-side cancellation triggers the native `AbortController` path.
|
|
|
|
#### Scenario: returns a typed RegisterResult on success
|
|
|
|
- **WHEN** `register({ rp, user, challenge, pubKeyCredParams: [{ alg: -7, type: "public-key" }], expectedOrigin: "https://example.com", expectedRpIdHash: <sha256("example.com")> })` is called
|
|
- **AND** the adapter returns a valid `PublicKeyCredential` with a `packed` attestation
|
|
- **THEN** the resolved value contains `credentialId: Uint8Array`, `publicKey: CoseKey`, `attestationObject: Uint8Array`, `clientDataJSON: Uint8Array`, `transports: string[]`, `authenticatorData: ParsedAuthData`, `attestationStatement: ParsedAttestationStmt`, and `extensions` keyed by extension id
|
|
|
|
#### Scenario: forwards AbortSignal to the adapter
|
|
|
|
- **GIVEN** an `AbortController` whose signal is passed via `opts.signal`
|
|
- **WHEN** the controller is aborted before the adapter resolves
|
|
- **THEN** `register` rejects with `WEBAUTHN.ABORTED` and the adapter receives the same signal in its `create` call
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/register/register.test.ts::"returns a typed RegisterResult on packed attestation"` AND `packages/vault/webauthn/src/register/register.test.ts::"forwards AbortSignal to navigator.credentials.create"`
|
|
|
|
---
|
|
|
|
### Requirement: Registration client-data validation
|
|
|
|
The package SHALL validate the `clientDataJSON` returned by the authenticator before resolving `register`. The validator SHALL enforce `type === "webauthn.create"`, `origin === expectedOrigin`, and that `challenge` (after base64url decode) constant-time-equals the caller-supplied challenge. All challenge and origin comparisons SHALL use `constantTimeEqual` from `@suaveplan/crypto-utils`. Mismatches SHALL throw `WEBAUTHN.MALFORMED_RESPONSE` (when `type` is wrong), `WEBAUTHN.ORIGIN_MISMATCH` (when `origin` is wrong), or `WEBAUTHN.CHALLENGE_MISMATCH` (when the challenge differs).
|
|
|
|
#### Scenario: rejects an assertion-typed clientDataJSON
|
|
|
|
- **GIVEN** a captured `AuthenticatorAttestationResponse` whose `clientDataJSON.type === "webauthn.get"`
|
|
- **WHEN** `register` is called against this stub adapter
|
|
- **THEN** the call rejects with `WEBAUTHN.MALFORMED_RESPONSE`
|
|
|
|
#### Scenario: rejects an origin mismatch
|
|
|
|
- **GIVEN** a captured response whose `clientDataJSON.origin === "https://attacker.example"`
|
|
- **WHEN** `register({ ..., expectedOrigin: "https://example.com" })` is called
|
|
- **THEN** the call rejects with `WEBAUTHN.ORIGIN_MISMATCH`
|
|
|
|
#### Scenario: rejects a challenge mismatch
|
|
|
|
- **GIVEN** a captured response whose decoded challenge differs from the caller's by a single byte
|
|
- **WHEN** `register({ ..., challenge: <caller> })` is called
|
|
- **THEN** the call rejects with `WEBAUTHN.CHALLENGE_MISMATCH`
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/register/register.test.ts::"rejects clientDataJSON.type !== webauthn.create"` AND `packages/vault/webauthn/src/register/register.test.ts::"rejects origin mismatch"` AND `packages/vault/webauthn/src/register/register.test.ts::"rejects challenge mismatch via constant-time compare"`
|
|
|
|
---
|
|
|
|
### Requirement: Authenticator-data parser
|
|
|
|
The package SHALL export `parseAuthenticatorData(bytes: Uint8Array): ParsedAuthData` from the `./parser` subpath. The parser SHALL split the 37-byte fixed prefix into `rpIdHash[32]`, `flags[1]`, `signCount[4]` per W3C WebAuthn L3 §6.1, then conditionally parse `aaguid[16]`, `credentialIdLength[2]`, `credentialId[L]`, `credentialPublicKey: COSE_Key (CBOR)` when the AT (attested-credential-data) flag is set, and conditionally parse the trailing CBOR `extensions` map when the ED (extensions-present) flag is set. The parser SHALL throw `WEBAUTHN.MALFORMED_RESPONSE` on truncation, on invalid CBOR, or on COSE_Key shape mismatch.
|
|
|
|
#### Scenario: parses fixed prefix on a flag-only frame
|
|
|
|
- **WHEN** `parseAuthenticatorData(bytes)` is called against a 37-byte frame with flags `0x00`
|
|
- **THEN** the result has `rpIdHash.byteLength === 32`, `flags === 0x00`, and `signCount` matches the caller-encoded value
|
|
|
|
#### Scenario: parses attested-credential-data when AT flag set
|
|
|
|
- **WHEN** `parseAuthenticatorData(bytes)` is called against a frame with `flags & 0x40` (AT) set
|
|
- **THEN** the result has `aaguid.byteLength === 16`, `credentialId.byteLength === credentialIdLength`, and `credentialPublicKey` is a typed `CoseKey`
|
|
|
|
#### Scenario: rejects truncated frame
|
|
|
|
- **WHEN** `parseAuthenticatorData(bytes.slice(0, 30))` is called
|
|
- **THEN** the call throws `WEBAUTHN.MALFORMED_RESPONSE`
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/parser/authenticator-data.test.ts::"parses 37-byte fixed prefix"` AND `packages/vault/webauthn/src/parser/authenticator-data.test.ts::"parses attested-credential-data when AT flag set"` AND `packages/vault/webauthn/src/parser/authenticator-data.test.ts::"rejects truncated frame"`
|
|
|
|
---
|
|
|
|
### Requirement: Flag-byte decoder
|
|
|
|
The package SHALL decode the 1-byte `flags` field of authenticator data into a `ParsedAuthDataFlags` object per W3C WebAuthn L3 §6.1: `userPresent` (UP, bit 0), `userVerified` (UV, bit 2), `backupEligible` (BE, bit 3), `backupState` (BS, bit 4), `attestedCredentialData` (AT, bit 6), `extensionsPresent` (ED, bit 7). Reserved bits SHALL be parsed but not surfaced.
|
|
|
|
#### Scenario: decodes UP-only flag byte
|
|
|
|
- **WHEN** `parseAuthenticatorData` is called against a frame with `flags === 0x01`
|
|
- **THEN** the result has `flags.userPresent === true` and every other flag false
|
|
|
|
#### Scenario: decodes UP + UV + AT + ED combined
|
|
|
|
- **WHEN** the frame uses `flags === 0xC5` (UP | UV | AT | ED)
|
|
- **THEN** the result has `userPresent`, `userVerified`, `attestedCredentialData`, `extensionsPresent` all `true`, and `backupEligible`, `backupState` both `false`
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/parser/authenticator-data.test.ts::"decodes UP-only flag byte"` AND `packages/vault/webauthn/src/parser/authenticator-data.test.ts::"decodes UP + UV + AT + ED combined flag byte"`
|
|
|
|
---
|
|
|
|
### Requirement: Authentication entry point
|
|
|
|
The package SHALL export `authenticate(opts: WebAuthnAuthenticateOptions): Promise<AuthenticateResult>` per W3C WebAuthn L3 §5.1.4 and §7.2. The function SHALL build a `PublicKeyCredentialRequestOptions` from `opts`, invoke `WebAuthnAdapter.get`, and return a typed `AuthenticateResult` containing the parsed credential id, signature bytes, parsed authenticator data, raw client-data JSON, and optional `userHandle`. The function SHALL pass `opts.mediation` (`"silent" | "optional" | "conditional" | "required"`) and `opts.signal` through to the adapter unchanged.
|
|
|
|
#### Scenario: returns a typed AuthenticateResult on success
|
|
|
|
- **WHEN** `authenticate({ challenge, rpId, allowCredentials, expectedOrigin, expectedRpIdHash })` is called against a stub returning a valid assertion
|
|
- **THEN** the resolved value contains `credentialId: Uint8Array`, `signature: Uint8Array`, `authenticatorData: ParsedAuthData`, `clientDataJSON: Uint8Array`, and an optional `userHandle: Uint8Array | null`
|
|
|
|
#### Scenario: forwards conditional mediation flag
|
|
|
|
- **WHEN** `authenticate({ ..., mediation: "conditional" })` is called
|
|
- **THEN** the underlying `navigator.credentials.get` call receives `mediation: "conditional"`
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/authenticate/authenticate.test.ts::"returns a typed AuthenticateResult on success"` AND `packages/vault/webauthn/src/authenticate/authenticate.test.ts::"forwards conditional mediation flag"`
|
|
|
|
---
|
|
|
|
### Requirement: Assertion client-data validation
|
|
|
|
The package SHALL validate the `clientDataJSON` returned during authentication. The validator SHALL enforce `type === "webauthn.get"`, `origin === expectedOrigin`, and that the decoded `challenge` constant-time-equals the caller-supplied challenge. Mismatches SHALL throw `WEBAUTHN.MALFORMED_RESPONSE`, `WEBAUTHN.ORIGIN_MISMATCH`, or `WEBAUTHN.CHALLENGE_MISMATCH` respectively.
|
|
|
|
#### Scenario: rejects a registration-typed clientDataJSON
|
|
|
|
- **GIVEN** a stub assertion whose `clientDataJSON.type === "webauthn.create"`
|
|
- **WHEN** `authenticate` is called
|
|
- **THEN** the call rejects with `WEBAUTHN.MALFORMED_RESPONSE`
|
|
|
|
#### Scenario: rejects challenge mismatch on assertion
|
|
|
|
- **GIVEN** a stub assertion whose decoded challenge differs from the caller's
|
|
- **WHEN** `authenticate({ ..., challenge: <caller> })` is called
|
|
- **THEN** the call rejects with `WEBAUTHN.CHALLENGE_MISMATCH`
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/authenticate/authenticate.test.ts::"rejects clientDataJSON.type !== webauthn.get"` AND `packages/vault/webauthn/src/authenticate/authenticate.test.ts::"rejects challenge mismatch on assertion"`
|
|
|
|
---
|
|
|
|
### Requirement: Assertion signature verification
|
|
|
|
The package SHALL export `verifyAssertion(result, storedPublicKey, expectedChallenge, expectedOrigin, expectedRpIdHash, signCounter): VerifyResult` from the `./verifier` subpath. The function SHALL verify the assertion signature over the byte string `authData || sha256(clientDataJSON)` using `@suaveplan/crypto-curve`, with the algorithm derived from the stored `CoseKey.alg` label per RFC 9052 §7. Supported algorithms: ES256 (-7), ES384 (-35), ES512 (-36), EdDSA (-8), RS256 (-257). The function SHALL also verify `result.authenticatorData.rpIdHash` constant-time-equals `expectedRpIdHash` and that the client data validates per the assertion-client-data requirement. The function SHALL return a discriminated-union result `{ ok: true } | { ok: false, code: WebAuthnErrorCode }`.
|
|
|
|
#### Scenario: verifies an ES256 signature
|
|
|
|
- **GIVEN** a stored ES256 `CoseKey` and a captured assertion signed with the matching private key
|
|
- **WHEN** `verifyAssertion(result, storedKey, challenge, origin, rpIdHash, 0)` is called
|
|
- **THEN** the result is `{ ok: true }`
|
|
|
|
#### Scenario: rejects a flipped signature
|
|
|
|
- **GIVEN** the same stored key but an assertion whose signature has one byte flipped
|
|
- **WHEN** `verifyAssertion(...)` is called
|
|
- **THEN** the result is `{ ok: false, code: "WEBAUTHN.SIGNATURE_INVALID" }`
|
|
|
|
#### Scenario: rejects an rpIdHash mismatch
|
|
|
|
- **GIVEN** an assertion whose `authenticatorData.rpIdHash` differs from `expectedRpIdHash`
|
|
- **WHEN** `verifyAssertion(...)` is called
|
|
- **THEN** the result is `{ ok: false, code: "WEBAUTHN.RPID_HASH_MISMATCH" }`
|
|
|
|
#### Scenario: verifies EdDSA / Ed25519 signature
|
|
|
|
- **GIVEN** a stored `CoseKey` with `alg === -8` and a captured assertion signed with Ed25519
|
|
- **WHEN** `verifyAssertion(...)` is called
|
|
- **THEN** the result is `{ ok: true }`
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/verifier/verify-assertion.test.ts::"verifies ES256 signature"` AND `packages/vault/webauthn/src/verifier/verify-assertion.test.ts::"rejects flipped signature"` AND `packages/vault/webauthn/src/verifier/verify-assertion.test.ts::"rejects rpIdHash mismatch"` AND `packages/vault/webauthn/src/verifier/verify-assertion.test.ts::"verifies EdDSA signature"`
|
|
|
|
---
|
|
|
|
### Requirement: Sign-counter regression detection
|
|
|
|
The package SHALL detect sign-counter regression per W3C WebAuthn L3 §6.1.1. When `result.authenticatorData.signCount` is less than or equal to the caller-supplied `signCounter` AND at least one of the two values is non-zero, `verifyAssertion` SHALL return `{ ok: false, code: "WEBAUTHN.SIGN_COUNTER_REGRESSION" }`. When both counters are zero (authenticators that do not maintain a counter), the check SHALL be skipped and verification proceeds. The caller decides whether to revoke the credential on regression.
|
|
|
|
#### Scenario: detects equal-counter regression
|
|
|
|
- **GIVEN** a stored `signCounter = 5` and an assertion with `signCount = 5`
|
|
- **WHEN** `verifyAssertion(...)` is called
|
|
- **THEN** the result is `{ ok: false, code: "WEBAUTHN.SIGN_COUNTER_REGRESSION" }`
|
|
|
|
#### Scenario: detects strict-decrease regression
|
|
|
|
- **GIVEN** a stored `signCounter = 10` and an assertion with `signCount = 7`
|
|
- **WHEN** `verifyAssertion(...)` is called
|
|
- **THEN** the result is `{ ok: false, code: "WEBAUTHN.SIGN_COUNTER_REGRESSION" }`
|
|
|
|
#### Scenario: skips regression check when both counters zero
|
|
|
|
- **GIVEN** a stored `signCounter = 0` and an assertion with `signCount = 0`
|
|
- **WHEN** `verifyAssertion(...)` is called against an otherwise-valid assertion
|
|
- **THEN** the result is `{ ok: true }`
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/verifier/verify-assertion.test.ts::"detects equal-counter regression"` AND `packages/vault/webauthn/src/verifier/verify-assertion.test.ts::"detects strict-decrease regression"` AND `packages/vault/webauthn/src/verifier/verify-assertion.test.ts::"skips regression check when both counters zero"`
|
|
|
|
---
|
|
|
|
### Requirement: Attestation verification dispatcher
|
|
|
|
The package SHALL export `verifyAttestation(result, expectedRpIdHash, attestationFormatRegistry): AttestationVerifyResult` from the `./verifier` subpath. The dispatcher SHALL read `result.attestationObject.fmt`, look up the corresponding verifier in `attestationFormatRegistry`, and invoke it. Supported built-in formats per W3C WebAuthn L3 §8 and the IANA WebAuthn registries: `packed`, `fido-u2f`, `tpm`, `android-key`, `android-safetynet`, `apple`, `none`. The dispatcher SHALL throw `WEBAUTHN.ATTESTATION_FORMAT_UNSUPPORTED` when no verifier is registered for `fmt`. Each verifier SHALL return `{ trusted: boolean, attestationType: "basic" | "self" | "attCA" | "anonCA" | "none", trustPath: X509[] }`.
|
|
|
|
#### Scenario: dispatches to the registered packed verifier
|
|
|
|
- **GIVEN** a registry containing the built-in `packed` verifier
|
|
- **WHEN** `verifyAttestation(result, expectedRpIdHash, registry)` is called against a packed attestation
|
|
- **THEN** the registry's `packed` verifier is invoked and the returned `attestationType` is one of `"basic"`, `"self"`, or `"attCA"`
|
|
|
|
#### Scenario: throws on unregistered format
|
|
|
|
- **GIVEN** a registry without an entry for `fmt === "tpm"`
|
|
- **WHEN** `verifyAttestation` is called against a TPM attestation
|
|
- **THEN** the call throws `WEBAUTHN.ATTESTATION_FORMAT_UNSUPPORTED`
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/verifier/verify-attestation.test.ts::"dispatches to the registered packed verifier"` AND `packages/vault/webauthn/src/verifier/verify-attestation.test.ts::"throws WEBAUTHN.ATTESTATION_FORMAT_UNSUPPORTED on unregistered fmt"`
|
|
|
|
---
|
|
|
|
### Requirement: Per-format attestation verifiers
|
|
|
|
The package SHALL ship verifiers for every format defined by W3C WebAuthn L3 §8 and listed in the IANA WebAuthn registry: `packed` (§8.2), `fido-u2f` (§8.6), `tpm` (§8.3), `android-key` (§8.4), `android-safetynet` (§8.5), `apple` (§8.8), `none` (§8.7). Each verifier SHALL live under its own `./attestation/<fmt>` subpath and SHALL implement the `AttestationFormatVerifier` interface returning `{ trusted, attestationType, trustPath }`. Each verifier SHALL reject signatures that fail to verify against the supplied trust path with `WEBAUTHN.SIGNATURE_INVALID`.
|
|
|
|
#### Scenario: packed verifier accepts a Yubikey 5 good vector
|
|
|
|
- **WHEN** the `packed` verifier is invoked against the captured Yubikey 5 attestation fixture
|
|
- **THEN** the result has `trusted: true`, `attestationType: "basic"`, and `trustPath.length >= 1`
|
|
|
|
#### Scenario: tpm verifier rejects a flipped signature
|
|
|
|
- **GIVEN** the captured Windows Hello TPM attestation fixture with one byte of `attStmt.sig` flipped
|
|
- **WHEN** the `tpm` verifier is invoked
|
|
- **THEN** the call throws `WEBAUTHN.SIGNATURE_INVALID`
|
|
|
|
#### Scenario: none verifier accepts and reports attestationType "none"
|
|
|
|
- **WHEN** the `none` verifier is invoked against the Chrome software authenticator fixture
|
|
- **THEN** the result has `trusted: true`, `attestationType: "none"`, and `trustPath.length === 0`
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/attestation/packed/packed.test.ts::"accepts Yubikey 5 packed good vector"` AND `packages/vault/webauthn/src/attestation/tpm/tpm.test.ts::"rejects flipped TPM signature"` AND `packages/vault/webauthn/src/attestation/none/none.test.ts::"accepts and reports attestationType none"` AND `packages/vault/webauthn/src/attestation/fido-u2f/fido-u2f.test.ts::"accepts Yubikey 5 fido-u2f good vector"` AND `packages/vault/webauthn/src/attestation/android-key/android-key.test.ts::"accepts Pixel android-key good vector"` AND `packages/vault/webauthn/src/attestation/android-safetynet/android-safetynet.test.ts::"accepts Pixel safetynet good vector"` AND `packages/vault/webauthn/src/attestation/apple/apple.test.ts::"accepts macOS Touch ID good vector"`
|
|
|
|
---
|
|
|
|
### Requirement: Pluggable attestation registry
|
|
|
|
The package SHALL export `defaultAttestationRegistry()` that returns a registry pre-populated with every built-in format verifier, and `registerAttestationFormat(registry, name, verifier)` that adds or overrides a format. Calling `registerAttestationFormat` with a name already present SHALL replace the previous verifier (last-write-wins) so genesis apps can swap implementations without forking the package.
|
|
|
|
#### Scenario: defaultAttestationRegistry contains every built-in format
|
|
|
|
- **WHEN** `defaultAttestationRegistry()` is called
|
|
- **THEN** the returned registry has entries keyed `"packed"`, `"fido-u2f"`, `"tpm"`, `"android-key"`, `"android-safetynet"`, `"apple"`, `"none"`
|
|
|
|
#### Scenario: registerAttestationFormat overrides a built-in
|
|
|
|
- **GIVEN** a registry from `defaultAttestationRegistry()`
|
|
- **WHEN** `registerAttestationFormat(registry, "packed", customVerifier)` is called
|
|
- **AND** `verifyAttestation` is invoked against a packed attestation
|
|
- **THEN** `customVerifier` is invoked instead of the built-in
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/verifier/verify-attestation.test.ts::"defaultAttestationRegistry contains every built-in format"` AND `packages/vault/webauthn/src/verifier/verify-attestation.test.ts::"registerAttestationFormat overrides a built-in verifier"`
|
|
|
|
---
|
|
|
|
### Requirement: Conditional UI mediation
|
|
|
|
The package SHALL pass `opts.mediation` through to `navigator.credentials.get` unchanged so callers can request `"silent" | "optional" | "conditional" | "required"` per the Credential Management spec. The package SHALL also export `isConditionalMediationAvailable(): Promise<boolean>` wrapping `PublicKeyCredential.isConditionalMediationAvailable?.()` and returning `false` when the static method is missing on the global `PublicKeyCredential` object.
|
|
|
|
#### Scenario: passes mediation: "conditional" to the adapter
|
|
|
|
- **WHEN** `authenticate({ ..., mediation: "conditional" })` is called
|
|
- **THEN** the underlying adapter receives `options.mediation === "conditional"`
|
|
|
|
#### Scenario: returns false when global PublicKeyCredential lacks the static check
|
|
|
|
- **GIVEN** a test environment where `globalThis.PublicKeyCredential.isConditionalMediationAvailable` is `undefined`
|
|
- **WHEN** `isConditionalMediationAvailable()` is called
|
|
- **THEN** the resolved value is `false`
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/authenticate/authenticate.test.ts::"passes mediation conditional to adapter"` AND `packages/vault/webauthn/src/adapter/adapter.test.ts::"isConditionalMediationAvailable returns false when static check missing"`
|
|
|
|
---
|
|
|
|
### Requirement: PRF extension support
|
|
|
|
The package SHALL accept the `prf` extension input on both `register` and `authenticate` per W3C WebAuthn L3 §10.1.4 and surface the per-credential PRF output as a `Uint8Array` (~32 bytes) on the result's `extensions.prf.results.first` field. When the authenticator supports PRF but the caller did not request an `eval` input, the result SHALL surface `extensions.prf.enabled === true` with no `results`. When the authenticator does not support PRF, the result SHALL surface `extensions.prf.enabled === false` and consumers SHALL NOT assume a secret is present.
|
|
|
|
#### Scenario: register surfaces PRF enabled flag
|
|
|
|
- **GIVEN** a stub adapter whose registration result includes `extensions.prf.enabled === true`
|
|
- **WHEN** `register({ ..., extensions: { prf: { eval: { first: salt } } } })` is called
|
|
- **THEN** the resolved value has `extensions.prf.enabled === true`
|
|
|
|
#### Scenario: authenticate yields a 32-byte PRF secret
|
|
|
|
- **GIVEN** a stub assertion whose `extensions.prf.results.first` is a 32-byte Uint8Array
|
|
- **WHEN** `authenticate({ ..., extensions: { prf: { eval: { first: salt } } } })` is called
|
|
- **THEN** the resolved `extensions.prf.results.first.byteLength === 32`
|
|
|
|
#### Scenario: missing PRF surfaces enabled false
|
|
|
|
- **GIVEN** a stub assertion whose `extensions.prf.enabled === false`
|
|
- **WHEN** `authenticate(...)` is called
|
|
- **THEN** the resolved `extensions.prf.enabled === false` and `extensions.prf.results` is undefined
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/extensions/prf/prf.test.ts::"register surfaces PRF enabled flag"` AND `packages/vault/webauthn/src/extensions/prf/prf.test.ts::"authenticate yields a 32-byte PRF secret"` AND `packages/vault/webauthn/src/extensions/prf/prf.test.ts::"missing PRF surfaces enabled false"`
|
|
|
|
---
|
|
|
|
### Requirement: largeBlob extension support
|
|
|
|
The package SHALL accept the `largeBlob` extension input on `register` (`{ support: "required" | "preferred" }`) and `authenticate` (`{ read: true } | { write: Uint8Array }`) per W3C WebAuthn L3 §10.1.3. On registration, the result SHALL surface `extensions.largeBlob.supported: boolean`. On authentication-read, the result SHALL surface `extensions.largeBlob.blob: Uint8Array | undefined`. On authentication-write, the result SHALL surface `extensions.largeBlob.written: boolean`.
|
|
|
|
#### Scenario: register surfaces largeBlob supported flag
|
|
|
|
- **GIVEN** a stub registration with `extensions.largeBlob.supported === true`
|
|
- **WHEN** `register({ ..., extensions: { largeBlob: { support: "required" } } })` is called
|
|
- **THEN** the resolved `extensions.largeBlob.supported === true`
|
|
|
|
#### Scenario: authenticate-read returns the blob
|
|
|
|
- **GIVEN** a stub assertion with `extensions.largeBlob.blob` set to a known byte string
|
|
- **WHEN** `authenticate({ ..., extensions: { largeBlob: { read: true } } })` is called
|
|
- **THEN** the resolved `extensions.largeBlob.blob` equals the known byte string
|
|
|
|
#### Scenario: authenticate-write reports success
|
|
|
|
- **GIVEN** a stub assertion with `extensions.largeBlob.written === true`
|
|
- **WHEN** `authenticate({ ..., extensions: { largeBlob: { write: bytes } } })` is called
|
|
- **THEN** the resolved `extensions.largeBlob.written === true`
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/extensions/large-blob/large-blob.test.ts::"register surfaces largeBlob supported flag"` AND `packages/vault/webauthn/src/extensions/large-blob/large-blob.test.ts::"authenticate-read returns the blob"` AND `packages/vault/webauthn/src/extensions/large-blob/large-blob.test.ts::"authenticate-write reports success"`
|
|
|
|
---
|
|
|
|
### Requirement: WebAuthnAdapter abstraction
|
|
|
|
The package SHALL export a `WebAuthnAdapter` interface declaring `create(options): Promise<PublicKeyCredential>`, `get(options): Promise<PublicKeyCredential>`, `isSupported(): boolean`, and `isConditionalMediationAvailable(): Promise<boolean>`. The package SHALL also export `defaultWebAuthnAdapter()` returning the production adapter that binds `globalThis.navigator.credentials`. When `globalThis.navigator?.credentials?.create` is missing, `defaultWebAuthnAdapter()` SHALL throw `WEBAUTHN.NOT_SUPPORTED`. Tests SHALL be able to inject a deterministic stub adapter via every public entry point.
|
|
|
|
#### Scenario: defaultWebAuthnAdapter throws when navigator.credentials missing
|
|
|
|
- **GIVEN** a test environment where `globalThis.navigator.credentials` is undefined
|
|
- **WHEN** `defaultWebAuthnAdapter()` is called
|
|
- **THEN** the call throws `WEBAUTHN.NOT_SUPPORTED`
|
|
|
|
#### Scenario: register accepts an injected stub adapter
|
|
|
|
- **GIVEN** a stub `WebAuthnAdapter` whose `create` resolves to a captured fixture
|
|
- **WHEN** `register({ ..., adapter: stub })` is called
|
|
- **THEN** `stub.create` is invoked exactly once with the assembled `PublicKeyCredentialCreationOptions`
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/adapter/adapter.test.ts::"defaultWebAuthnAdapter throws WEBAUTHN.NOT_SUPPORTED when navigator.credentials missing"` AND `packages/vault/webauthn/src/register/register.test.ts::"register accepts an injected stub adapter"`
|
|
|
|
---
|
|
|
|
### Requirement: Subpath layout and tier separation
|
|
|
|
The package SHALL declare subpath exports such that `./parser` and `./verifier` import zero DOM types and can be consumed by server-side relying-party verifiers. The browser-tier root barrel (`@suaveplan/webauthn`) MAY reference `navigator`, `PublicKeyCredential`, and `CredentialsContainer`. Each per-format attestation verifier SHALL be reachable via a dedicated subpath (`./attestation/<fmt>`).
|
|
|
|
#### Scenario: parser subpath has no DOM type references
|
|
|
|
- **WHEN** the dep-graph spec-conformance harness inspects every TypeScript file under `src/parser/`
|
|
- **THEN** zero references to `Document`, `Window`, `Navigator`, `CredentialsContainer`, or `PublicKeyCredential` are found
|
|
|
|
#### Scenario: verifier subpath has no DOM type references
|
|
|
|
- **WHEN** the harness inspects `src/verifier/` and `src/attestation/`
|
|
- **THEN** zero DOM-type references are found
|
|
|
|
#### Scenario: every documented subpath resolves
|
|
|
|
- **WHEN** every subpath listed in the proposal is imported in a consumer test fixture
|
|
- **THEN** every import resolves with no `Module not found` errors
|
|
|
|
**Verified by:** `packages/vault/webauthn/tests/dep-graph.test.ts::"parser subpath has no DOM type references"` AND `packages/vault/webauthn/tests/dep-graph.test.ts::"verifier subpath has no DOM type references"` AND `packages/vault/webauthn/src/index.test.ts::"every documented subpath export resolves"`
|
|
|
|
---
|
|
|
|
### Requirement: DOMException to typed error mapping
|
|
|
|
The package SHALL map every native `DOMException.name` raised by `navigator.credentials.create` / `get` to a typed `WebAuthnError` at the boundary so raw `DOMException` instances never leak to callers. The mapping SHALL be: `NotAllowedError` → `WEBAUTHN.USER_CANCELLED`, `InvalidStateError` → `WEBAUTHN.INVALID_STATE`, `SecurityError` → `WEBAUTHN.SECURITY_ERROR`, `AbortError` → `WEBAUTHN.ABORTED`, `NotSupportedError` → `WEBAUTHN.NOT_SUPPORTED`. Unknown `DOMException.name` values SHALL map to `WEBAUTHN.MALFORMED_RESPONSE` with the original `DOMException` attached as `cause`.
|
|
|
|
#### Scenario: maps NotAllowedError to USER_CANCELLED
|
|
|
|
- **GIVEN** a stub adapter whose `create` rejects with `new DOMException("denied", "NotAllowedError")`
|
|
- **WHEN** `register(...)` is called
|
|
- **THEN** the rejection is a `WebAuthnError` with `code === "WEBAUTHN.USER_CANCELLED"` and `cause` equal to the original `DOMException`
|
|
|
|
#### Scenario: maps AbortError to ABORTED
|
|
|
|
- **GIVEN** a stub adapter whose `get` rejects with `new DOMException("cancelled", "AbortError")`
|
|
- **WHEN** `authenticate(...)` is called
|
|
- **THEN** the rejection is a `WebAuthnError` with `code === "WEBAUTHN.ABORTED"`
|
|
|
|
#### Scenario: unknown DOMException name maps to MALFORMED_RESPONSE
|
|
|
|
- **GIVEN** a stub adapter whose `create` rejects with `new DOMException("weird", "WeirdError")`
|
|
- **WHEN** `register(...)` is called
|
|
- **THEN** the rejection is a `WebAuthnError` with `code === "WEBAUTHN.MALFORMED_RESPONSE"` and `cause` set to the original `DOMException`
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/register/register.test.ts::"maps NotAllowedError to WEBAUTHN.USER_CANCELLED"` AND `packages/vault/webauthn/src/authenticate/authenticate.test.ts::"maps AbortError to WEBAUTHN.ABORTED"` AND `packages/vault/webauthn/src/register/register.test.ts::"maps unknown DOMException to WEBAUTHN.MALFORMED_RESPONSE"`
|
|
|
|
---
|
|
|
|
### Requirement: Error code registry
|
|
|
|
The package SHALL define every error code listed in the enterprise plan via `defineCodes("WEBAUTHN", [...])`: `NOT_SUPPORTED`, `USER_CANCELLED`, `INVALID_STATE`, `SECURITY_ERROR`, `NOT_ALLOWED`, `MALFORMED_RESPONSE`, `CHALLENGE_MISMATCH`, `ORIGIN_MISMATCH`, `RPID_HASH_MISMATCH`, `SIGN_COUNTER_REGRESSION`, `SIGNATURE_INVALID`, `ATTESTATION_FORMAT_UNSUPPORTED`, `UV_NOT_PERFORMED`, `UP_NOT_PERFORMED`, `ABORTED`, `TIMEOUT`. Every code SHALL be raised by at least one production path covered by tests. Per `openspec/AGENTS.md` §2c, no code in the registry may exist solely to placate the conformance gate.
|
|
|
|
#### Scenario: every WEBAUTHN code has a production caller
|
|
|
|
- **WHEN** the spec-conformance harness scans `src/` for `throw new WebAuthnError(...)` and `verifyAssertion` discriminated-union returns
|
|
- **THEN** every code in the registry is referenced by at least one production-side call site
|
|
|
|
#### Scenario: UV_NOT_PERFORMED raised when userVerification required and UV flag absent
|
|
|
|
- **GIVEN** an assertion whose `authenticatorData.flags.userVerified === false`
|
|
- **WHEN** `authenticate({ ..., userVerification: "required" })` is called
|
|
- **THEN** the call rejects with `WEBAUTHN.UV_NOT_PERFORMED`
|
|
|
|
#### Scenario: UP_NOT_PERFORMED raised when UP flag absent
|
|
|
|
- **GIVEN** an assertion whose `authenticatorData.flags.userPresent === false`
|
|
- **WHEN** `authenticate(...)` is called
|
|
- **THEN** the call rejects with `WEBAUTHN.UP_NOT_PERFORMED`
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/errors/errors.test.ts::"every WEBAUTHN code has at least one production caller"` AND `packages/vault/webauthn/src/authenticate/authenticate.test.ts::"raises UV_NOT_PERFORMED when UV required and flag absent"` AND `packages/vault/webauthn/src/authenticate/authenticate.test.ts::"raises UP_NOT_PERFORMED when UP flag absent"`
|
|
|
|
---
|
|
|
|
### Requirement: Zod boundary schemas
|
|
|
|
The package SHALL declare Zod schemas at every public surface: `WebAuthnRegisterOptionsSchema`, `WebAuthnAuthenticateOptionsSchema`, `RegisterResultSchema`, `AuthenticateResultSchema`, `ParsedAuthDataSchema`, `ParsedAttestationStmtSchema`, `CoseKeySchema`, `ClientDataJSONSchema`. Inputs that fail validation SHALL throw `WebAuthnError` with code `WEBAUTHN.MALFORMED_RESPONSE` (for parsed bytes) or with the framework's standard `INVALID_OPTIONS` semantics (for caller-supplied options).
|
|
|
|
#### Scenario: register rejects malformed options
|
|
|
|
- **WHEN** `register({ rp: null as unknown as RpEntity, ...rest })` is called
|
|
- **THEN** the call rejects with a typed validation error before any adapter call is made
|
|
|
|
#### Scenario: parser rejects invalid CoseKey shape
|
|
|
|
- **GIVEN** authenticator-data bytes whose embedded CBOR COSE_Key is missing the `kty` label
|
|
- **WHEN** `parseAuthenticatorData(bytes)` is called
|
|
- **THEN** the call throws `WEBAUTHN.MALFORMED_RESPONSE`
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/register/register.test.ts::"rejects malformed options before adapter call"` AND `packages/vault/webauthn/src/parser/cose-key.test.ts::"rejects CoseKey missing kty label"`
|
|
|
|
---
|
|
|
|
### Requirement: AbortSignal threading
|
|
|
|
The package SHALL accept an `AbortSignal` on both `register` and `authenticate` and SHALL forward it into the underlying `navigator.credentials.{create,get}` call. Pre-aborted signals SHALL cause the call to reject with `WEBAUTHN.ABORTED` before any adapter invocation. Mid-flight aborts SHALL surface `AbortError` from the native API and map to `WEBAUTHN.ABORTED` per the DOMException-mapping requirement.
|
|
|
|
#### Scenario: pre-aborted signal short-circuits before adapter call
|
|
|
|
- **GIVEN** an `AbortController` whose signal is already aborted
|
|
- **WHEN** `register({ ..., signal: controller.signal })` is called
|
|
- **THEN** the call rejects with `WEBAUTHN.ABORTED` and the adapter's `create` is never invoked
|
|
|
|
#### Scenario: mid-flight abort surfaces ABORTED
|
|
|
|
- **GIVEN** an `AbortController` aborted after the adapter's `create` is invoked
|
|
- **WHEN** the adapter rejects with `AbortError`
|
|
- **THEN** the `register` call rejects with `WEBAUTHN.ABORTED`
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/register/register.test.ts::"pre-aborted signal short-circuits before adapter call"` AND `packages/vault/webauthn/src/register/register.test.ts::"mid-flight abort surfaces WEBAUTHN.ABORTED"`
|
|
|
|
---
|
|
|
|
### Requirement: Constant-time comparisons
|
|
|
|
The package SHALL compare every challenge, every `rpIdHash`, and every credential id using `constantTimeEqual` from `@suaveplan/crypto-utils`. Naive equality (`===` or `Buffer.equals`) SHALL NOT be used for any comparison whose mismatch would surface a `WEBAUTHN.*` error code.
|
|
|
|
#### Scenario: challenge mismatch detected even when only the last byte differs
|
|
|
|
- **GIVEN** a 32-byte challenge whose final byte is flipped in the captured response
|
|
- **WHEN** `register` validates the response
|
|
- **THEN** the call rejects with `WEBAUTHN.CHALLENGE_MISMATCH` and the comparison takes time independent of the byte index of the difference
|
|
|
|
#### Scenario: rpIdHash mismatch detected even when only the first byte differs
|
|
|
|
- **GIVEN** an `expectedRpIdHash` whose first byte differs from the assertion's
|
|
- **WHEN** `verifyAssertion(...)` is called
|
|
- **THEN** the result is `{ ok: false, code: "WEBAUTHN.RPID_HASH_MISMATCH" }`
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/register/register.test.ts::"detects challenge mismatch when only last byte differs"` AND `packages/vault/webauthn/src/verifier/verify-assertion.test.ts::"detects rpIdHash mismatch when only first byte differs"`
|
|
|
|
---
|
|
|
|
### Requirement: Observability surface
|
|
|
|
The package SHALL emit OTel metrics on every production path. Required instruments: `webauthn.register_duration_ms{outcome}` histogram, `webauthn.authenticate_duration_ms{outcome}` histogram, `webauthn.outcome_total{op,outcome}` counter (`op` ∈ `register | authenticate`, `outcome` ∈ `ok | error | aborted`), `webauthn.attestation_format_total{fmt}` counter (`fmt` cardinality bounded to the seven IANA-registered formats plus `unknown`), `webauthn.errors_total{code}` counter keyed on the WebAuthn error registry. Each instrument SHALL be assertable against an in-memory `MeterProvider` in tests.
|
|
|
|
#### Scenario: register success records duration_ms with outcome=ok
|
|
|
|
- **GIVEN** an in-memory `MeterProvider` configured for the package
|
|
- **WHEN** `register(...)` resolves successfully
|
|
- **THEN** `webauthn.register_duration_ms{outcome=ok}` records exactly one observation, and `webauthn.outcome_total{op=register,outcome=ok}` increments by 1
|
|
|
|
#### Scenario: failed assertion increments errors_total with the code label
|
|
|
|
- **GIVEN** an in-memory `MeterProvider`
|
|
- **WHEN** `authenticate(...)` rejects with `WEBAUTHN.SIGNATURE_INVALID`
|
|
- **THEN** `webauthn.errors_total{code="WEBAUTHN.SIGNATURE_INVALID"}` increments by 1, and `webauthn.outcome_total{op=authenticate,outcome=error}` increments by 1
|
|
|
|
#### Scenario: attestation_format_total increments per dispatched fmt
|
|
|
|
- **WHEN** `verifyAttestation` dispatches against a `packed` attestation
|
|
- **THEN** `webauthn.attestation_format_total{fmt=packed}` increments by 1
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/observability/metrics.test.ts::"register success records duration_ms with outcome=ok"` AND `packages/vault/webauthn/src/observability/metrics.test.ts::"failed assertion increments errors_total with code label"` AND `packages/vault/webauthn/src/observability/metrics.test.ts::"attestation_format_total increments per dispatched fmt"`
|
|
|
|
---
|
|
|
|
### Requirement: Test-vector bundle
|
|
|
|
The package SHALL ship a bundle of captured authenticator responses under `src/__fixtures__/` covering every attestation format. The bundle SHALL include real-device captures from a Yubikey 5 (`packed` and `fido-u2f`), Windows Hello (`tpm`), macOS Touch ID (`apple`), Chrome Software Authenticator (`none`), and an Android Pixel platform authenticator (`android-key` and `android-safetynet`). Every fixture set SHALL include at least one corruption variant (signature flipped, `rpIdHash` zeroed, or `signCount` frozen) for negative-path tests. Fixtures SHALL be generated offline; CI SHALL NOT depend on a live authenticator.
|
|
|
|
#### Scenario: every attestation format has at least one good vector
|
|
|
|
- **WHEN** the conformance harness inventories `src/__fixtures__/`
|
|
- **THEN** at least one good vector exists for each of `packed`, `fido-u2f`, `tpm`, `android-key`, `android-safetynet`, `apple`, `none`
|
|
|
|
#### Scenario: every fixture set has a corruption variant
|
|
|
|
- **WHEN** the conformance harness inventories `src/__fixtures__/`
|
|
- **THEN** every format directory contains at least one `*-flipped-sig.bin` or `*-zeroed-rpid.bin` or `*-frozen-counter.bin` fixture
|
|
|
|
**Verified by:** `packages/vault/webauthn/tests/dep-graph.test.ts::"every attestation format has at least one good fixture"` AND `packages/vault/webauthn/tests/dep-graph.test.ts::"every fixture set has at least one corruption variant"`
|
|
|
|
---
|
|
|
|
### Requirement: Property-based parser tests
|
|
|
|
The package SHALL include `fast-check` property tests for the authenticator-data parser and the attestation-object decoder. The properties SHALL include: (a) the parser never throws an untyped error — every rejection is a `WebAuthnError` with code `WEBAUTHN.MALFORMED_RESPONSE`; (b) any byte string the parser accepts can be re-parsed identically (round-trip stability); (c) flipping any single bit in a valid frame either yields a valid different parse or a typed rejection — never a panic.
|
|
|
|
#### Scenario: parser never panics on arbitrary byte strings
|
|
|
|
- **WHEN** fast-check generates 1,000 arbitrary `Uint8Array` inputs and feeds them to `parseAuthenticatorData`
|
|
- **THEN** every rejection is a `WebAuthnError` with code `WEBAUTHN.MALFORMED_RESPONSE`; no `TypeError`, `RangeError`, or untyped `Error` escapes
|
|
|
|
#### Scenario: round-trip stability holds for accepted frames
|
|
|
|
- **GIVEN** a frame that `parseAuthenticatorData` accepts
|
|
- **WHEN** the parsed result is re-serialised and re-parsed
|
|
- **THEN** the second parse produces a deeply-equal result
|
|
|
|
**Verified by:** `packages/vault/webauthn/src/parser/authenticator-data.property.test.ts::"parser never panics on arbitrary byte strings"` AND `packages/vault/webauthn/src/parser/authenticator-data.property.test.ts::"round-trip stability holds for accepted frames"` AND `packages/vault/webauthn/src/parser/attestation-object.property.test.ts::"attestation-object decoder never panics"`
|
|
|
|
---
|
|
|
|
### Requirement: CBOR dependency on `@suaveplan/cbor`
|
|
|
|
The package SHALL consume `@suaveplan/cbor` for every CBOR-encoded surface: COSE_Key decoding per RFC 9052 §7, attestation-object envelope decoding per CTAP 2.1 §6.5, and CBOR-encoded authenticator-data extensions per W3C WebAuthn L3 §9. The package SHALL NOT bundle a private CBOR implementation. The implementation tasks for this requirement are gated on the `enterprise-cbor` change archiving first.
|
|
|
|
#### Scenario: COSE_Key decoder consumes @suaveplan/cbor
|
|
|
|
- **WHEN** the spec-conformance harness inspects `src/parser/cose-key.ts`
|
|
- **THEN** the file imports from `@suaveplan/cbor` (or one of its subpaths) and contains no inline CBOR major-type / additional-information branching
|
|
|
|
#### Scenario: attestation-object decoder consumes @suaveplan/cbor
|
|
|
|
- **WHEN** the harness inspects `src/parser/attestation-object.ts`
|
|
- **THEN** the file imports from `@suaveplan/cbor`
|
|
|
|
**Verified by:** `packages/vault/webauthn/tests/dep-graph.test.ts::"COSE_Key decoder consumes @suaveplan/cbor"` AND `packages/vault/webauthn/tests/dep-graph.test.ts::"attestation-object decoder consumes @suaveplan/cbor"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|