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

12 KiB

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 state machine, JWS chain verification (@suaveplan/jws + @suaveplan/x509), and a multi-megabyte FIDO Alliance 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 it without touching the browser-tier surface at all.

The seam back into webauthn is two injected interfaces (MetadataResolver, StatusFilter) — type-only at the webauthn side via import type, so the runtime dep graph stays acyclic.

2. Package layout

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", [...]) — 14 codes
      errors.test.ts
    schemas/
      metadata-blob.schemas.ts + .md    # Zod for the whole MDS3 v3 surface
    fetch/
      fetch-blob.ts + .md + .test.ts    # conditional fetch (ETag, Last-Modified)
    verify/
      verify-blob.ts + .md + .test.ts   # JWS verify, x5c chain, alg allow-list
    cache/
      metadata-cache.ts + .md + .test.ts   # rollback-protected envelope
    bootstrap/
      blob.ts + .md + .test.ts          # shipped fallback blob
    refresher/
      metadata-refresher.ts + .md + .test.ts
    resolver/
      types.ts + .md                    # MetadataResolver, MetadataLookup
      memory.ts + .md + .test.ts        # O(1) maps: AAGUID + cert-key-id
      cached.ts + .md + .test.ts        # read-through cache + lazy refresh
      manual.ts + .md + .test.ts        # injected-blob override
    status-filter/
      status-filter.ts + .md + .test.ts # allow/deny lists; deny wins
    events/
      topics.ts + .md + .test.ts        # typed EventBus topics
    observability/
      metrics.ts + .md + .test.ts
      spans.ts + .md + .test.ts
    __fixtures__/
      mds3-sample-blob.jwt
      mds3-corrupted-blob.jwt
      mds3-rolled-back-blob.jwt
      index.ts
    __benchmarks__/
      verify-blob.bench.ts
      resolve-aaguid.bench.ts
    dep-graph.test.ts                   # tier-purity harness
    index.test.ts                       # subpath-export harness
    index.ts                            # explicit named re-exports (rule 10)

3. Key types

// src/resolver/types.ts
export interface MetadataLookup {
    readonly statement: MetadataStatement;
    readonly currentStatus: AuthenticatorStatus;
    readonly statusReports: readonly StatusReport[]; // full history
    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 {
    lookup(aaguid: Uint8Array): Promise<MetadataLookup | undefined>;
    lookupByCertKeyId(ski: Uint8Array): Promise<MetadataLookup | undefined>;
}

// src/cache/metadata-cache.ts
export interface CachedMetadataEnvelope {
    readonly payload: MetadataBlobPayload;
    readonly etag?: string;
    readonly lastModified?: string;
    readonly fetchedAt: number; // epoch ms
}

// src/fetch/fetch-blob.ts
export type FetchOutcome =
    | { readonly kind: "fresh"; readonly bytes: Uint8Array; readonly etag?: string; readonly lastModified?: string }
    | { readonly kind: "not-modified" };

// src/status-filter/status-filter.ts
export interface StatusFilter {
    accept(status: AuthenticatorStatus): boolean;
}

4. Refresh policy

Trigger Behaviour
First start, cache empty Try network fetch; if it fails, fall back to BOOTSTRAP_BLOB; schedule next at nextUpdate - jitter
Scheduled tick (cached envelope present) Conditional fetch via If-None-Match / If-Modified-Since; on 304 re-arm timer, do NOT re-verify; on 200 verify, rollback-check, write
nextUpdate absent Fall back to intervalMs (default 24h)
Concurrent refresh() call 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
Verify fails on a fresh fetch Keep prior cached envelope; emit webauthn-mds3.refresh.failure and webauthn_mds3.refresh_total{outcome="verify_failed"}

The refresher is decoupled from the resolver — a resolver can be backed by a cache that some other process refreshes (cron, Cloudflare Cron Trigger). createCachedResolver accepts an optional refresher that it start()s on first lookup* call.

5. Cache contract

Thin wrapper around StorageLike<CachedMetadataEnvelope> from @suaveplan/types:

