Initial Commit
This commit is contained in:
200
openspec/changes/complete-webauthn-mds3/design.md
Normal file
200
openspec/changes/complete-webauthn-mds3/design.md
Normal 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).
|
||||
Reference in New Issue
Block a user