12 KiB
@suaveplan/webauthn-mds3 — FIDO Metadata Service v3 integration
Package: @suaveplan/webauthn-mds3 (NEW)
Status: PROPOSED
Type: new-package (genesis-original)
Date: 2026-05-17
Path: packages/auth/webauthn-mds3/
Design: ./design.md
Spec delta: ./specs/webauthn-mds3/spec.md
Companion delta: ./specs/webauthn/spec.md (## MODIFIED Requirements)
Why
The enterprise-webauthn change (archived 2026-05-11) shipped verifyAttestation returning a trustPath: X509[] plus an attestationType, but explicitly deferred FIDO Metadata Service v3 (MDS3) integration to a follow-up:
"MDS3 (FIDO Metadata Service) integration —
verifyAttestationreturns thetrustPath: X509[]andattestationTypeso callers can perform MDS3 lookups against their own metadata source. Bundling MDS3 fetch + caching expands scope into a separate package and is a follow-up." —openspec/archive/2026-05-11-enterprise-webauthn/proposal.md, "Out of scope"
Without MDS3 a relying party cannot answer the production-critical questions every passkey deployment eventually has to answer: is this AAGUID a known certified authenticator, is its certification still valid, is it on the revocation list, what attestation root CAs are trusted for this AAGUID, and what attachment / user-verification / key-protection statuses did the FIDO Alliance certify it at. Today every genesis consumer that wants those answers has to hand-roll the MDS3 blob fetch, JWS verification, FIDO root-CA pin, AAGUID lookup, and refresh schedule — exactly the duplication this monorepo exists to eliminate.
This change introduces a sibling package, @suaveplan/webauthn-mds3, that owns the MDS3 wire protocol end-to-end (download, JWS verify against the pinned FIDO Alliance root, cache, periodic refresh, AAGUID-keyed lookup) and exposes an injectable MetadataResolver that @suaveplan/webauthn's attestation pipeline consumes through a new optional seam. Absent injection, the existing verifyAttestation contract is preserved byte-for-byte (strict back-compat).
What changes
New package: @suaveplan/webauthn-mds3
Universal-tier package at packages/auth/webauthn-mds3/. No browser-only or Node-only APIs — FetchLike and StorageLike are injected from @suaveplan/types.
MDS3 blob download + JWS verification
- ADDED:
fetchMetadataBlob({ fetch, url? }): Promise<RawBlob>— fetches the MDS3 JWT (application/jwt) fromurl(defaulthttps://mds.fidoalliance.org/) using the injectedFetchLikeso consumers can layer in retries, proxies, or a Cloudflare-Workersfetch. - ADDED:
verifyMetadataBlob({ jws, trustAnchors? }): Promise<MetadataBlobPayload>— verifies the compact-JWS signature via@suaveplan/jwsusing the FIDO Alliance MDS3 root CA chain. The root CA fingerprint is pinned inconstants/fido-root.ts; consumers can override viatrustAnchorsfor staging / private metadata services. Verifiesx5cchain to root, validates not-before / not-after, enforces RS256 / ES256 alg allow-list, decodes the JSON payload, and Zod-validates it againstMetadataBlobPayloadSchema(FIDO MDS3 §3.1.7). - ADDED:
MetadataBlobPayload,MetadataStatement,AuthenticatorStatus,StatusReport,BiometricStatusReport,EcdaaTrustAnchor— Zod schemas tracking the published MDS3 v3 schema verbatim.
Cache + scheduled refresh
- ADDED:
MetadataCache—StorageLike<MetadataBlobPayload>-backed cache withlegalHeader,no,nextUpdate, andentriespreserved. Cache key namespacewebauthn-mds3:blob:v1. - ADDED:
createMetadataRefresher({ fetch, storage, clock, logger, intervalMs?, signal? }): MetadataRefresher— schedules refresh atpayload.nextUpdate - jitter; falls back tointervalMs(default 24h) when the blob omits anextUpdate. HonorsAbortSignal; never starts a second refresh while one is in flight; emits OTelwebauthn_mds3.refresh_total{outcome}counter.
AAGUID lookup API
- ADDED:
MetadataResolverinterface —resolveByAaguid(aaguid: Uint8Array): Promise<MetadataLookup | undefined>andresolveByAttestationCertificateKeyId(keyId: Uint8Array): Promise<MetadataLookup | undefined>(U2F authenticators are MDS3-keyed by attestation cert SKI, not AAGUID — FIDO MDS3 §3.1.6). - ADDED:
MetadataLookup—{ statement, currentStatus, statusReports, attestationRootCertificates: Uint8Array[], aaguid?, attestationCertificateKeyIds?, lastUpdate }. - ADDED:
createMemoryResolver({ blob }): MetadataResolver— synchronous in-memory resolver over a verified payload. Used by tests and by consumers that ship their own refresh loop. - ADDED:
createCachedResolver({ cache, refresher? }): MetadataResolver— production resolver that reads the cache and optionally drives a refresher.
Integration seam back into @suaveplan/webauthn
- ADDED (in
@suaveplan/webauthn): optionalmetadata?: MetadataResolverfield onVerifyAttestationInput. When supplied, after the per-format verifier returns atrustPath,verifyAttestationresolves the AAGUID (or attestation-cert SKI forfido-u2f) and, when aMetadataLookupis returned, augments the result withmetadata: MetadataLookupand downgradestrustedtofalsewhencurrentStatusisREVOKED,USER_KEY_REMOTE_COMPROMISE,USER_KEY_PHYSICAL_COMPROMISE,ATTESTATION_KEY_COMPROMISE, or any other FIDOAuthenticatorStatusin the configurable revocation set. - ADDED (in
@suaveplan/webauthn):AttestationVerifyResult.metadata?: MetadataLookupfield. Absent when no resolver is injected — strict back-compat.
Errors
- ADDED:
WEBAUTHN_MDS3.*codes viadefineCodes: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,REFRESHER_ABORTED.
Test strategy
- Vectors of the FIDO Alliance MDS3 sample blob (
__fixtures__/mds3-sample-blob.jwt+ parsed payload) sourced offline from the FIDO Alliance conformance suite. Tests use theFetchLikeinjection seam to return the fixture bytes — no live network in CI. - Negative fixtures: signature-flipped blob, chain-broken blob, expired
nextUpdate, schema-violation payload.
Subpath exports
.— root barrel:MetadataResolver,createMemoryResolver,createCachedResolver, every type, every error code../fetch—fetchMetadataBlob../verify—verifyMetadataBlob,MetadataBlobPayloadSchema../cache—MetadataCache, cache-key constants../refresher—createMetadataRefresher,MetadataRefresher../errors—WEBAUTHN_MDS3_CODES,WebAuthnMds3Error.
Dependencies
@suaveplan/types workspace:^—FetchLike,StorageLike,Clock,LoggerLike.@suaveplan/error workspace:^—defineCodes, base error.@suaveplan/jws workspace:^— JWS verification withx5cchain.@suaveplan/x509 workspace:^— chain validation to pinned FIDO root.@suaveplan/crypto-hash workspace:^— SHA-256 for AAGUID key derivation and cert thumbprints.@suaveplan/crypto-utils workspace:^—constantTimeEqual, base64url.@opentelemetry/api catalog:observability— refresh + lookup metrics.tslib catalog:build.zod catalog:forms— boundary validation.
Out of scope
- MDS v2 / MDS v1 compatibility. MDS3 is the only metadata version on the FIDO Alliance roadmap; bundling deprecated MDS2 expands scope and ships dead code for new deployments.
- Per-RP custom metadata sources. The package only speaks the FIDO Alliance MDS3 wire format. Custom metadata catalogues (enterprise allow-lists) should implement
MetadataResolverdirectly — that interface is the integration seam, not the MDS3 wire format. - Authenticator-status policy decisions. The package surfaces
currentStatusandstatusReports; the relying party decides what status set to treat as revocation. The default revocation set is opinionated but configurable. - CRL / OCSP polling for attestation-root chains. Out of band for MDS3 — root pinning + the published metadata
nextUpdatecadence is the FIDO-Alliance-blessed freshness model.
Source mapping
| Requirement | Source | Notes |
|---|---|---|
| Why an MDS3 integration is needed | openspec/archive/2026-05-11-enterprise-webauthn/proposal.md "Out of scope" |
Verbatim deferral text |
trustPath + attestationType consumed downstream |
packages/auth/webauthn/src/verifier/registry.ts L14-L18 |
AttestationVerifyResult is the integration seam |
| AAGUID parse + carry on attested-credential-data | packages/auth/webauthn/src/parser/authenticator-data.ts L73-L84 |
Already available at attestation-verification time |
none/fido-u2f/packed doc anchors flag MDS3 as caller responsibility |
packages/auth/webauthn/src/attestation/{packed,fido-u2f,none}/*.md |
Today's wording moves into the MetadataResolver |
| MDS3 wire format | FIDO Alliance "Metadata Service Specification" v3.0 §3.1 | External standard anchor |
| FIDO Alliance root CA pin | https://mds.fidoalliance.org/Root.cer | Pinned in constants/fido-root.ts |
| JWS verification | RFC 7515 + @suaveplan/jws |
Internal contract |
Verification approach
Every requirement in specs/webauthn-mds3/spec.md and every modified requirement in specs/webauthn/spec.md carries a **Verified by:** line naming planned test files. Test files (all paths relative to packages/auth/webauthn-mds3/ unless noted):
src/fetch/fetch-blob.test.ts— happy path via injectedFetchLike; non-2xx surfacesBLOB_FETCH_FAILED;AbortSignalcancellation.src/verify/verify-blob.test.ts— sample-blob signature verifies; flipped signature →BLOB_SIGNATURE_INVALID; broken chain →BLOB_CHAIN_UNTRUSTED; expirednextUpdate→BLOB_EXPIRED; HS256 →BLOB_ALG_DISALLOWED; schema mismatch →BLOB_SCHEMA_MISMATCH.src/cache/metadata-cache.test.ts— round-trip throughStorageLike; read/write failure mapping.src/refresher/metadata-refresher.test.ts— refreshes atnextUpdate - jitter; falls back tointervalMs; reentrancy guard firesREFRESHER_ALREADY_RUNNING;signal.abort()firesREFRESHER_ABORTED; OTel counter records per outcome.src/resolver/memory.test.ts— AAGUID lookup hit / miss;attestationCertificateKeyIdlookup for U2F.src/resolver/cached.test.ts— reads cache; lazy-refreshes when stale.src/errors/errors.test.ts— everyWEBAUTHN_MDS3.*code is thrown by at least one production path.packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"augments result with metadata when resolver injected"AND"::"downgrades trusted=false when currentStatus is revoked"AND"::"omits metadata field when no resolver injected (back-compat)"— integration into the existing@suaveplan/webauthnverifier.
Impact
Affected dependents
Run bunx turbo ls --filter=...@suaveplan/webauthn-mds3 after landing — at draft time the package is new.
@suaveplan/webauthn— [x] tests run / [x] docs reviewed / [ ] no action needed. Gains the optionalmetadata?: MetadataResolverfield onVerifyAttestationInputand the optionalmetadata?: MetadataLookupfield onAttestationVerifyResult. Both are additive; existing call sites that do not inject a resolver see byte-identical behaviour.@suaveplan/vault— [ ] tests run / [x] docs reviewed / [x] no action needed (reason: vault unlock consumesverifyAssertion, notverifyAttestation; MDS3 surfaces only on registration paths the vault doesn't drive).@suaveplan/react-auth— [ ] tests run / [x] docs reviewed / [x] no action needed (reason: hook surface unchanged; MDS3 plugs into server-side verifier).
Migration
No prior MDS3 surface — additive only. Behaviour change for verifyAttestation consumers is opt-in (only fires when metadata: MetadataResolver is passed). Consumers that previously open-coded an MDS3 lookup over the returned trustPath MAY migrate to the injected seam at their own pace; the old "ignore metadata, do it yourself" path is permanently supported.