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_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, and the rejection's code isWEBAUTHN_MDS3.BLOB_FETCH_FAILEDwithcause.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
MetadataBlobPayloadwithlegalHeader,no,nextUpdate, and at least oneentries[]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 whosex5cchain 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 headeralg === "HS256" - THEN the call rejects with
WEBAUTHN_MDS3.BLOB_ALG_DISALLOWED
Scenario: rejects expired blob
- WHEN
verifyMetadataBlob({ jws })is called andClock.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 requiredentriesfield - 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)thencache.get()is called against an in-memoryStorageLike - THEN the returned payload deep-equals the written payload
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 a payload with
no: 42 - WHEN
cache.set(payloadWithNo41)is called - THEN the call rejects with
WEBAUTHN_MDS3.CACHE_WRITE_FAILEDanderror.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.setTimeoutcall is in the range[55min, 65min]fromnow
Scenario: falls back to intervalMs when nextUpdate absent
- WHEN a refresher is started against a payload with
nextUpdate: undefinedandintervalMs: 1h - THEN the next
clock.setTimeoutcall is at 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 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-...",currentStatusset,attestationRootCertificates.length >= 1, andlastUpdate > 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
attestationCertificateKeyIdentifiersincludes a specific SHA-1 SKI - WHEN
resolveByAttestationCertificateKeyId(thatSki)is called - THEN the resolved value's
statementmatches 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/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: 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"}is1
Scenario: lookup_total records on miss
- WHEN
resolveByAaguidreturnsundefined - THEN
webauthn_mds3.lookup_total{outcome="miss"}is1
Scenario: blob_age_ms records on read
- WHEN
cache.get()returns a payload aged 1h - THEN
webauthn_mds3.blob_age_mshas 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,Blobreferences - 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.tsimports 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"