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
cacheenvelope withetag === "W/\"abc\"" - WHEN the stub returns
304 Not Modified - THEN the resolved value is
{ kind: "not-modified" }AND the request carriedIf-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_FAILEDanderror.context.status === 503
Scenario: forwards AbortSignal
- GIVEN an
AbortControllerwhosesignalis passed - WHEN the controller is aborted before the stub fetch resolves
- THEN the call rejects, the stub fetch received the same
signal, andcause.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
MetadataBlobPayloadwithlegalHeader,no,nextUpdate, and at least oneentries[]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
x5cchain 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
entriesfield - 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 })thencache.get()is called against an in-memoryStorageLike - THEN the returned envelope deep-equals the written envelope
Scenario: surfaces storage read failure
- GIVEN a
StorageLikewhosegetrejects withnew Error("disk full") - WHEN
cache.get()is called - THEN the call rejects with
WEBAUTHN_MDS3.CACHE_READ_FAILEDanderror.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_REFUSEDanderror.context.cachedNo === 42ANDerror.context.incomingNo === 41
Scenario: write is atomic — failure mid-write leaves prior envelope intact
- GIVEN a
StorageLikewhosesetrejects after returning the priorgetvalue - WHEN
cache.set(newEnvelope)is called - THEN the call rejects with
WEBAUTHN_MDS3.CACHE_WRITE_FAILEDAND a subsequentcache.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
fetchthat rejects withBLOB_FETCH_FAILED - WHEN
resolver.lookup(knownBootstrapAaguid)is called - THEN the resolver returns a
MetadataLookupsourced fromBOOTSTRAP_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.setTimeoutcall is in[55min, 65min]
Scenario: falls back to intervalMs when nextUpdate absent
- WHEN started against a payload with
nextUpdate: undefinedandintervalMs: 1h - THEN the next
clock.setTimeoutcall is approximatelynow + 1h
Scenario: reentrancy guard fires REFRESHER_ALREADY_RUNNING
- WHEN
refresh()is called while a priorrefresh()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
verifyMetadataBlobis NOT called AND the cached envelope'sfetchedAtis 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 ANDwebauthn_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.successevent ANDwebauthn_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 === "...",currentStatusset to the latest report's status, ANDstatusReports.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
attestationCertificateKeyIdentifiersincludes a specific SKI - WHEN
lookupByCertKeyId(thatSki)is called - THEN the resolved value's
statementmatches 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/forWEBAUTHN_MDS3_CODES.<CODE>references for every declaredCODE - 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.successevent withpayload.no === payload.no
Scenario: lookup.miss fires when lookup returns undefined
- WHEN
lookup(unknownAaguid)returnsundefinedagainst an injected EventBus - THEN the bus received exactly one
webauthn-mds3.lookup.missevent
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"}is1
Scenario: lookup_total records on miss
- WHEN
lookupreturnsundefined - THEN
webauthn_mds3.lookup_total{outcome="miss"}is1
Scenario: blob_age_ms records on read
- WHEN
cache.get()returns an envelope aged 1h - THEN
webauthn_mds3.blob_age_mshas a recorded value in the bucket containing3_600_000
Scenario: verify span carries algorithm attribute
- WHEN
verifyMetadataBlobruns against an in-memoryTracerProvider - THEN a
webauthn_mds3.verifyspan ended with attributewebauthn_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.tsimports 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"