Files
2026-05-27 23:29:53 +01:00

20 KiB

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"