Files
2026-05-27 23:29:53 +01:00

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 — verifyAttestation returns the trustPath: X509[] and attestationType so 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) from url (default https://mds.fidoalliance.org/) using the injected FetchLike so consumers can layer in retries, proxies, or a Cloudflare-Workers fetch.
  • ADDED: verifyMetadataBlob({ jws, trustAnchors? }): Promise<MetadataBlobPayload> — verifies the compact-JWS signature via @suaveplan/jws using the FIDO Alliance MDS3 root CA chain. The root CA fingerprint is pinned in constants/fido-root.ts; consumers can override via trustAnchors for staging / private metadata services. Verifies x5c chain to root, validates not-before / not-after, enforces RS256 / ES256 alg allow-list, decodes the JSON payload, and Zod-validates it against MetadataBlobPayloadSchema (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 with legalHeader, no, nextUpdate, and entries preserved. Cache key namespace webauthn-mds3:blob:v1.
  • ADDED: createMetadataRefresher({ fetch, storage, clock, logger, intervalMs?, signal? }): MetadataRefresher — schedules refresh at payload.nextUpdate - jitter; falls back to intervalMs (default 24h) when the blob omits a nextUpdate. Honors AbortSignal; never starts a second refresh while one is in flight; emits OTel webauthn_mds3.refresh_total{outcome} counter.

AAGUID lookup API

  • ADDED: MetadataResolver interface — resolveByAaguid(aaguid: Uint8Array): Promise<MetadataLookup | undefined> and resolveByAttestationCertificateKeyId(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): optional metadata?: MetadataResolver field on VerifyAttestationInput. When supplied, after the per-format verifier returns a trustPath, verifyAttestation resolves the AAGUID (or attestation-cert SKI for fido-u2f) and, when a MetadataLookup is returned, augments the result with metadata: MetadataLookup and downgrades trusted to false when currentStatus is REVOKED, USER_KEY_REMOTE_COMPROMISE, USER_KEY_PHYSICAL_COMPROMISE, ATTESTATION_KEY_COMPROMISE, or any other FIDO AuthenticatorStatus in the configurable revocation set.
  • ADDED (in @suaveplan/webauthn): AttestationVerifyResult.metadata?: MetadataLookup field. Absent when no resolver is injected — strict back-compat.

Errors

  • ADDED: WEBAUTHN_MDS3.* codes via defineCodes: 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 the FetchLike injection 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 with x5c chain.
  • @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 MetadataResolver directly — that interface is the integration seam, not the MDS3 wire format.
  • Authenticator-status policy decisions. The package surfaces currentStatus and statusReports; 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 nextUpdate cadence 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 injected FetchLike; non-2xx surfaces BLOB_FETCH_FAILED; AbortSignal cancellation.
  • src/verify/verify-blob.test.ts — sample-blob signature verifies; flipped signature → BLOB_SIGNATURE_INVALID; broken chain → BLOB_CHAIN_UNTRUSTED; expired nextUpdate → BLOB_EXPIRED; HS256 → BLOB_ALG_DISALLOWED; schema mismatch → BLOB_SCHEMA_MISMATCH.
  • src/cache/metadata-cache.test.ts — round-trip through StorageLike; read/write failure mapping.
  • src/refresher/metadata-refresher.test.ts — refreshes at nextUpdate - jitter; falls back to intervalMs; reentrancy guard fires REFRESHER_ALREADY_RUNNING; signal.abort() fires REFRESHER_ABORTED; OTel counter records per outcome.
  • src/resolver/memory.test.ts — AAGUID lookup hit / miss; attestationCertificateKeyId lookup for U2F.
  • src/resolver/cached.test.ts — reads cache; lazy-refreshes when stale.
  • src/errors/errors.test.ts — every WEBAUTHN_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/webauthn verifier.

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 optional metadata?: MetadataResolver field on VerifyAttestationInput and the optional metadata?: MetadataLookup field on AttestationVerifyResult. 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 consumes verifyAssertion, not verifyAttestation; 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.