Files
suaveplan/openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-webauthn-mds3/design.md
2026-05-27 23:29:53 +01:00

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

The seam back into webauthn is a single injected interface (MetadataResolver) — 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", [...])
      errors.test.ts
    schemas/
      metadata-blob.schemas.ts + .md   # Zod for the whole MDS3 v3 surface
    fetch/
      fetch-blob.ts + .md + .test.ts
    verify/
      verify-blob.ts + .md + .test.ts
    cache/
      metadata-cache.ts + .md + .test.ts
    refresher/
      metadata-refresher.ts + .md + .test.ts
    resolver/
      types.ts + .md                   # MetadataResolver, MetadataLookup
      memory.ts + .md + .test.ts
      cached.ts + .md + .test.ts
    observability/
      metrics.ts + .md + .test.ts
    __fixtures__/
      mds3-sample-blob.jwt
      mds3-corrupted-blob.jwt
      index.ts
    __benchmarks__/
      verify-blob.bench.ts
      resolve-aaguid.bench.ts
    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[];
    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 {
    resolveByAaguid(aaguid: Uint8Array): Promise<MetadataLookup | undefined>;
    resolveByAttestationCertificateKeyId(
        keyId: Uint8Array,
    ): Promise<MetadataLookup | undefined>;
}
// new optional field on @suaveplan/webauthn's VerifyAttestationInput
export interface VerifyAttestationInput {
    // ...existing fields...
    readonly metadata?: import("@suaveplan/webauthn-mds3").MetadataResolver;
    readonly revokedStatuses?: ReadonlySet<AuthenticatorStatus>; // override default
}

// new optional field on AttestationVerifyResult
export interface AttestationVerifyResult {
    readonly trusted: boolean;
    readonly attestationType: AttestationType;
    readonly trustPath: readonly Uint8Array[];
    readonly metadata?: MetadataLookup; // present only when resolver injected
}

4. Refresh policy

Trigger Behaviour
First start (no cached blob) Fetch immediately, verify, cache, schedule next at nextUpdate - jitter
Scheduled tick Fetch, verify, write-through cache; on verify-fail keep prior payload and emit webauthn_mds3.refresh_total{outcome=verify_failed}
nextUpdate absent Fall back to intervalMs (default 24h)
Concurrent call while refresh in flight 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 against the MDS endpoint

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

5. Cache contract

The cache is a thin wrapper around StorageLike<MetadataBlobPayload> from @suaveplan/types:

  • Key namespace webauthn-mds3:blob:v1 — bump the trailing version on any 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; the resolver MAY treat a missing cache entry as "fetch now" if a refresher is wired.
  • Writes are atomic from the resolver's perspective — the cache writes the verified payload only after verifyMetadataBlob resolves successfully.

6. 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.resolveByAttestationCertificateKeyId(ski(result.trustPath[0]))
         : input.metadata.resolveByAaguid(attestedCredentialData.aaguid)
  4. if lookup === undefined: return { ...result, metadata: undefined }
  5. revoked = (input.revokedStatuses ?? DEFAULT_REVOCATION_STATUSES).has(lookup.currentStatus)
  6. return { ...result, trusted: result.trusted && !revoked, metadata: lookup }

DEFAULT_REVOCATION_STATUSES = new Set(["REVOKED", "USER_KEY_REMOTE_COMPROMISE", "USER_KEY_PHYSICAL_COMPROMISE", "ATTESTATION_KEY_COMPROMISE", "USER_VERIFICATION_BYPASS"]).

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

7. 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 + grace; the 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 blob with a payload whose no field is lower than the cached no
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, so concurrent refreshes can't flip the result mid-flight
FetchLike impersonation in tests Production wires a real fetch; tests inject deterministic stubs returning fixture bytes — no live network, no vi.mock (per genesis rule 2)

8. Companion delta to @suaveplan/webauthn

Because the VerifyAttestationInput and AttestationVerifyResult shapes gain optional fields and the post-verifier flow grows two trust-altering steps, the integration is captured as a ## MODIFIED Requirements block in specs/webauthn/spec.md rather than left implicit. The modified requirement keeps every original scenario (the back-compat path) and adds two new scenarios (resolver-injected augmentation; revoked-status downgrade), each with a **Verified by:** line pointing into the existing verify-attestation.test.ts (extended in task 13.4).

9. 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.
  • tests/dep-graph.test.ts (in the package) enforces zero DOM imports.

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

10. 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.