  • Key namespace webauthn-mds3:blob:v1 — bump the trailing version on any envelope-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.
  • Writes are atomic from the resolver's perspective — the envelope is written only after verifyMetadataBlob resolves successfully.
  • Rollback protection (CACHE_ROLLBACK_REFUSED): a write whose payload no < cached.no is rejected. FIDO MDS3 §3.1.7 mandates monotonically increasing no; a lower no is either a downgrade attack or a corrupt upstream and is never written.

6. Bootstrap blob

src/bootstrap/blob.ts exports BOOTSTRAP_BLOB — a snapshot of the FIDO Alliance MDS3 blob captured at package release, verified against the pinned root, and embedded as a base64 string. The createCachedResolver factory uses it on cold-start when both the cache is empty AND the first network fetch fails. On the first successful refresh the bootstrap is overwritten — it is purely a fallback, never an ongoing source of truth. Tests assert the bootstrap verifies against the pinned root using the standard verifyMetadataBlob path.

7. Manual blob override

createManualResolver({ jws }) and createMemoryResolver({ payload }) let callers bypass the network entirely:

  • Air-gapped deployments ship the JWS over an out-of-band channel and inject it directly.
  • Tests seed a known payload without exercising the network or scheduler.
  • Enterprise allow-lists can be expressed as a small hand-curated payload that overlays the public blob.

createManualResolver runs the same verifyMetadataBlob pipeline so an untrusted JWS still cannot be injected.

8. Status filter

createStatusFilter({ allow?, deny? }):

  • deny wins — a status present in deny is always rejected, even if also in allow.
  • If allow is supplied, only listed statuses pass.
  • If only deny is supplied, everything except deny passes.
  • If neither is supplied, the default deny set is { REVOKED, USER_VERIFICATION_BYPASS, ATTESTATION_KEY_COMPROMISE, USER_KEY_REMOTE_COMPROMISE, USER_KEY_PHYSICAL_COMPROMISE }.

verifyAttestation consults the filter (or the default) to decide whether to downgrade trusted.

9. Attestation-time lookup flow

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.lookupByCertKeyId(ski(result.trustPath[0]))
         : input.metadata.lookup(attestedCredentialData.aaguid)
  4. if lookup === undefined: return { ...result, metadata: undefined }
  5. filter = input.statusFilter ?? DEFAULT_STATUS_FILTER
  6. accepted = filter.accept(lookup.currentStatus)
  7. return { ...result, trusted: result.trusted && accepted, metadata: lookup }

fido-u2f authenticators predate AAGUID and are MDS3-keyed by attestation-cert SKI (FIDO MDS3 §3.1.6). The SKI is computed inside webauthn-mds3 so webauthn doesn't gain an X.509 parser at runtime.

10. 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; 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 envelope with a payload whose no field is lower than the cached no — CACHE_ROLLBACK_REFUSED
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
Conditional-fetch poisoning (attacker returns 304 to a request with no prior ETag) The fetcher only sends If-None-Match / If-Modified-Since when the cache holds matching validators; a 304 with no prior validator is treated as BLOB_FETCH_FAILED
Bootstrap-blob staleness Bootstrap is fallback-only; once a fresh blob is fetched it is overwritten; OTel blob_age_ms exposes age so operators can alert
FetchLike impersonation in tests Production wires a real fetch; tests inject deterministic stubs returning fixture bytes — no live network, no vi.mock

11. 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.
  • dep-graph.test.ts enforces zero DOM / Node imports in the public surface.

This means server-side relying parties (Bun, Node, Workers, Deno) and browser-tier consumers can both depend on it without polyfills.

12. CBOR confirmation

FIDO MDS3 blobs are JWS-signed JSON (per FIDO Alliance "Metadata Service Specification" v3.0 §3.1). They are not CBOR. The package therefore does not depend on @suaveplan/cbor — JWS over JSON is the entire wire format. (CBOR appears elsewhere in WebAuthn for attestationObject, but that is handled in @suaveplan/webauthn's parser, not here.)

13. 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.
  • Does not certify new authenticators (FIDO Alliance's job).