# 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 ```text 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 ```ts // 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; lookupByCertKeyId(ski: Uint8Array): Promise; } // 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` 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 `StorageLike` with a tenant-prefixing decorator. - Read failures surface `CACHE_READ_FAILED` with the underlying cause attached. - Writes are atomic from the resolver's perspective — the envelope is written only after `verifyMetadataBlob` resolves successfully. - **Rollback protection (`CACHE_ROLLBACK_REFUSED`):** a write whose payload `no < cached.no` is rejected. FIDO MDS3 §3.1.7 mandates monotonically increasing `no`; a lower `no` is 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 `deny` is always rejected, even if also in `allow`. - If `allow` is supplied, only listed statuses pass. - If only `deny` is supplied, everything except `deny` passes. - 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 ```text 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.fetch` reference — `FetchLike` is injected. - No `localStorage` / `IndexedDB` — `StorageLike` is injected. - No `Date.now()` — `Clock` is injected. - No DOM types in the public surface — `MetadataLookup` carries `Uint8Array` not `Blob`. - `dep-graph.test.ts` enforces 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 `vault` unlock path — `verifyAssertion` (the unlock path) does not consume attestation, so MDS3 has no role there. - Does **not** certify new authenticators (FIDO Alliance's job).