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

13 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

The package SHALL export fetchMetadataBlob({ fetch, url?, signal? }): Promise<Uint8Array> 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<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 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<MetadataBlobPayload> 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<MetadataLookup | undefined> and resolveByAttestationCertificateKeyId(keyId: Uint8Array): Promise<MetadataLookup | undefined>. 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.<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: 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"