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>— pluggableFetchLike; periodic refresh uses caller-managed timers; supports conditional fetch viaIf-None-Match(ETag) andIf-Modified-Since(Last-Modified) whencachecarries the previous response's validators; surfaces304 Not Modifiedas{ 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; fullx5cchain validation via@suaveplan/x509; algorithm allow-list{ RS256, ES256, ES384 }; Zod-validated payload againstMetadataBlobPayloadSchema(FIDO MDS3 §3.1.7); rejects expired blobs vianextUpdatecheck. - ADDED: Note — FIDO MDS3 blobs are JWS-signed JSON, not CBOR. No
@suaveplan/cbordep needed.
Cache, rollback protection, bootstrap, manual override
- ADDED:
MetadataCache— wraps injectedStorageLike<CachedMetadataEnvelope>under key namespacewebauthn-mds3:blob:v1. Envelope carries{ payload, etag?, lastModified?, fetchedAt }. Writes are atomic (single key, last-writer-wins on the underlyingStorageLike). Rollback protection: refuses to overwrite a cached payload with one whosenofield is strictly lower (FIDO MDS3 §3.1.7 monotonicity). - ADDED:
BOOTSTRAP_BLOBshipped atsrc/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:
MetadataResolverinterface withlookup(aaguid: Uint8Array): Promise<MetadataLookup | undefined>andlookupByCertKeyId(ski: Uint8Array): Promise<MetadataLookup | undefined>. Returns the fullMetadataStatementplus completestatusReports[]history (not just the most recent), so callers can render certification timelines. - ADDED:
MetadataLookup—{ statement, currentStatus, statusReports, attestationRootCertificates, aaguid?, attestationCertificateKeyIds?, lastUpdate }. - ADDED: Indexing —
createMemoryResolverbuilds an O(1) primary map by 16-byte AAGUID and a secondary O(1) map byattestationCertificateKeyIdentifier(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 overAuthenticatorStatus.createStatusFilter({ allow?, deny? })returns a predicate evaluating "deny wins"; defaults rejectREVOKED,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 atpayload.nextUpdate - jitter(uniform[-5min, +5min]) when present; falls back tointervalMs(default 24h). Reentrancy guard firesREFRESHER_ALREADY_RUNNING; honorsAbortSignalwithREFRESHER_ABORTED. Emits EventBus eventswebauthn-mds3.refresh.success/webauthn-mds3.refresh.failureand OTel spanswebauthn_mds3.refresh.
Integration seam back into @suaveplan/webauthn
- MODIFIED (in
@suaveplan/webauthn): optionalmetadata?: MetadataResolverandstatusFilter?: StatusFilteronVerifyAttestationInput. Absent ⇒ byte-identical legacy behaviour (strict back-compat). Present ⇒ post-verifier resolution: AAGUID for FIDO2 formats, SHA-1 SKI of the leaf attestation cert forfido-u2f(per FIDO MDS3 §3.1.6); attachesmetadata: MetadataLookupto the result; downgradestrustedtofalsewhen the filter rejects the resolved status. - ADDED (in
@suaveplan/webauthn):AttestationVerifyResult.metadata?: MetadataLookup.
Errors
- ADDED:
WEBAUTHN_MDS3.*codes viadefineCodes(≥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; counterswebauthn_mds3.refresh_total{outcome},webauthn_mds3.lookup_total{outcome}; histogramwebauthn_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 deterministicFetchLike. - 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 optionalmetadata?: MetadataResolverandstatusFilter?: StatusFilterfields onVerifyAttestationInputplus optionalmetadata?: MetadataLookuponAttestationVerifyResult. Additive only — byte-identical when not supplied.@suaveplan/vault— [ ] tests run / [x] docs reviewed / [x] no action needed (reason: vault unlock consumesverifyAssertion, notverifyAttestation).@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.