# Capability Spec Delta: webauthn-mds3 (webauthn-mds3) **Package**: `@suaveplan/webauthn-mds3` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### Requirement: MDS3 blob fetch via injected FetchLike The package SHALL export `fetchMetadataBlob({ fetch, url?, signal? }): Promise` from the `./fetch` subpath. The function SHALL call the injected `FetchLike` (per `@suaveplan/types`) against `url` (default `https://mds.fidoalliance.org/`), return the response body as a `Uint8Array`, map any non-2xx status to `WEBAUTHN_MDS3.BLOB_FETCH_FAILED` carrying the status code in `context.status`, and forward `signal` so caller-side cancellation triggers the underlying `AbortController` path. #### Scenario: returns blob bytes on 200 - **WHEN** `fetchMetadataBlob({ fetch: stub200WithBytes, url: "https://example.test/mds" })` is called - **THEN** the resolved value `equalsBytes(stubBytes)` #### Scenario: maps non-2xx to BLOB_FETCH_FAILED - **WHEN** `fetchMetadataBlob({ fetch: stub503 })` is called - **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_FETCH_FAILED` and `error.context.status === 503` #### Scenario: forwards AbortSignal - **GIVEN** an `AbortController` whose `signal` is passed - **WHEN** the controller is aborted before the stub fetch resolves - **THEN** the call rejects, the stub fetch received the same `signal`, and the rejection's code is `WEBAUTHN_MDS3.BLOB_FETCH_FAILED` with `cause.name === "AbortError"` **Verified by:** `src/fetch/fetch-blob.test.ts::"returns blob bytes on 200"` AND `src/fetch/fetch-blob.test.ts::"maps non-2xx to BLOB_FETCH_FAILED"` AND `src/fetch/fetch-blob.test.ts::"forwards AbortSignal to the injected fetch"` --- ### Requirement: MDS3 blob signature verification with pinned FIDO root The package SHALL export `verifyMetadataBlob({ jws, trustAnchors? }): Promise` from the `./verify` subpath. The function SHALL parse the compact JWS, enforce the algorithm allow-list `{ RS256, ES256, ES384 }` (rejecting any other with `WEBAUTHN_MDS3.BLOB_ALG_DISALLOWED`), validate the `x5c` certificate chain to the pinned FIDO Alliance MDS3 root CA (or to caller-supplied `trustAnchors`) via `@suaveplan/x509`, verify the signature via `@suaveplan/jws` and `@suaveplan/crypto-rsa`/`@suaveplan/crypto-curve` per the header `alg`, decode the JSON payload, Zod-validate against `MetadataBlobPayloadSchema`, and reject with `WEBAUTHN_MDS3.BLOB_EXPIRED` when `now > payload.nextUpdate`. #### Scenario: verifies the FIDO sample blob - **WHEN** `verifyMetadataBlob({ jws: SAMPLE_BLOB_JWT })` is called against the bundled FIDO Alliance conformance sample - **THEN** the resolved value is a typed `MetadataBlobPayload` with `legalHeader`, `no`, `nextUpdate`, and at least one `entries[]` element #### Scenario: rejects flipped signature - **WHEN** `verifyMetadataBlob({ jws: CORRUPTED_BLOB_JWT })` is called against the bundled signature-flipped variant - **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_SIGNATURE_INVALID` #### Scenario: rejects untrusted chain - **WHEN** `verifyMetadataBlob({ jws })` is called with a JWS whose `x5c` chain terminates at a non-pinned root - **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_CHAIN_UNTRUSTED` #### Scenario: rejects disallowed algorithm - **WHEN** `verifyMetadataBlob({ jws })` is called with a JWS whose header `alg === "HS256"` - **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_ALG_DISALLOWED` #### Scenario: rejects expired blob - **WHEN** `verifyMetadataBlob({ jws })` is called and `Clock.now() > payload.nextUpdate` - **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_EXPIRED` #### Scenario: rejects schema mismatch - **WHEN** `verifyMetadataBlob({ jws })` is called against a payload missing the required `entries` field - **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_SCHEMA_MISMATCH` **Verified by:** `src/verify/verify-blob.test.ts::"verifies the FIDO sample blob"` AND `src/verify/verify-blob.test.ts::"rejects flipped signature"` AND `src/verify/verify-blob.test.ts::"rejects untrusted chain"` AND `src/verify/verify-blob.test.ts::"rejects disallowed algorithm"` AND `src/verify/verify-blob.test.ts::"rejects expired blob"` AND `src/verify/verify-blob.test.ts::"rejects schema mismatch"` --- ### Requirement: Metadata cache via injected StorageLike The package SHALL export `MetadataCache` from the `./cache` subpath that wraps an injected `StorageLike` under the key namespace `webauthn-mds3:blob:v1`. The cache SHALL surface storage read failures as `WEBAUTHN_MDS3.CACHE_READ_FAILED` and write failures as `WEBAUTHN_MDS3.CACHE_WRITE_FAILED`, attaching the underlying error as `cause`. The cache SHALL refuse to write a payload whose `no` field is strictly lower than the currently-cached payload's `no` (rollback protection). #### Scenario: round-trips a verified payload - **WHEN** `cache.set(payload)` then `cache.get()` is called against an in-memory `StorageLike` - **THEN** the returned payload deep-equals the written payload #### Scenario: surfaces storage read failure - **GIVEN** a `StorageLike` whose `get` rejects with `new Error("disk full")` - **WHEN** `cache.get()` is called - **THEN** the call rejects with `WEBAUTHN_MDS3.CACHE_READ_FAILED` and `error.cause.message === "disk full"` #### Scenario: refuses to write a rolled-back payload - **GIVEN** a cache holding a payload with `no: 42` - **WHEN** `cache.set(payloadWithNo41)` is called - **THEN** the call rejects with `WEBAUTHN_MDS3.CACHE_WRITE_FAILED` and `error.context.reason === "rollback"` **Verified by:** `src/cache/metadata-cache.test.ts::"round-trips a verified payload"` AND `src/cache/metadata-cache.test.ts::"surfaces storage read failure as CACHE_READ_FAILED"` AND `src/cache/metadata-cache.test.ts::"refuses to write a rolled-back payload"` --- ### Requirement: Scheduled metadata refresher The package SHALL export `createMetadataRefresher({ fetch, storage, clock, logger, intervalMs?, signal? }): MetadataRefresher` from the `./refresher` subpath. The refresher SHALL schedule the next refresh at `payload.nextUpdate - jitter` (uniform jitter in `[-5min, +5min]`) when `nextUpdate` is present, otherwise at `now + intervalMs` (default 24h). The refresher SHALL throw `WEBAUTHN_MDS3.REFRESHER_ALREADY_RUNNING` when `refresh()` is called while a refresh is in flight, and SHALL reject the pending refresh with `WEBAUTHN_MDS3.REFRESHER_ABORTED` when the injected `AbortSignal` fires. #### Scenario: schedules at nextUpdate minus jitter - **WHEN** a refresher is started against a payload with `nextUpdate = now + 1h` - **THEN** the next `clock.setTimeout` call is in the range `[55min, 65min]` from `now` #### Scenario: falls back to intervalMs when nextUpdate absent - **WHEN** a refresher is started against a payload with `nextUpdate: undefined` and `intervalMs: 1h` - **THEN** the next `clock.setTimeout` call is at approximately `now + 1h` #### Scenario: reentrancy guard fires REFRESHER_ALREADY_RUNNING - **WHEN** `refresh()` is called while a prior `refresh()` is still pending - **THEN** the second call rejects with `WEBAUTHN_MDS3.REFRESHER_ALREADY_RUNNING` #### Scenario: aborts on signal - **GIVEN** a refresher started with `signal: ac.signal` - **WHEN** `ac.abort()` is called mid-fetch - **THEN** the pending refresh rejects with `WEBAUTHN_MDS3.REFRESHER_ABORTED` #### Scenario: records refresh outcome counter - **WHEN** a refresh completes successfully - **THEN** the OTel counter `webauthn_mds3.refresh_total{outcome="ok"}` increments by 1 **Verified by:** `src/refresher/metadata-refresher.test.ts::"schedules at nextUpdate minus jitter"` AND `src/refresher/metadata-refresher.test.ts::"falls back to intervalMs when nextUpdate absent"` AND `src/refresher/metadata-refresher.test.ts::"reentrancy guard fires REFRESHER_ALREADY_RUNNING"` AND `src/refresher/metadata-refresher.test.ts::"aborts on signal"` AND `src/refresher/metadata-refresher.test.ts::"records refresh outcome counter"` --- ### Requirement: AAGUID lookup API The package SHALL export a `MetadataResolver` interface with `resolveByAaguid(aaguid: Uint8Array): Promise` and `resolveByAttestationCertificateKeyId(keyId: Uint8Array): Promise`. The package SHALL ship two factories: `createMemoryResolver({ blob })` (synchronous in-memory over a verified payload) and `createCachedResolver({ cache, refresher? })` (production resolver that reads through the cache and optionally starts the refresher on first access). The AAGUID comparator SHALL use `constantTimeEqual` from `@suaveplan/crypto-utils`. The `attestationCertificateKeyId` comparator SHALL match on the byte-equal SHA-1 SKI of the leaf attestation certificate (FIDO MDS3 §3.1.6 — U2F authenticators have no AAGUID). #### Scenario: AAGUID hit returns a typed MetadataLookup - **GIVEN** a memory resolver seeded with a payload containing an entry whose AAGUID is `0x11223344...` - **WHEN** `resolveByAaguid(0x11223344...)` is called - **THEN** the resolved value has `statement.aaguid === "11223344-..."`, `currentStatus` set, `attestationRootCertificates.length >= 1`, and `lastUpdate > 0` #### Scenario: AAGUID miss returns undefined - **WHEN** `resolveByAaguid(zerosBytes)` is called against a resolver whose payload has no zero-AAGUID entry - **THEN** the resolved value is `undefined` #### Scenario: U2F attestation-cert SKI lookup - **GIVEN** a memory resolver seeded with a payload containing an entry whose `attestationCertificateKeyIdentifiers` includes a specific SHA-1 SKI - **WHEN** `resolveByAttestationCertificateKeyId(thatSki)` is called - **THEN** the resolved value's `statement` matches that entry #### Scenario: cached resolver lazy-refreshes when cache empty - **GIVEN** a cached resolver whose cache is empty and whose refresher is wired - **WHEN** `resolveByAaguid(any)` is called - **THEN** the refresher's `refresh()` is invoked exactly once before the resolver returns **Verified by:** `src/resolver/memory.test.ts::"AAGUID hit returns a typed MetadataLookup"` AND `src/resolver/memory.test.ts::"AAGUID miss returns undefined"` AND `src/resolver/memory.test.ts::"U2F attestation-cert SKI lookup"` AND `src/resolver/cached.test.ts::"lazy-refreshes when cache empty"` --- ### Requirement: Error-code registry The package SHALL declare its error codes via `defineCodes("WEBAUTHN_MDS3", [...])` covering `BLOB_FETCH_FAILED`, `BLOB_MALFORMED`, `BLOB_SIGNATURE_INVALID`, `BLOB_CHAIN_UNTRUSTED`, `BLOB_EXPIRED`, `BLOB_ALG_DISALLOWED`, `BLOB_SCHEMA_MISMATCH`, `CACHE_READ_FAILED`, `CACHE_WRITE_FAILED`, `RESOLVER_LOOKUP_FAILED`, `REFRESHER_ALREADY_RUNNING`, and `REFRESHER_ABORTED`. Every declared code SHALL be thrown by at least one production call site exercised by an existing test. #### Scenario: every code is reachable - **WHEN** the test harness greps `src/` for `WEBAUTHN_MDS3_CODES.` references for every declared `CODE` - **THEN** every code has at least one reference outside `src/errors/` **Verified by:** `src/errors/errors.test.ts::"every WEBAUTHN_MDS3 code is thrown by at least one production path"` --- ### Requirement: Observability counters and histogram The package SHALL emit `webauthn_mds3.refresh_total{outcome}` and `webauthn_mds3.lookup_total{outcome}` counters and a `webauthn_mds3.blob_age_ms` histogram via `@opentelemetry/api`. When no `MeterProvider` is registered the metrics SHALL no-op silently. #### Scenario: refresh_total records on success - **WHEN** a refresh completes successfully against an in-memory `MeterProvider` - **THEN** `webauthn_mds3.refresh_total{outcome="ok"}` is `1` #### Scenario: lookup_total records on miss - **WHEN** `resolveByAaguid` returns `undefined` - **THEN** `webauthn_mds3.lookup_total{outcome="miss"}` is `1` #### Scenario: blob_age_ms records on read - **WHEN** `cache.get()` returns a payload aged 1h - **THEN** `webauthn_mds3.blob_age_ms` has a recorded value in the bucket containing 3_600_000 **Verified by:** `src/observability/metrics.test.ts::"refresh_total records on success"` AND `src/observability/metrics.test.ts::"lookup_total records on miss"` AND `src/observability/metrics.test.ts::"blob_age_ms records on read"` --- ### Requirement: Universal-tier purity The package SHALL NOT import any DOM, browser, or Node-only API. `FetchLike`, `StorageLike`, `Clock`, and `LoggerLike` SHALL be injected through the public API surface so the package runs unmodified on Bun, Node, Cloudflare Workers, Deno, and inside a browser bundle. #### Scenario: dep-graph harness sees no DOM types - **WHEN** the dep-graph test sweeps the public surface for `Window`, `Document`, `localStorage`, `Blob` references - **THEN** zero matches are found **Verified by:** `src/dep-graph.test.ts::"public surface imports zero DOM or Node-only types"` --- ### Requirement: Subpath exports The package SHALL expose subpath exports for `.`, `./fetch`, `./verify`, `./cache`, `./refresher`, and `./errors` in its `package.json` `exports` map so consumers can tree-shake unused capabilities. #### Scenario: every subpath resolves - **WHEN** the package's `index.test.ts` imports from each subpath - **THEN** every import resolves to the expected module and the bundler tree-shakes unused entries **Verified by:** `src/index.test.ts::"every documented subpath resolves through the package's exports map"`