Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,200 @@
# 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
```text
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
```ts
// 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
```text
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).