12 KiB
Design — @suaveplan/webauthn-mds3
1. Sibling package vs. in-tree module
Decision: sibling package @suaveplan/webauthn-mds3 at packages/auth/webauthn-mds3/.
@suaveplan/webauthn is browser-and-universal mixed-tier and intentionally slim. Bundling MDS3 would drag four heavy concerns into it: a FetchLike network surface, a long-lived refresh state machine, JWS chain verification (@suaveplan/jws + @suaveplan/x509), and a multi-megabyte FIDO Alliance blob in test fixtures. Every consumer that only needs register / authenticate would pay that cost. A sibling keeps the integration opt-in, lets MDS3 evolve on its own release cadence (the FIDO Alliance ships schema bumps independently of WebAuthn), and lets server-side relying-party deployments depend on it without touching the browser-tier surface at all.
The seam back into webauthn is two injected interfaces (MetadataResolver, StatusFilter) — type-only at the webauthn side via import type, so the runtime dep graph stays acyclic.
2. Package layout
packages/auth/webauthn-mds3/
package.json # subpath exports per §6
README.md
src/
constants/
fido-root.ts + .md # pinned root CA DER + SHA-256, defaults
errors/
error-codes.ts + .md # defineCodes("WEBAUTHN_MDS3", [...]) — 14 codes
errors.test.ts
schemas/
metadata-blob.schemas.ts + .md # Zod for the whole MDS3 v3 surface
fetch/
fetch-blob.ts + .md + .test.ts # conditional fetch (ETag, Last-Modified)
verify/
verify-blob.ts + .md + .test.ts # JWS verify, x5c chain, alg allow-list
cache/
metadata-cache.ts + .md + .test.ts # rollback-protected envelope
bootstrap/
blob.ts + .md + .test.ts # shipped fallback blob
refresher/
metadata-refresher.ts + .md + .test.ts
resolver/
types.ts + .md # MetadataResolver, MetadataLookup
memory.ts + .md + .test.ts # O(1) maps: AAGUID + cert-key-id
cached.ts + .md + .test.ts # read-through cache + lazy refresh
manual.ts + .md + .test.ts # injected-blob override
status-filter/
status-filter.ts + .md + .test.ts # allow/deny lists; deny wins
events/
topics.ts + .md + .test.ts # typed EventBus topics
observability/
metrics.ts + .md + .test.ts
spans.ts + .md + .test.ts
__fixtures__/
mds3-sample-blob.jwt
mds3-corrupted-blob.jwt
mds3-rolled-back-blob.jwt
index.ts
__benchmarks__/
verify-blob.bench.ts
resolve-aaguid.bench.ts
dep-graph.test.ts # tier-purity harness
index.test.ts # subpath-export harness
index.ts # explicit named re-exports (rule 10)
3. Key types
// src/resolver/types.ts
export interface MetadataLookup {
readonly statement: MetadataStatement;
readonly currentStatus: AuthenticatorStatus;
readonly statusReports: readonly StatusReport[]; // full history
readonly attestationRootCertificates: readonly Uint8Array[]; // DER
readonly aaguid?: Uint8Array; // 16 bytes
readonly attestationCertificateKeyIds?: readonly Uint8Array[]; // SHA-1 SKIs
readonly lastUpdate: number; // epoch ms
}
export interface MetadataResolver {
lookup(aaguid: Uint8Array): Promise<MetadataLookup | undefined>;
lookupByCertKeyId(ski: Uint8Array): Promise<MetadataLookup | undefined>;
}
// src/cache/metadata-cache.ts
export interface CachedMetadataEnvelope {
readonly payload: MetadataBlobPayload;
readonly etag?: string;
readonly lastModified?: string;
readonly fetchedAt: number; // epoch ms
}
// src/fetch/fetch-blob.ts
export type FetchOutcome =
| { readonly kind: "fresh"; readonly bytes: Uint8Array; readonly etag?: string; readonly lastModified?: string }
| { readonly kind: "not-modified" };
// src/status-filter/status-filter.ts
export interface StatusFilter {
accept(status: AuthenticatorStatus): boolean;
}
4. Refresh policy
| Trigger | Behaviour |
|---|---|
| First start, cache empty | Try network fetch; if it fails, fall back to BOOTSTRAP_BLOB; schedule next at nextUpdate - jitter |
| Scheduled tick (cached envelope present) | Conditional fetch via If-None-Match / If-Modified-Since; on 304 re-arm timer, do NOT re-verify; on 200 verify, rollback-check, write |
nextUpdate absent |
Fall back to intervalMs (default 24h) |
Concurrent refresh() call |
Throw REFRESHER_ALREADY_RUNNING (no silent dedup — caller-visible) |
signal.aborted |
Cancel pending refresh, throw REFRESHER_ABORTED |
| Jitter | Uniform random in [-5min, +5min] to avoid thundering herd |
| Verify fails on a fresh fetch | Keep prior cached envelope; emit webauthn-mds3.refresh.failure and webauthn_mds3.refresh_total{outcome="verify_failed"} |
The refresher is decoupled from the resolver — a resolver can be backed by a cache that some other process refreshes (cron, Cloudflare Cron Trigger). createCachedResolver accepts an optional refresher that it start()s on first lookup* call.
5. Cache contract
Thin wrapper around StorageLike<CachedMetadataEnvelope> from @suaveplan/types:
- Key namespace
webauthn-mds3:blob:v1— bump the trailing version on any envelope-schema migration. - Single key per environment (one blob = one MDS3 endpoint). Multi-tenancy is the consumer's problem — wrap the
StorageLikewith a tenant-prefixing decorator. - Read failures surface
CACHE_READ_FAILEDwith the underlying cause attached. - Writes are atomic from the resolver's perspective — the envelope is written only after
verifyMetadataBlobresolves successfully. - Rollback protection (
CACHE_ROLLBACK_REFUSED): a write whose payloadno < cached.nois rejected. FIDO MDS3 §3.1.7 mandates monotonically increasingno; a lowernois either a downgrade attack or a corrupt upstream and is never written.
6. Bootstrap blob
src/bootstrap/blob.ts exports BOOTSTRAP_BLOB — a snapshot of the FIDO Alliance MDS3 blob captured at package release, verified against the pinned root, and embedded as a base64 string. The createCachedResolver factory uses it on cold-start when both the cache is empty AND the first network fetch fails. On the first successful refresh the bootstrap is overwritten — it is purely a fallback, never an ongoing source of truth. Tests assert the bootstrap verifies against the pinned root using the standard verifyMetadataBlob path.
7. Manual blob override
createManualResolver({ jws }) and createMemoryResolver({ payload }) let callers bypass the network entirely:
- Air-gapped deployments ship the JWS over an out-of-band channel and inject it directly.
- Tests seed a known payload without exercising the network or scheduler.
- Enterprise allow-lists can be expressed as a small hand-curated payload that overlays the public blob.
createManualResolver runs the same verifyMetadataBlob pipeline so an untrusted JWS still cannot be injected.
8. Status filter
createStatusFilter({ allow?, deny? }):
- deny wins — a status present in
denyis always rejected, even if also inallow. - If
allowis supplied, only listed statuses pass. - If only
denyis supplied, everything exceptdenypasses. - If neither is supplied, the default deny set is
{ REVOKED, USER_VERIFICATION_BYPASS, ATTESTATION_KEY_COMPROMISE, USER_KEY_REMOTE_COMPROMISE, USER_KEY_PHYSICAL_COMPROMISE }.
verifyAttestation consults the filter (or the default) to decide whether to downgrade trusted.
9. Attestation-time lookup flow
verifyAttestation(input):
1. result = await registry.get(input.attestationObject.fmt)(...)
2. if input.metadata === undefined: return result // strict back-compat
3. lookup =
(fmt === "fido-u2f" || attestedCredentialData?.aaguid is all-zero)
? input.metadata.lookupByCertKeyId(ski(result.trustPath[0]))
: input.metadata.lookup(attestedCredentialData.aaguid)
4. if lookup === undefined: return { ...result, metadata: undefined }
5. filter = input.statusFilter ?? DEFAULT_STATUS_FILTER
6. accepted = filter.accept(lookup.currentStatus)
7. return { ...result, trusted: result.trusted && accepted, metadata: lookup }
fido-u2f authenticators predate AAGUID and are MDS3-keyed by attestation-cert SKI (FIDO MDS3 §3.1.6). The SKI is computed inside webauthn-mds3 so webauthn doesn't gain an X.509 parser at runtime.
10. Security model
| Concern | Mitigation |
|---|---|
| Blob substitution (attacker serves a benign blob omitting a compromised authenticator) | Pin the FIDO Alliance root CA in constants/fido-root.ts; verify the JWS x5c chain terminates at the pinned root; reject any chain ending elsewhere |
| Algorithm downgrade | Allow-list RS256 / ES256 / ES384 only; reject none, HS256, and any other alg with BLOB_ALG_DISALLOWED |
| Stale blob masking a known-compromised authenticator | Reject when now > nextUpdate; refresher pulls new blobs on schedule; OTel exposes webauthn_mds3.blob_age_ms for monitoring |
Replay of a previous blob (no rollback) |
Refuse to overwrite the cached envelope with a payload whose no field is lower than the cached no — CACHE_ROLLBACK_REFUSED |
| Schema drift | All blob fields pass through Zod (MetadataBlobPayloadSchema); unknown fields are tolerated but typed-out — additive FIDO Alliance extensions don't crash the verifier |
| TOCTOU between resolve and trust decision | The trust decision happens inline in verifyAttestation; the resolver returns a MetadataLookup snapshot, not a live handle |
| Conditional-fetch poisoning (attacker returns 304 to a request with no prior ETag) | The fetcher only sends If-None-Match / If-Modified-Since when the cache holds matching validators; a 304 with no prior validator is treated as BLOB_FETCH_FAILED |
| Bootstrap-blob staleness | Bootstrap is fallback-only; once a fresh blob is fetched it is overwritten; OTel blob_age_ms exposes age so operators can alert |
FetchLike impersonation in tests |
Production wires a real fetch; tests inject deterministic stubs returning fixture bytes — no live network, no vi.mock |
11. Tier purity
@suaveplan/webauthn-mds3 is universal-tier:
- No
globalThis.fetchreference —FetchLikeis injected. - No
localStorage/IndexedDB—StorageLikeis injected. - No
Date.now()—Clockis injected. - No DOM types in the public surface —
MetadataLookupcarriesUint8ArraynotBlob. dep-graph.test.tsenforces zero DOM / Node imports in the public surface.
This means server-side relying parties (Bun, Node, Workers, Deno) and browser-tier consumers can both depend on it without polyfills.
12. CBOR confirmation
FIDO MDS3 blobs are JWS-signed JSON (per FIDO Alliance "Metadata Service Specification" v3.0 §3.1). They are not CBOR. The package therefore does not depend on @suaveplan/cbor — JWS over JSON is the entire wire format. (CBOR appears elsewhere in WebAuthn for attestationObject, but that is handled in @suaveplan/webauthn's parser, not here.)
13. What this change deliberately does NOT do
- Does not ship an MDS3 publishing tool (creating blobs is for the FIDO Alliance only).
- Does not ship a CRL / OCSP poller for attestation roots — that's a separate trust-chain concern outside the FIDO metadata contract.
- Does not wire MDS3 into the
vaultunlock path —verifyAssertion(the unlock path) does not consume attestation, so MDS3 has no role there. - Does not certify new authenticators (FIDO Alliance's job).