Files
suaveplan/openspec/changes/complete-webauthn-mds3/proposal.md
2026-05-27 23:29:53 +01:00

8.8 KiB

@suaveplan/webauthn-mds3 — FULL FIDO Metadata Service v3 capability

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) Supersedes: openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-webauthn-mds3/

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. Without MDS3 a relying party cannot answer the production-critical questions every passkey deployment eventually faces: is this AAGUID a known certified authenticator, is its certification valid, is it revoked, is it on a compromise list, what attestation roots are trusted for it, and what user-verification / key-protection level did the FIDO Alliance certify it at.

The prior draft (_superseded-narrow-deferrals-2026-05-17/add-webauthn-mds3/) covered the bones (fetch / verify / cache / refresh / resolver / integration seam). This change supersedes it with the full MDS3 surface that a production relying party actually needs: conditional fetch (ETag/Last-Modified), rollback protection on the cache, configurable status allow/deny lists, a shipped bootstrap blob so cold-start works without the FIDO endpoint, a manual blob override seam for air-gapped deployments and tests, EventBus events on refresh outcomes, OTel spans across the full pipeline, and at least twelve WEBAUTHN_MDS3.* error codes covering every reachable failure path.

What changes

New package: @suaveplan/webauthn-mds3

Universal-tier package at packages/auth/webauthn-mds3/. No browser-only or Node-only APIs; FetchLike, StorageLike, Clock, LoggerLike, EventBusLike are all injected from @suaveplan/types.

Wire protocol — fetch + verify

  • ADDED: fetchMetadataBlob({ fetch, url?, signal?, cache? }): Promise<FetchOutcome> — pluggable FetchLike; periodic refresh uses caller-managed timers; supports conditional fetch via If-None-Match (ETag) and If-Modified-Since (Last-Modified) when cache carries the previous response's validators; surfaces 304 Not Modified as { kind: "not-modified" } so the refresher can skip re-verification.
  • ADDED: verifyMetadataBlob({ jws, trustAnchors?, clock? }) — compact-JWS verify against the pinned FIDO Alliance MDS3 root CA; full x5c chain validation via @suaveplan/x509; algorithm allow-list { RS256, ES256, ES384 }; Zod-validated payload against MetadataBlobPayloadSchema (FIDO MDS3 §3.1.7); rejects expired blobs via nextUpdate check.
  • ADDED: Note — FIDO MDS3 blobs are JWS-signed JSON, not CBOR. No @suaveplan/cbor dep needed.

Cache, rollback protection, bootstrap, manual override

  • ADDED: MetadataCache — wraps injected StorageLike<CachedMetadataEnvelope> under key namespace webauthn-mds3:blob:v1. Envelope carries { payload, etag?, lastModified?, fetchedAt }. Writes are atomic (single key, last-writer-wins on the underlying StorageLike). Rollback protection: refuses to overwrite a cached payload with one whose no field is strictly lower (FIDO MDS3 §3.1.7 monotonicity).
  • ADDED: BOOTSTRAP_BLOB shipped at src/bootstrap/blob.ts — a verified FIDO Alliance blob captured at package release. Used on cold-start when the cache is empty and the first network fetch fails; replaced atomically by the first successful refresh.
  • ADDED: createManualResolver({ jws }) / createMemoryResolver({ payload }) — caller-injected blob overrides for testing, air-gapped deployments, or enterprise allow-lists; bypasses the network entirely.

Lookup, status filtering

  • ADDED: MetadataResolver interface with lookup(aaguid: Uint8Array): Promise<MetadataLookup | undefined> and lookupByCertKeyId(ski: Uint8Array): Promise<MetadataLookup | undefined>. Returns the full MetadataStatement plus complete statusReports[] history (not just the most recent), so callers can render certification timelines.
  • ADDED: MetadataLookup — { statement, currentStatus, statusReports, attestationRootCertificates, aaguid?, attestationCertificateKeyIds?, lastUpdate }.
  • ADDED: Indexing — createMemoryResolver builds an O(1) primary map by 16-byte AAGUID and a secondary O(1) map by attestationCertificateKeyIdentifier (SHA-1 SKI hex string). The two maps coexist so a payload that mixes FIDO2 (AAGUID) and U2F (SKI) entries works uniformly.
  • ADDED: StatusFilter — caller-configurable allow/deny lists over AuthenticatorStatus. createStatusFilter({ allow?, deny? }) returns a predicate evaluating "deny wins"; defaults reject REVOKED, USER_VERIFICATION_BYPASS, ATTESTATION_KEY_COMPROMISE, USER_KEY_REMOTE_COMPROMISE, USER_KEY_PHYSICAL_COMPROMISE.

