# 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 loop (a state machine `webauthn` deliberately avoids), JWS chain verification (`@suaveplan/jws` + `@suaveplan/x509`), and a multi-megabyte FIDO Alliance metadata 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 `webauthn-mds3` without touching the browser-tier surface at all. The seam back into `webauthn` is a single injected interface (`MetadataResolver`) — 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", [...]) errors.test.ts schemas/ metadata-blob.schemas.ts + .md # Zod for the whole MDS3 v3 surface fetch/ fetch-blob.ts + .md + .test.ts verify/ verify-blob.ts + .md + .test.ts cache/ metadata-cache.ts + .md + .test.ts refresher/ metadata-refresher.ts + .md + .test.ts resolver/ types.ts + .md # MetadataResolver, MetadataLookup memory.ts + .md + .test.ts cached.ts + .md + .test.ts observability/ metrics.ts + .md + .test.ts __fixtures__/ mds3-sample-blob.jwt mds3-corrupted-blob.jwt index.ts __benchmarks__/ verify-blob.bench.ts resolve-aaguid.bench.ts 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[]; 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 { resolveByAaguid(aaguid: Uint8Array): Promise; resolveByAttestationCertificateKeyId( keyId: Uint8Array, ): Promise; } ``` ```ts // new optional field on @suaveplan/webauthn's VerifyAttestationInput export interface VerifyAttestationInput { // ...existing fields... readonly metadata?: import("@suaveplan/webauthn-mds3").MetadataResolver; readonly revokedStatuses?: ReadonlySet; // override default } // new optional field on AttestationVerifyResult export interface AttestationVerifyResult { readonly trusted: boolean; readonly attestationType: AttestationType; readonly trustPath: readonly Uint8Array[]; readonly metadata?: MetadataLookup; // present only when resolver injected } ``` ## 4. Refresh policy | Trigger | Behaviour | |---|---| | First start (no cached blob) | Fetch immediately, verify, cache, schedule next at `nextUpdate - jitter` | | Scheduled tick | Fetch, verify, write-through cache; on verify-fail keep prior payload and emit `webauthn_mds3.refresh_total{outcome=verify_failed}` | | `nextUpdate` absent | Fall back to `intervalMs` (default 24h) | | Concurrent call while refresh in flight | 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 against the MDS endpoint | The refresher is decoupled from the resolver — a resolver can be backed by a cache that some other process refreshes (e.g. a cron job, a Cloudflare Cron Trigger). `createCachedResolver` accepts an optional `refresher` that it `start()`s on first `resolve*` call. ## 5. Cache contract The cache is a thin wrapper around `StorageLike` from `@suaveplan/types`: - Key namespace `webauthn-mds3:blob:v1` — bump the trailing version on any 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; the resolver MAY treat a missing cache entry as "fetch now" if a refresher is wired. - Writes are atomic from the resolver's perspective — the cache writes the verified payload only after `verifyMetadataBlob` resolves successfully. ## 6. 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.resolveByAttestationCertificateKeyId(ski(result.trustPath[0])) : input.metadata.resolveByAaguid(attestedCredentialData.aaguid) 4. if lookup === undefined: return { ...result, metadata: undefined } 5. revoked = (input.revokedStatuses ?? DEFAULT_REVOCATION_STATUSES).has(lookup.currentStatus) 6. return { ...result, trusted: result.trusted && !revoked, metadata: lookup } ``` `DEFAULT_REVOCATION_STATUSES = new Set(["REVOKED", "USER_KEY_REMOTE_COMPROMISE", "USER_KEY_PHYSICAL_COMPROMISE", "ATTESTATION_KEY_COMPROMISE", "USER_VERIFICATION_BYPASS"])`. `fido-u2f` authenticators are MDS3-keyed by attestation-cert SKI (FIDO MDS3 §3.1.6) because they predate AAGUID. The attestation cert SKI is computed inside `webauthn-mds3` so `webauthn` doesn't need an X.509 parser at runtime. ## 7. 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 + grace`; the 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 blob with a payload whose `no` field is lower than the cached `no` | | 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, so concurrent refreshes can't flip the result mid-flight | | `FetchLike` impersonation in tests | Production wires a real `fetch`; tests inject deterministic stubs returning fixture bytes — no live network, no `vi.mock` (per genesis rule 2) | ## 8. Companion delta to `@suaveplan/webauthn` Because the `VerifyAttestationInput` and `AttestationVerifyResult` shapes gain optional fields and the post-verifier flow grows two trust-altering steps, the integration is captured as a `## MODIFIED Requirements` block in `specs/webauthn/spec.md` rather than left implicit. The modified requirement keeps every original scenario (the back-compat path) and adds two new scenarios (resolver-injected augmentation; revoked-status downgrade), each with a `**Verified by:**` line pointing into the existing `verify-attestation.test.ts` (extended in task 13.4). ## 9. 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`. - `tests/dep-graph.test.ts` (in the package) enforces zero DOM imports. This means server-side relying parties (Bun, Node, Workers, Deno) and browser-tier consumers can both depend on it without polyfills. ## 10. 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.