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
StorageLikewith a tenant-prefixing decorator. - Read failures surface
CACHE_READ_FAILEDwith 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
verifyMetadataBlobresolves 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.fetchreference —FetchLikeis injected. - No
localStorage/IndexedDB—StorageLikeis injected. - No
Date.now()—Clockis injected. - No DOM types in the public surface —
MetadataLookupcarriesUint8ArraynotBlob. 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
vaultunlock path —verifyAssertion(the unlock path) does not consume attestation, so MDS3 has no role there.