Scheduled refresher

  • ADDED: createMetadataRefresher({ fetch, storage, clock, logger, eventBus?, intervalMs?, signal? }) — schedules at payload.nextUpdate - jitter (uniform [-5min, +5min]) when present; falls back to intervalMs (default 24h). Reentrancy guard fires REFRESHER_ALREADY_RUNNING; honors AbortSignal with REFRESHER_ABORTED. Emits EventBus events webauthn-mds3.refresh.success / webauthn-mds3.refresh.failure and OTel spans webauthn_mds3.refresh.

Integration seam back into @suaveplan/webauthn

  • MODIFIED (in @suaveplan/webauthn): optional metadata?: MetadataResolver and statusFilter?: StatusFilter on VerifyAttestationInput. Absent ⇒ byte-identical legacy behaviour (strict back-compat). Present ⇒ post-verifier resolution: AAGUID for FIDO2 formats, SHA-1 SKI of the leaf attestation cert for fido-u2f (per FIDO MDS3 §3.1.6); attaches metadata: MetadataLookup to the result; downgrades trusted to false when the filter rejects the resolved status.
  • ADDED (in @suaveplan/webauthn): AttestationVerifyResult.metadata?: MetadataLookup.

Errors

  • ADDED: WEBAUTHN_MDS3.* codes via defineCodes (≥12 — current count: 14): BLOB_FETCH_FAILED, BLOB_NOT_MODIFIED, BLOB_MALFORMED, BLOB_SIGNATURE_INVALID, BLOB_CHAIN_UNTRUSTED, BLOB_EXPIRED, BLOB_ALG_DISALLOWED, BLOB_SCHEMA_MISMATCH, CACHE_READ_FAILED, CACHE_WRITE_FAILED, CACHE_ROLLBACK_REFUSED, RESOLVER_LOOKUP_FAILED, REFRESHER_ALREADY_RUNNING, REFRESHER_ABORTED.

Observability

  • ADDED: EventBus topics webauthn-mds3.refresh.success, webauthn-mds3.refresh.failure, webauthn-mds3.lookup.hit, webauthn-mds3.lookup.miss (typed payloads via @suaveplan/events).
  • ADDED: OTel spans webauthn_mds3.fetch, webauthn_mds3.verify, webauthn_mds3.refresh, webauthn_mds3.lookup; counters webauthn_mds3.refresh_total{outcome}, webauthn_mds3.lookup_total{outcome}; histogram webauthn_mds3.blob_age_ms.

Test strategy

  • Bundled FIDO Alliance sample blob fixtures (__fixtures__/mds3-sample-blob.jwt, parsed JSON, corrupted variants for negative tests). No live network in CI — every test injects a deterministic FetchLike.
  • 100% line / branch / function / statement coverage via istanbul.

Subpath exports

., ./fetch, ./verify, ./cache, ./refresher, ./resolver, ./bootstrap, ./status-filter, ./events, ./errors.

Dependencies

@suaveplan/types, @suaveplan/error, @suaveplan/events, @suaveplan/jws, @suaveplan/x509, @suaveplan/crypto-hash, @suaveplan/crypto-utils (all workspace:^); @opentelemetry/api catalog:observability, tslib catalog:build, zod catalog:forms.

Out of scope

  • Certifying new authenticators. That is the FIDO Alliance's exclusive job; this package only consumes their published metadata blob.

Impact

Affected dependents

Run bunx turbo ls --filter=...@suaveplan/webauthn-mds3 — at draft time the package is new.

  • @suaveplan/webauthn — [x] tests run / [x] docs reviewed / [ ] no action needed. Gains optional metadata?: MetadataResolver and statusFilter?: StatusFilter fields on VerifyAttestationInput plus optional metadata?: MetadataLookup on AttestationVerifyResult. Additive only — byte-identical when not supplied.
  • @suaveplan/vault — [ ] tests run / [x] docs reviewed / [x] no action needed (reason: vault unlock consumes verifyAssertion, not verifyAttestation).
  • @suaveplan/react-auth — [ ] tests run / [x] docs reviewed / [x] no action needed (reason: hook surface unchanged; MDS3 plugs into the server-side verifier).

Migration

No prior MDS3 surface — additive only. verifyAttestation consumers see byte-identical behaviour unless they opt in by injecting metadata. Consumers that previously open-coded an MDS3 lookup over the returned trustPath MAY migrate to the injected seam at their own pace; the "ignore metadata, do it yourself" path stays permanently supported.