Files
suaveplan/openspec/changes/complete-webauthn-mds3/specs/webauthn-mds3/spec.md
2026-05-27 23:29:53 +01:00

335 lines
20 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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 with conditional GET
The package SHALL export `fetchMetadataBlob({ fetch, url?, signal?, cache? }): Promise<FetchOutcome>` from the `./fetch` subpath. The function SHALL call the injected `FetchLike` (per `@suaveplan/types`) against `url` (default `https://mds.fidoalliance.org/`), include `If-None-Match` and `If-Modified-Since` request headers when `cache` carries the previous response's `etag` / `lastModified` validators, return `{ kind: "not-modified" }` on HTTP `304`, return `{ kind: "fresh", bytes, etag?, lastModified? }` on HTTP `200`, map any other non-2xx status to `WEBAUTHN_MDS3.BLOB_FETCH_FAILED` carrying the status code in `context.status`, and forward `signal` so caller-side cancellation propagates.
#### Scenario: returns fresh bytes with validators on 200
- **WHEN** `fetchMetadataBlob({ fetch: stub200WithEtagAndBytes })` is called
- **THEN** the resolved value is `{ kind: "fresh", bytes, etag: stubEtag, lastModified: stubLastModified }`
#### Scenario: returns not-modified on 304
- **GIVEN** a `cache` envelope with `etag === "W/\"abc\""`
- **WHEN** the stub returns `304 Not Modified`
- **THEN** the resolved value is `{ kind: "not-modified" }` AND the request carried `If-None-Match: W/"abc"`
#### Scenario: maps non-2xx to BLOB_FETCH_FAILED
- **WHEN** the stub returns `503`
- **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 `cause.name === "AbortError"`
**Verified by:** `src/fetch/fetch-blob.test.ts::"returns fresh bytes with validators on 200"` AND `src/fetch/fetch-blob.test.ts::"returns not-modified on 304 when cache holds matching etag"` 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?, clock? }): Promise<MetadataBlobPayload>` 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`, decode the JSON payload, Zod-validate against `MetadataBlobPayloadSchema`, and reject with `WEBAUTHN_MDS3.BLOB_EXPIRED` when `clock.now() > payload.nextUpdate`.
#### Scenario: verifies the bundled FIDO sample blob
- **WHEN** `verifyMetadataBlob({ jws: SAMPLE_BLOB_JWT })` is called
- **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
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_SIGNATURE_INVALID`
#### Scenario: rejects untrusted chain
- **WHEN** the JWS `x5c` chain terminates at a non-pinned root
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_CHAIN_UNTRUSTED`
#### Scenario: rejects disallowed algorithm
- **WHEN** the JWS header `alg === "HS256"`
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_ALG_DISALLOWED`
#### Scenario: rejects expired blob
- **WHEN** `clock.now() > payload.nextUpdate`
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_EXPIRED`
#### Scenario: rejects schema mismatch
- **WHEN** the payload is 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 bundled 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: Atomic, rollback-protected metadata cache
The package SHALL export `MetadataCache` from the `./cache` subpath that wraps an injected `StorageLike<CachedMetadataEnvelope>` under 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 atomically replace the prior envelope on successful write (single-key semantics). The cache SHALL refuse to write a payload whose `no` field is strictly lower than the currently-cached payload's `no`, rejecting with `WEBAUTHN_MDS3.CACHE_ROLLBACK_REFUSED`.
#### Scenario: round-trips a verified envelope
- **WHEN** `cache.set({ payload, etag, lastModified, fetchedAt })` then `cache.get()` is called against an in-memory `StorageLike`
- **THEN** the returned envelope deep-equals the written envelope
#### 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 an envelope with `payload.no: 42`
- **WHEN** `cache.set(envelopeWithNo41)` is called
- **THEN** the call rejects with `WEBAUTHN_MDS3.CACHE_ROLLBACK_REFUSED` and `error.context.cachedNo === 42` AND `error.context.incomingNo === 41`
#### Scenario: write is atomic — failure mid-write leaves prior envelope intact
- **GIVEN** a `StorageLike` whose `set` rejects after returning the prior `get` value
- **WHEN** `cache.set(newEnvelope)` is called
- **THEN** the call rejects with `WEBAUTHN_MDS3.CACHE_WRITE_FAILED` AND a subsequent `cache.get()` returns the prior envelope
**Verified by:** `src/cache/metadata-cache.test.ts::"round-trips a verified envelope"` 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"` AND `src/cache/metadata-cache.test.ts::"write failure leaves prior envelope intact"`
---
### Requirement: Bootstrap blob shipped as cold-start fallback
The package SHALL ship a verified FIDO Alliance MDS3 blob as `BOOTSTRAP_BLOB` (base64 JWS) and `BOOTSTRAP_PAYLOAD` (parsed `MetadataBlobPayload`) from the `./bootstrap` subpath. The bootstrap SHALL verify against the pinned FIDO root via the standard `verifyMetadataBlob` pipeline. `createCachedResolver` SHALL fall back to the bootstrap payload when both the cache is empty AND the first network refresh fails; the bootstrap SHALL be overwritten by the first successful subsequent refresh.
#### Scenario: bootstrap verifies against the pinned root
- **WHEN** `verifyMetadataBlob({ jws: BOOTSTRAP_BLOB })` is called
- **THEN** the call resolves and returns a payload deep-equal to `BOOTSTRAP_PAYLOAD`
#### Scenario: cached resolver uses bootstrap on cold-start fetch failure
- **GIVEN** an empty cache and a `fetch` that rejects with `BLOB_FETCH_FAILED`
- **WHEN** `resolver.lookup(knownBootstrapAaguid)` is called
- **THEN** the resolver returns a `MetadataLookup` sourced from `BOOTSTRAP_PAYLOAD`
#### Scenario: bootstrap overwritten by first successful refresh
- **GIVEN** a cached resolver that fell back to bootstrap on cold start
- **WHEN** the next refresh succeeds and writes a fresh envelope
- **THEN** subsequent lookups return data from the fresh envelope, not bootstrap
**Verified by:** `src/bootstrap/blob.test.ts::"bootstrap verifies against the pinned FIDO root"` AND `src/resolver/cached.test.ts::"uses bootstrap when cache empty and fetch fails"` AND `src/resolver/cached.test.ts::"bootstrap is overwritten by first successful refresh"`
---
### Requirement: Manual blob override
The package SHALL export `createManualResolver({ jws }): Promise<MetadataResolver>` from the `./resolver` subpath. The factory SHALL run the supplied JWS through `verifyMetadataBlob` (rejecting on any verification failure) and return a memory-backed resolver over the verified payload. This seam SHALL enable air-gapped deployments, deterministic tests, and enterprise allow-lists without exercising the network or the refresher.
#### Scenario: manual resolver verifies the injected JWS
- **WHEN** `createManualResolver({ jws: SAMPLE_BLOB_JWT })` is called
- **THEN** the returned resolver looks up AAGUIDs from the sample payload AND no network fetch occurred
#### Scenario: manual resolver rejects an invalid JWS
- **WHEN** `createManualResolver({ jws: CORRUPTED_BLOB_JWT })` is called
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_SIGNATURE_INVALID`
**Verified by:** `src/resolver/manual.test.ts::"verifies the injected JWS and returns a memory resolver"` AND `src/resolver/manual.test.ts::"rejects an invalid JWS"`
---
### Requirement: Scheduled metadata refresher with EventBus + OTel
The package SHALL export `createMetadataRefresher({ fetch, storage, clock, logger, eventBus?, 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` on concurrent `refresh()`, reject with `WEBAUTHN_MDS3.REFRESHER_ABORTED` when `AbortSignal` fires, skip re-verification when the conditional fetch returns `{ kind: "not-modified" }`, retain the prior cached envelope when verification fails on a fresh fetch, emit `webauthn-mds3.refresh.success` / `webauthn-mds3.refresh.failure` on the injected `EventBusLike`, and increment the OTel counter `webauthn_mds3.refresh_total{outcome}`.
#### Scenario: schedules at nextUpdate minus jitter
- **WHEN** the refresher is started against a payload with `nextUpdate = now + 1h`
- **THEN** the next `clock.setTimeout` call is in `[55min, 65min]`
#### Scenario: falls back to intervalMs when nextUpdate absent
- **WHEN** started against a payload with `nextUpdate: undefined` and `intervalMs: 1h`
- **THEN** the next `clock.setTimeout` call is 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** `signal: ac.signal`
- **WHEN** `ac.abort()` is called mid-fetch
- **THEN** the pending refresh rejects with `WEBAUTHN_MDS3.REFRESHER_ABORTED`
#### Scenario: 304 skips re-verification
- **WHEN** the conditional fetch returns `{ kind: "not-modified" }`
- **THEN** `verifyMetadataBlob` is NOT called AND the cached envelope's `fetchedAt` is updated
#### Scenario: verify failure preserves prior envelope
- **GIVEN** a cache holding a valid envelope
- **WHEN** the refresh fetches a fresh JWS that fails verification
- **THEN** `cache.get()` still returns the prior envelope AND `webauthn_mds3.refresh_total{outcome="verify_failed"} === 1`
#### Scenario: emits EventBus refresh.success on success
- **WHEN** a refresh completes successfully
- **THEN** the EventBus received exactly one `webauthn-mds3.refresh.success` event AND `webauthn_mds3.refresh_total{outcome="ok"} === 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::"304 skips re-verification"` AND `src/refresher/metadata-refresher.test.ts::"verify failure preserves prior envelope"` AND `src/refresher/metadata-refresher.test.ts::"emits EventBus refresh.success on success"`
---
### Requirement: O(1) AAGUID and cert-key-id lookup
The package SHALL export a `MetadataResolver` interface with `lookup(aaguid: Uint8Array): Promise<MetadataLookup | undefined>` and `lookupByCertKeyId(ski: Uint8Array): Promise<MetadataLookup | undefined>`, returning the full `MetadataStatement` plus the complete `StatusReport[]` history (not just the most recent). The package SHALL ship `createMemoryResolver({ payload })` (synchronous, in-memory; O(1) primary map by 16-byte AAGUID and secondary O(1) map by `attestationCertificateKeyIdentifier` SHA-1 SKI), `createCachedResolver({ cache, refresher?, bootstrap? })` (read-through; lazy-refreshes; falls back to bootstrap), and `createManualResolver({ jws })` (see separate requirement). AAGUID comparison SHALL use `constantTimeEqual` from `@suaveplan/crypto-utils`.
#### Scenario: AAGUID hit returns a typed MetadataLookup with full status history
- **GIVEN** a memory resolver seeded with a payload whose entry has `statusReports.length === 3`
- **WHEN** `lookup(thatAaguid)` is called
- **THEN** the resolved value has `statement.aaguid === "..."`, `currentStatus` set to the latest report's status, AND `statusReports.length === 3`
#### Scenario: AAGUID miss returns undefined
- **WHEN** `lookup(unknownAaguid)` is called
- **THEN** the resolved value is `undefined`
#### Scenario: U2F attestation-cert SKI lookup
- **GIVEN** a memory resolver seeded with a payload entry whose `attestationCertificateKeyIdentifiers` includes a specific SKI
- **WHEN** `lookupByCertKeyId(thatSki)` is called
- **THEN** the resolved value's `statement` matches that entry
#### Scenario: cached resolver lazy-refreshes when cache empty
- **GIVEN** a cached resolver with empty cache and wired refresher
- **WHEN** `lookup(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 with full status history"` 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: Configurable AuthenticatorStatus allow/deny filter
The package SHALL export `createStatusFilter({ allow?, deny? }): StatusFilter` from the `./status-filter` subpath. The filter SHALL implement deny-wins semantics: a status in `deny` is always rejected; if `allow` is supplied only listed statuses are accepted; if neither is supplied the default deny set `DEFAULT_DENY_STATUSES = { REVOKED, USER_VERIFICATION_BYPASS, ATTESTATION_KEY_COMPROMISE, USER_KEY_REMOTE_COMPROMISE, USER_KEY_PHYSICAL_COMPROMISE }` applies.
#### Scenario: default deny set rejects REVOKED
- **WHEN** `createStatusFilter({}).accept("REVOKED")` is called
- **THEN** the result is `false`
#### Scenario: allow-list rejects unlisted
- **WHEN** `createStatusFilter({ allow: new Set(["FIDO_CERTIFIED_L2"]) }).accept("FIDO_CERTIFIED_L1")` is called
- **THEN** the result is `false`
#### Scenario: deny wins over allow
- **WHEN** `createStatusFilter({ allow: new Set(["REVOKED"]), deny: new Set(["REVOKED"]) }).accept("REVOKED")` is called
- **THEN** the result is `false`
**Verified by:** `src/status-filter/status-filter.test.ts::"default deny set rejects REVOKED"` AND `src/status-filter/status-filter.test.ts::"allow-list rejects unlisted statuses"` AND `src/status-filter/status-filter.test.ts::"deny wins over allow"`
---
### Requirement: Error-code registry (≥12 codes)
The package SHALL declare its error codes via `defineCodes("WEBAUTHN_MDS3", [...])` covering at minimum `BLOB_FETCH_FAILED`, `BLOB_NOT_MODIFIED`, `BLOB_MALFORMED`, `BLOB_SIGNATURE_INVALID`, `BLOB_CHAIN_UNTRUSTED`, `BLOB_EXPIRED`, `BLOB_ALG_DISALLOWED`, `BLOB_SCHEMA_MISMATCH`, `CACHE_READ_FAILED`, `CACHE_WRITE_FAILED`, `CACHE_ROLLBACK_REFUSED`, `RESOLVER_LOOKUP_FAILED`, `REFRESHER_ALREADY_RUNNING`, and `REFRESHER_ABORTED`. Every declared code SHALL be thrown by at least one production call site exercised by a test.
#### Scenario: every code is reachable
- **WHEN** the test harness greps `src/` for `WEBAUTHN_MDS3_CODES.<CODE>` 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: EventBus topics for refresh and lookup outcomes
The package SHALL expose typed EventBus topics from the `./events` subpath: `webauthn-mds3.refresh.success` (`{ no, fetchedAt, durationMs }`), `webauthn-mds3.refresh.failure` (`{ code, durationMs }`), `webauthn-mds3.lookup.hit` (`{ aaguid?, ski?, currentStatus }`), `webauthn-mds3.lookup.miss` (`{ aaguid?, ski? }`). Each topic SHALL fire from its production call site when an `EventBusLike` is supplied.
#### Scenario: refresh.success fires on successful refresh
- **WHEN** a refresh completes successfully against an injected EventBus
- **THEN** the bus received exactly one `webauthn-mds3.refresh.success` event with `payload.no === payload.no`
#### Scenario: lookup.miss fires when lookup returns undefined
- **WHEN** `lookup(unknownAaguid)` returns `undefined` against an injected EventBus
- **THEN** the bus received exactly one `webauthn-mds3.lookup.miss` event
**Verified by:** `src/events/topics.test.ts::"refresh.success fires on successful refresh"` AND `src/events/topics.test.ts::"lookup.miss fires when lookup returns undefined"`
---
### Requirement: OTel counters, histogram, and spans
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`, and SHALL open spans `webauthn_mds3.fetch`, `webauthn_mds3.verify`, `webauthn_mds3.refresh`, `webauthn_mds3.lookup`. When no `MeterProvider` / `TracerProvider` is registered, the metrics and spans 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** `lookup` returns `undefined`
- **THEN** `webauthn_mds3.lookup_total{outcome="miss"}` is `1`
#### Scenario: blob_age_ms records on read
- **WHEN** `cache.get()` returns an envelope aged 1h
- **THEN** `webauthn_mds3.blob_age_ms` has a recorded value in the bucket containing `3_600_000`
#### Scenario: verify span carries algorithm attribute
- **WHEN** `verifyMetadataBlob` runs against an in-memory `TracerProvider`
- **THEN** a `webauthn_mds3.verify` span ended with attribute `webauthn_mds3.alg === "RS256"`
**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"` AND `src/observability/spans.test.ts::"verify span carries algorithm attribute"`
---
### Requirement: Universal-tier purity
The package SHALL NOT import any DOM, browser, or Node-only API. `FetchLike`, `StorageLike`, `Clock`, `LoggerLike`, and `EventBusLike` 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 or Node types
- **WHEN** the dep-graph test sweeps the public surface for `Window`, `Document`, `localStorage`, `Blob`, `node:*` 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`, `./bootstrap`, `./refresher`, `./resolver`, `./status-filter`, `./events`, 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
**Verified by:** `src/index.test.ts::"every documented subpath resolves through the package's exports map"`