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).
|
||||
92
openspec/changes/complete-webauthn-mds3/proposal.md
Normal file
92
openspec/changes/complete-webauthn-mds3/proposal.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# `@suaveplan/webauthn-mds3` — FULL FIDO Metadata Service v3 capability
|
||||
|
||||
**Package**: `@suaveplan/webauthn-mds3` (NEW)
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package (genesis-original)
|
||||
**Date**: 2026-05-17
|
||||
**Path**: `packages/auth/webauthn-mds3/`
|
||||
**Design**: ./design.md
|
||||
**Spec delta**: ./specs/webauthn-mds3/spec.md
|
||||
**Companion delta**: ./specs/webauthn/spec.md (`## MODIFIED Requirements`)
|
||||
**Supersedes**: `openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-webauthn-mds3/`
|
||||
|
||||
## Why
|
||||
|
||||
The `enterprise-webauthn` change (archived 2026-05-11) shipped `verifyAttestation` returning a `trustPath: X509[]` plus an `attestationType`, but explicitly deferred FIDO Metadata Service v3 (MDS3) integration. Without MDS3 a relying party cannot answer the production-critical questions every passkey deployment eventually faces: is this AAGUID a known certified authenticator, is its certification valid, is it revoked, is it on a compromise list, what attestation roots are trusted for it, and what user-verification / key-protection level did the FIDO Alliance certify it at.
|
||||
|
||||
The prior draft (`_superseded-narrow-deferrals-2026-05-17/add-webauthn-mds3/`) covered the bones (fetch / verify / cache / refresh / resolver / integration seam). This change supersedes it with the **full** MDS3 surface that a production relying party actually needs: conditional fetch (ETag/Last-Modified), rollback protection on the cache, configurable status allow/deny lists, a shipped bootstrap blob so cold-start works without the FIDO endpoint, a manual blob override seam for air-gapped deployments and tests, EventBus events on refresh outcomes, OTel spans across the full pipeline, and at least twelve `WEBAUTHN_MDS3.*` error codes covering every reachable failure path.
|
||||
|
||||
## What changes
|
||||
|
||||
### New package: `@suaveplan/webauthn-mds3`
|
||||
|
||||
Universal-tier package at `packages/auth/webauthn-mds3/`. No browser-only or Node-only APIs; `FetchLike`, `StorageLike`, `Clock`, `LoggerLike`, `EventBusLike` are all injected from `@suaveplan/types`.
|
||||
|
||||
#### Wire protocol — fetch + verify
|
||||
|
||||
- ADDED: `fetchMetadataBlob({ fetch, url?, signal?, cache? }): Promise<FetchOutcome>` — pluggable `FetchLike`; periodic refresh uses caller-managed timers; supports **conditional fetch** via `If-None-Match` (ETag) and `If-Modified-Since` (Last-Modified) when `cache` carries the previous response's validators; surfaces `304 Not Modified` as `{ kind: "not-modified" }` so the refresher can skip re-verification.
|
||||
- ADDED: `verifyMetadataBlob({ jws, trustAnchors?, clock? })` — compact-JWS verify against the pinned **FIDO Alliance MDS3 root CA**; full `x5c` chain validation via `@suaveplan/x509`; algorithm allow-list `{ RS256, ES256, ES384 }`; Zod-validated payload against `MetadataBlobPayloadSchema` (FIDO MDS3 §3.1.7); rejects expired blobs via `nextUpdate` check.
|
||||
- ADDED: Note — FIDO MDS3 blobs are JWS-signed JSON, **not CBOR**. No `@suaveplan/cbor` dep needed.
|
||||
|
||||
#### Cache, rollback protection, bootstrap, manual override
|
||||
|
||||
- ADDED: `MetadataCache` — wraps injected `StorageLike<CachedMetadataEnvelope>` under key namespace `webauthn-mds3:blob:v1`. Envelope carries `{ payload, etag?, lastModified?, fetchedAt }`. Writes are **atomic** (single key, last-writer-wins on the underlying `StorageLike`). **Rollback protection**: refuses to overwrite a cached payload with one whose `no` field is strictly lower (FIDO MDS3 §3.1.7 monotonicity).
|
||||
- ADDED: `BOOTSTRAP_BLOB` shipped at `src/bootstrap/blob.ts` — a verified FIDO Alliance blob captured at package release. Used on cold-start when the cache is empty and the first network fetch fails; replaced atomically by the first successful refresh.
|
||||
- ADDED: `createManualResolver({ jws })` / `createMemoryResolver({ payload })` — caller-injected blob overrides for testing, air-gapped deployments, or enterprise allow-lists; bypasses the network entirely.
|
||||
|
||||
#### Lookup, status filtering
|
||||
|
||||
- ADDED: `MetadataResolver` interface with `lookup(aaguid: Uint8Array): Promise<MetadataLookup | undefined>` and `lookupByCertKeyId(ski: Uint8Array): Promise<MetadataLookup | undefined>`. Returns the **full** `MetadataStatement` plus complete `statusReports[]` history (not just the most recent), so callers can render certification timelines.
|
||||
- ADDED: `MetadataLookup` — `{ statement, currentStatus, statusReports, attestationRootCertificates, aaguid?, attestationCertificateKeyIds?, lastUpdate }`.
|
||||
- ADDED: Indexing — `createMemoryResolver` builds an O(1) primary map by 16-byte AAGUID and a secondary O(1) map by `attestationCertificateKeyIdentifier` (SHA-1 SKI hex string). The two maps coexist so a payload that mixes FIDO2 (AAGUID) and U2F (SKI) entries works uniformly.
|
||||
- ADDED: `StatusFilter` — caller-configurable allow/deny lists over `AuthenticatorStatus`. `createStatusFilter({ allow?, deny? })` returns a predicate evaluating "deny wins"; defaults reject `REVOKED`, `USER_VERIFICATION_BYPASS`, `ATTESTATION_KEY_COMPROMISE`, `USER_KEY_REMOTE_COMPROMISE`, `USER_KEY_PHYSICAL_COMPROMISE`.
|
||||
|
||||
#### Scheduled refresher
|
||||
|
||||
- ADDED: `createMetadataRefresher({ fetch, storage, clock, logger, eventBus?, intervalMs?, signal? })` — schedules at `payload.nextUpdate - jitter` (uniform `[-5min, +5min]`) when present; falls back to `intervalMs` (default 24h). Reentrancy guard fires `REFRESHER_ALREADY_RUNNING`; honors `AbortSignal` with `REFRESHER_ABORTED`. Emits EventBus events `webauthn-mds3.refresh.success` / `webauthn-mds3.refresh.failure` and OTel spans `webauthn_mds3.refresh`.
|
||||
|
||||
#### Integration seam back into `@suaveplan/webauthn`
|
||||
|
||||
- MODIFIED (in `@suaveplan/webauthn`): optional `metadata?: MetadataResolver` and `statusFilter?: StatusFilter` on `VerifyAttestationInput`. Absent ⇒ **byte-identical** legacy behaviour (strict back-compat). Present ⇒ post-verifier resolution: AAGUID for FIDO2 formats, SHA-1 SKI of the leaf attestation cert for `fido-u2f` (per FIDO MDS3 §3.1.6); attaches `metadata: MetadataLookup` to the result; downgrades `trusted` to `false` when the filter rejects the resolved status.
|
||||
- ADDED (in `@suaveplan/webauthn`): `AttestationVerifyResult.metadata?: MetadataLookup`.
|
||||
|
||||
#### Errors
|
||||
|
||||
- ADDED: `WEBAUTHN_MDS3.*` codes via `defineCodes` (≥12 — current count: 14):
|
||||
`BLOB_FETCH_FAILED`, `BLOB_NOT_MODIFIED`, `BLOB_MALFORMED`, `BLOB_SIGNATURE_INVALID`, `BLOB_CHAIN_UNTRUSTED`, `BLOB_EXPIRED`, `BLOB_ALG_DISALLOWED`, `BLOB_SCHEMA_MISMATCH`, `CACHE_READ_FAILED`, `CACHE_WRITE_FAILED`, `CACHE_ROLLBACK_REFUSED`, `RESOLVER_LOOKUP_FAILED`, `REFRESHER_ALREADY_RUNNING`, `REFRESHER_ABORTED`.
|
||||
|
||||
#### Observability
|
||||
|
||||
- ADDED: EventBus topics `webauthn-mds3.refresh.success`, `webauthn-mds3.refresh.failure`, `webauthn-mds3.lookup.hit`, `webauthn-mds3.lookup.miss` (typed payloads via `@suaveplan/events`).
|
||||
- ADDED: OTel spans `webauthn_mds3.fetch`, `webauthn_mds3.verify`, `webauthn_mds3.refresh`, `webauthn_mds3.lookup`; counters `webauthn_mds3.refresh_total{outcome}`, `webauthn_mds3.lookup_total{outcome}`; histogram `webauthn_mds3.blob_age_ms`.
|
||||
|
||||
#### Test strategy
|
||||
|
||||
- Bundled FIDO Alliance sample blob fixtures (`__fixtures__/mds3-sample-blob.jwt`, parsed JSON, corrupted variants for negative tests). **No live network in CI** — every test injects a deterministic `FetchLike`.
|
||||
- 100% line / branch / function / statement coverage via istanbul.
|
||||
|
||||
### Subpath exports
|
||||
|
||||
`.`, `./fetch`, `./verify`, `./cache`, `./refresher`, `./resolver`, `./bootstrap`, `./status-filter`, `./events`, `./errors`.
|
||||
|
||||
### Dependencies
|
||||
|
||||
`@suaveplan/types`, `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/jws`, `@suaveplan/x509`, `@suaveplan/crypto-hash`, `@suaveplan/crypto-utils` (all `workspace:^`); `@opentelemetry/api catalog:observability`, `tslib catalog:build`, `zod catalog:forms`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Certifying new authenticators.** That is the FIDO Alliance's exclusive job; this package only **consumes** their published metadata blob.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/webauthn-mds3` — at draft time the package is new.
|
||||
|
||||
- `@suaveplan/webauthn` — [x] tests run / [x] docs reviewed / [ ] no action needed. Gains optional `metadata?: MetadataResolver` and `statusFilter?: StatusFilter` fields on `VerifyAttestationInput` plus optional `metadata?: MetadataLookup` on `AttestationVerifyResult`. Additive only — byte-identical when not supplied.
|
||||
- `@suaveplan/vault` — [ ] tests run / [x] docs reviewed / [x] no action needed (reason: vault unlock consumes `verifyAssertion`, not `verifyAttestation`).
|
||||
- `@suaveplan/react-auth` — [ ] tests run / [x] docs reviewed / [x] no action needed (reason: hook surface unchanged; MDS3 plugs into the server-side verifier).
|
||||
|
||||
## Migration
|
||||
|
||||
No prior MDS3 surface — additive only. `verifyAttestation` consumers see byte-identical behaviour unless they opt in by injecting `metadata`. Consumers that previously open-coded an MDS3 lookup over the returned `trustPath` MAY migrate to the injected seam at their own pace; the "ignore `metadata`, do it yourself" path stays permanently supported.
|
||||
@@ -0,0 +1,334 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: webauthn-mds3 (webauthn-mds3)
|
||||
|
||||
**Package**: `@suaveplan/webauthn-mds3`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: MDS3 blob fetch via injected FetchLike with conditional GET
|
||||
|
||||
The package SHALL export `fetchMetadataBlob({ fetch, url?, signal?, cache? }): Promise<FetchOutcome>` from the `./fetch` subpath. The function SHALL call the injected `FetchLike` (per `@suaveplan/types`) against `url` (default `https://mds.fidoalliance.org/`), include `If-None-Match` and `If-Modified-Since` request headers when `cache` carries the previous response's `etag` / `lastModified` validators, return `{ kind: "not-modified" }` on HTTP `304`, return `{ kind: "fresh", bytes, etag?, lastModified? }` on HTTP `200`, map any other non-2xx status to `WEBAUTHN_MDS3.BLOB_FETCH_FAILED` carrying the status code in `context.status`, and forward `signal` so caller-side cancellation propagates.
|
||||
|
||||
#### Scenario: returns fresh bytes with validators on 200
|
||||
|
||||
- **WHEN** `fetchMetadataBlob({ fetch: stub200WithEtagAndBytes })` is called
|
||||
- **THEN** the resolved value is `{ kind: "fresh", bytes, etag: stubEtag, lastModified: stubLastModified }`
|
||||
|
||||
#### Scenario: returns not-modified on 304
|
||||
|
||||
- **GIVEN** a `cache` envelope with `etag === "W/\"abc\""`
|
||||
- **WHEN** the stub returns `304 Not Modified`
|
||||
- **THEN** the resolved value is `{ kind: "not-modified" }` AND the request carried `If-None-Match: W/"abc"`
|
||||
|
||||
#### Scenario: maps non-2xx to BLOB_FETCH_FAILED
|
||||
|
||||
- **WHEN** the stub returns `503`
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_FETCH_FAILED` and `error.context.status === 503`
|
||||
|
||||
#### Scenario: forwards AbortSignal
|
||||
|
||||
- **GIVEN** an `AbortController` whose `signal` is passed
|
||||
- **WHEN** the controller is aborted before the stub fetch resolves
|
||||
- **THEN** the call rejects, the stub fetch received the same `signal`, and `cause.name === "AbortError"`
|
||||
|
||||
**Verified by:** `src/fetch/fetch-blob.test.ts::"returns fresh bytes with validators on 200"` AND `src/fetch/fetch-blob.test.ts::"returns not-modified on 304 when cache holds matching etag"` AND `src/fetch/fetch-blob.test.ts::"maps non-2xx to BLOB_FETCH_FAILED"` AND `src/fetch/fetch-blob.test.ts::"forwards AbortSignal to the injected fetch"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: MDS3 blob signature verification with pinned FIDO root
|
||||
|
||||
The package SHALL export `verifyMetadataBlob({ jws, trustAnchors?, clock? }): Promise<MetadataBlobPayload>` from the `./verify` subpath. The function SHALL parse the compact JWS, enforce the algorithm allow-list `{ RS256, ES256, ES384 }` (rejecting any other with `WEBAUTHN_MDS3.BLOB_ALG_DISALLOWED`), validate the `x5c` certificate chain to the pinned FIDO Alliance MDS3 root CA (or to caller-supplied `trustAnchors`) via `@suaveplan/x509`, verify the signature via `@suaveplan/jws`, decode the JSON payload, Zod-validate against `MetadataBlobPayloadSchema`, and reject with `WEBAUTHN_MDS3.BLOB_EXPIRED` when `clock.now() > payload.nextUpdate`.
|
||||
|
||||
#### Scenario: verifies the bundled FIDO sample blob
|
||||
|
||||
- **WHEN** `verifyMetadataBlob({ jws: SAMPLE_BLOB_JWT })` is called
|
||||
- **THEN** the resolved value is a typed `MetadataBlobPayload` with `legalHeader`, `no`, `nextUpdate`, and at least one `entries[]` element
|
||||
|
||||
#### Scenario: rejects flipped signature
|
||||
|
||||
- **WHEN** `verifyMetadataBlob({ jws: CORRUPTED_BLOB_JWT })` is called
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_SIGNATURE_INVALID`
|
||||
|
||||
#### Scenario: rejects untrusted chain
|
||||
|
||||
- **WHEN** the JWS `x5c` chain terminates at a non-pinned root
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_CHAIN_UNTRUSTED`
|
||||
|
||||
#### Scenario: rejects disallowed algorithm
|
||||
|
||||
- **WHEN** the JWS header `alg === "HS256"`
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_ALG_DISALLOWED`
|
||||
|
||||
#### Scenario: rejects expired blob
|
||||
|
||||
- **WHEN** `clock.now() > payload.nextUpdate`
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_EXPIRED`
|
||||
|
||||
#### Scenario: rejects schema mismatch
|
||||
|
||||
- **WHEN** the payload is missing the required `entries` field
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_SCHEMA_MISMATCH`
|
||||
|
||||
**Verified by:** `src/verify/verify-blob.test.ts::"verifies the bundled FIDO sample blob"` AND `src/verify/verify-blob.test.ts::"rejects flipped signature"` AND `src/verify/verify-blob.test.ts::"rejects untrusted chain"` AND `src/verify/verify-blob.test.ts::"rejects disallowed algorithm"` AND `src/verify/verify-blob.test.ts::"rejects expired blob"` AND `src/verify/verify-blob.test.ts::"rejects schema mismatch"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Atomic, rollback-protected metadata cache
|
||||
|
||||
The package SHALL export `MetadataCache` from the `./cache` subpath that wraps an injected `StorageLike<CachedMetadataEnvelope>` under key namespace `webauthn-mds3:blob:v1`. The cache SHALL surface storage read failures as `WEBAUTHN_MDS3.CACHE_READ_FAILED` and write failures as `WEBAUTHN_MDS3.CACHE_WRITE_FAILED`, attaching the underlying error as `cause`. The cache SHALL atomically replace the prior envelope on successful write (single-key semantics). The cache SHALL refuse to write a payload whose `no` field is strictly lower than the currently-cached payload's `no`, rejecting with `WEBAUTHN_MDS3.CACHE_ROLLBACK_REFUSED`.
|
||||
|
||||
#### Scenario: round-trips a verified envelope
|
||||
|
||||
- **WHEN** `cache.set({ payload, etag, lastModified, fetchedAt })` then `cache.get()` is called against an in-memory `StorageLike`
|
||||
- **THEN** the returned envelope deep-equals the written envelope
|
||||
|
||||
#### Scenario: surfaces storage read failure
|
||||
|
||||
- **GIVEN** a `StorageLike` whose `get` rejects with `new Error("disk full")`
|
||||
- **WHEN** `cache.get()` is called
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.CACHE_READ_FAILED` and `error.cause.message === "disk full"`
|
||||
|
||||
#### Scenario: refuses to write a rolled-back payload
|
||||
|
||||
- **GIVEN** a cache holding an envelope with `payload.no: 42`
|
||||
- **WHEN** `cache.set(envelopeWithNo41)` is called
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.CACHE_ROLLBACK_REFUSED` and `error.context.cachedNo === 42` AND `error.context.incomingNo === 41`
|
||||
|
||||
#### Scenario: write is atomic — failure mid-write leaves prior envelope intact
|
||||
|
||||
- **GIVEN** a `StorageLike` whose `set` rejects after returning the prior `get` value
|
||||
- **WHEN** `cache.set(newEnvelope)` is called
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.CACHE_WRITE_FAILED` AND a subsequent `cache.get()` returns the prior envelope
|
||||
|
||||
**Verified by:** `src/cache/metadata-cache.test.ts::"round-trips a verified envelope"` AND `src/cache/metadata-cache.test.ts::"surfaces storage read failure as CACHE_READ_FAILED"` AND `src/cache/metadata-cache.test.ts::"refuses to write a rolled-back payload"` AND `src/cache/metadata-cache.test.ts::"write failure leaves prior envelope intact"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Bootstrap blob shipped as cold-start fallback
|
||||
|
||||
The package SHALL ship a verified FIDO Alliance MDS3 blob as `BOOTSTRAP_BLOB` (base64 JWS) and `BOOTSTRAP_PAYLOAD` (parsed `MetadataBlobPayload`) from the `./bootstrap` subpath. The bootstrap SHALL verify against the pinned FIDO root via the standard `verifyMetadataBlob` pipeline. `createCachedResolver` SHALL fall back to the bootstrap payload when both the cache is empty AND the first network refresh fails; the bootstrap SHALL be overwritten by the first successful subsequent refresh.
|
||||
|
||||
#### Scenario: bootstrap verifies against the pinned root
|
||||
|
||||
- **WHEN** `verifyMetadataBlob({ jws: BOOTSTRAP_BLOB })` is called
|
||||
- **THEN** the call resolves and returns a payload deep-equal to `BOOTSTRAP_PAYLOAD`
|
||||
|
||||
#### Scenario: cached resolver uses bootstrap on cold-start fetch failure
|
||||
|
||||
- **GIVEN** an empty cache and a `fetch` that rejects with `BLOB_FETCH_FAILED`
|
||||
- **WHEN** `resolver.lookup(knownBootstrapAaguid)` is called
|
||||
- **THEN** the resolver returns a `MetadataLookup` sourced from `BOOTSTRAP_PAYLOAD`
|
||||
|
||||
#### Scenario: bootstrap overwritten by first successful refresh
|
||||
|
||||
- **GIVEN** a cached resolver that fell back to bootstrap on cold start
|
||||
- **WHEN** the next refresh succeeds and writes a fresh envelope
|
||||
- **THEN** subsequent lookups return data from the fresh envelope, not bootstrap
|
||||
|
||||
**Verified by:** `src/bootstrap/blob.test.ts::"bootstrap verifies against the pinned FIDO root"` AND `src/resolver/cached.test.ts::"uses bootstrap when cache empty and fetch fails"` AND `src/resolver/cached.test.ts::"bootstrap is overwritten by first successful refresh"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Manual blob override
|
||||
|
||||
The package SHALL export `createManualResolver({ jws }): Promise<MetadataResolver>` from the `./resolver` subpath. The factory SHALL run the supplied JWS through `verifyMetadataBlob` (rejecting on any verification failure) and return a memory-backed resolver over the verified payload. This seam SHALL enable air-gapped deployments, deterministic tests, and enterprise allow-lists without exercising the network or the refresher.
|
||||
|
||||
#### Scenario: manual resolver verifies the injected JWS
|
||||
|
||||
- **WHEN** `createManualResolver({ jws: SAMPLE_BLOB_JWT })` is called
|
||||
- **THEN** the returned resolver looks up AAGUIDs from the sample payload AND no network fetch occurred
|
||||
|
||||
#### Scenario: manual resolver rejects an invalid JWS
|
||||
|
||||
- **WHEN** `createManualResolver({ jws: CORRUPTED_BLOB_JWT })` is called
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_SIGNATURE_INVALID`
|
||||
|
||||
**Verified by:** `src/resolver/manual.test.ts::"verifies the injected JWS and returns a memory resolver"` AND `src/resolver/manual.test.ts::"rejects an invalid JWS"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Scheduled metadata refresher with EventBus + OTel
|
||||
|
||||
The package SHALL export `createMetadataRefresher({ fetch, storage, clock, logger, eventBus?, intervalMs?, signal? }): MetadataRefresher` from the `./refresher` subpath. The refresher SHALL schedule the next refresh at `payload.nextUpdate - jitter` (uniform jitter in `[-5min, +5min]`) when `nextUpdate` is present, otherwise at `now + intervalMs` (default 24h). The refresher SHALL throw `WEBAUTHN_MDS3.REFRESHER_ALREADY_RUNNING` on concurrent `refresh()`, reject with `WEBAUTHN_MDS3.REFRESHER_ABORTED` when `AbortSignal` fires, skip re-verification when the conditional fetch returns `{ kind: "not-modified" }`, retain the prior cached envelope when verification fails on a fresh fetch, emit `webauthn-mds3.refresh.success` / `webauthn-mds3.refresh.failure` on the injected `EventBusLike`, and increment the OTel counter `webauthn_mds3.refresh_total{outcome}`.
|
||||
|
||||
#### Scenario: schedules at nextUpdate minus jitter
|
||||
|
||||
- **WHEN** the refresher is started against a payload with `nextUpdate = now + 1h`
|
||||
- **THEN** the next `clock.setTimeout` call is in `[55min, 65min]`
|
||||
|
||||
#### Scenario: falls back to intervalMs when nextUpdate absent
|
||||
|
||||
- **WHEN** started against a payload with `nextUpdate: undefined` and `intervalMs: 1h`
|
||||
- **THEN** the next `clock.setTimeout` call is approximately `now + 1h`
|
||||
|
||||
#### Scenario: reentrancy guard fires REFRESHER_ALREADY_RUNNING
|
||||
|
||||
- **WHEN** `refresh()` is called while a prior `refresh()` is still pending
|
||||
- **THEN** the second call rejects with `WEBAUTHN_MDS3.REFRESHER_ALREADY_RUNNING`
|
||||
|
||||
#### Scenario: aborts on signal
|
||||
|
||||
- **GIVEN** `signal: ac.signal`
|
||||
- **WHEN** `ac.abort()` is called mid-fetch
|
||||
- **THEN** the pending refresh rejects with `WEBAUTHN_MDS3.REFRESHER_ABORTED`
|
||||
|
||||
#### Scenario: 304 skips re-verification
|
||||
|
||||
- **WHEN** the conditional fetch returns `{ kind: "not-modified" }`
|
||||
- **THEN** `verifyMetadataBlob` is NOT called AND the cached envelope's `fetchedAt` is updated
|
||||
|
||||
#### Scenario: verify failure preserves prior envelope
|
||||
|
||||
- **GIVEN** a cache holding a valid envelope
|
||||
- **WHEN** the refresh fetches a fresh JWS that fails verification
|
||||
- **THEN** `cache.get()` still returns the prior envelope AND `webauthn_mds3.refresh_total{outcome="verify_failed"} === 1`
|
||||
|
||||
#### Scenario: emits EventBus refresh.success on success
|
||||
|
||||
- **WHEN** a refresh completes successfully
|
||||
- **THEN** the EventBus received exactly one `webauthn-mds3.refresh.success` event AND `webauthn_mds3.refresh_total{outcome="ok"} === 1`
|
||||
|
||||
**Verified by:** `src/refresher/metadata-refresher.test.ts::"schedules at nextUpdate minus jitter"` AND `src/refresher/metadata-refresher.test.ts::"falls back to intervalMs when nextUpdate absent"` AND `src/refresher/metadata-refresher.test.ts::"reentrancy guard fires REFRESHER_ALREADY_RUNNING"` AND `src/refresher/metadata-refresher.test.ts::"aborts on signal"` AND `src/refresher/metadata-refresher.test.ts::"304 skips re-verification"` AND `src/refresher/metadata-refresher.test.ts::"verify failure preserves prior envelope"` AND `src/refresher/metadata-refresher.test.ts::"emits EventBus refresh.success on success"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: O(1) AAGUID and cert-key-id lookup
|
||||
|
||||
The package SHALL export a `MetadataResolver` interface with `lookup(aaguid: Uint8Array): Promise<MetadataLookup | undefined>` and `lookupByCertKeyId(ski: Uint8Array): Promise<MetadataLookup | undefined>`, returning the full `MetadataStatement` plus the complete `StatusReport[]` history (not just the most recent). The package SHALL ship `createMemoryResolver({ payload })` (synchronous, in-memory; O(1) primary map by 16-byte AAGUID and secondary O(1) map by `attestationCertificateKeyIdentifier` SHA-1 SKI), `createCachedResolver({ cache, refresher?, bootstrap? })` (read-through; lazy-refreshes; falls back to bootstrap), and `createManualResolver({ jws })` (see separate requirement). AAGUID comparison SHALL use `constantTimeEqual` from `@suaveplan/crypto-utils`.
|
||||
|
||||
#### Scenario: AAGUID hit returns a typed MetadataLookup with full status history
|
||||
|
||||
- **GIVEN** a memory resolver seeded with a payload whose entry has `statusReports.length === 3`
|
||||
- **WHEN** `lookup(thatAaguid)` is called
|
||||
- **THEN** the resolved value has `statement.aaguid === "..."`, `currentStatus` set to the latest report's status, AND `statusReports.length === 3`
|
||||
|
||||
#### Scenario: AAGUID miss returns undefined
|
||||
|
||||
- **WHEN** `lookup(unknownAaguid)` is called
|
||||
- **THEN** the resolved value is `undefined`
|
||||
|
||||
#### Scenario: U2F attestation-cert SKI lookup
|
||||
|
||||
- **GIVEN** a memory resolver seeded with a payload entry whose `attestationCertificateKeyIdentifiers` includes a specific SKI
|
||||
- **WHEN** `lookupByCertKeyId(thatSki)` is called
|
||||
- **THEN** the resolved value's `statement` matches that entry
|
||||
|
||||
#### Scenario: cached resolver lazy-refreshes when cache empty
|
||||
|
||||
- **GIVEN** a cached resolver with empty cache and wired refresher
|
||||
- **WHEN** `lookup(any)` is called
|
||||
- **THEN** the refresher's `refresh()` is invoked exactly once before the resolver returns
|
||||
|
||||
**Verified by:** `src/resolver/memory.test.ts::"AAGUID hit returns a typed MetadataLookup with full status history"` AND `src/resolver/memory.test.ts::"AAGUID miss returns undefined"` AND `src/resolver/memory.test.ts::"U2F attestation-cert SKI lookup"` AND `src/resolver/cached.test.ts::"lazy-refreshes when cache empty"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Configurable AuthenticatorStatus allow/deny filter
|
||||
|
||||
The package SHALL export `createStatusFilter({ allow?, deny? }): StatusFilter` from the `./status-filter` subpath. The filter SHALL implement deny-wins semantics: a status in `deny` is always rejected; if `allow` is supplied only listed statuses are accepted; if neither is supplied the default deny set `DEFAULT_DENY_STATUSES = { REVOKED, USER_VERIFICATION_BYPASS, ATTESTATION_KEY_COMPROMISE, USER_KEY_REMOTE_COMPROMISE, USER_KEY_PHYSICAL_COMPROMISE }` applies.
|
||||
|
||||
#### Scenario: default deny set rejects REVOKED
|
||||
|
||||
- **WHEN** `createStatusFilter({}).accept("REVOKED")` is called
|
||||
- **THEN** the result is `false`
|
||||
|
||||
#### Scenario: allow-list rejects unlisted
|
||||
|
||||
- **WHEN** `createStatusFilter({ allow: new Set(["FIDO_CERTIFIED_L2"]) }).accept("FIDO_CERTIFIED_L1")` is called
|
||||
- **THEN** the result is `false`
|
||||
|
||||
#### Scenario: deny wins over allow
|
||||
|
||||
- **WHEN** `createStatusFilter({ allow: new Set(["REVOKED"]), deny: new Set(["REVOKED"]) }).accept("REVOKED")` is called
|
||||
- **THEN** the result is `false`
|
||||
|
||||
**Verified by:** `src/status-filter/status-filter.test.ts::"default deny set rejects REVOKED"` AND `src/status-filter/status-filter.test.ts::"allow-list rejects unlisted statuses"` AND `src/status-filter/status-filter.test.ts::"deny wins over allow"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Error-code registry (≥12 codes)
|
||||
|
||||
The package SHALL declare its error codes via `defineCodes("WEBAUTHN_MDS3", [...])` covering at minimum `BLOB_FETCH_FAILED`, `BLOB_NOT_MODIFIED`, `BLOB_MALFORMED`, `BLOB_SIGNATURE_INVALID`, `BLOB_CHAIN_UNTRUSTED`, `BLOB_EXPIRED`, `BLOB_ALG_DISALLOWED`, `BLOB_SCHEMA_MISMATCH`, `CACHE_READ_FAILED`, `CACHE_WRITE_FAILED`, `CACHE_ROLLBACK_REFUSED`, `RESOLVER_LOOKUP_FAILED`, `REFRESHER_ALREADY_RUNNING`, and `REFRESHER_ABORTED`. Every declared code SHALL be thrown by at least one production call site exercised by a test.
|
||||
|
||||
#### Scenario: every code is reachable
|
||||
|
||||
- **WHEN** the test harness greps `src/` for `WEBAUTHN_MDS3_CODES.<CODE>` references for every declared `CODE`
|
||||
- **THEN** every code has at least one reference outside `src/errors/`
|
||||
|
||||
**Verified by:** `src/errors/errors.test.ts::"every WEBAUTHN_MDS3 code is thrown by at least one production path"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: EventBus topics for refresh and lookup outcomes
|
||||
|
||||
The package SHALL expose typed EventBus topics from the `./events` subpath: `webauthn-mds3.refresh.success` (`{ no, fetchedAt, durationMs }`), `webauthn-mds3.refresh.failure` (`{ code, durationMs }`), `webauthn-mds3.lookup.hit` (`{ aaguid?, ski?, currentStatus }`), `webauthn-mds3.lookup.miss` (`{ aaguid?, ski? }`). Each topic SHALL fire from its production call site when an `EventBusLike` is supplied.
|
||||
|
||||
#### Scenario: refresh.success fires on successful refresh
|
||||
|
||||
- **WHEN** a refresh completes successfully against an injected EventBus
|
||||
- **THEN** the bus received exactly one `webauthn-mds3.refresh.success` event with `payload.no === payload.no`
|
||||
|
||||
#### Scenario: lookup.miss fires when lookup returns undefined
|
||||
|
||||
- **WHEN** `lookup(unknownAaguid)` returns `undefined` against an injected EventBus
|
||||
- **THEN** the bus received exactly one `webauthn-mds3.lookup.miss` event
|
||||
|
||||
**Verified by:** `src/events/topics.test.ts::"refresh.success fires on successful refresh"` AND `src/events/topics.test.ts::"lookup.miss fires when lookup returns undefined"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OTel counters, histogram, and spans
|
||||
|
||||
The package SHALL emit `webauthn_mds3.refresh_total{outcome}` and `webauthn_mds3.lookup_total{outcome}` counters and a `webauthn_mds3.blob_age_ms` histogram via `@opentelemetry/api`, and SHALL open spans `webauthn_mds3.fetch`, `webauthn_mds3.verify`, `webauthn_mds3.refresh`, `webauthn_mds3.lookup`. When no `MeterProvider` / `TracerProvider` is registered, the metrics and spans SHALL no-op silently.
|
||||
|
||||
#### Scenario: refresh_total records on success
|
||||
|
||||
- **WHEN** a refresh completes successfully against an in-memory `MeterProvider`
|
||||
- **THEN** `webauthn_mds3.refresh_total{outcome="ok"}` is `1`
|
||||
|
||||
#### Scenario: lookup_total records on miss
|
||||
|
||||
- **WHEN** `lookup` returns `undefined`
|
||||
- **THEN** `webauthn_mds3.lookup_total{outcome="miss"}` is `1`
|
||||
|
||||
#### Scenario: blob_age_ms records on read
|
||||
|
||||
- **WHEN** `cache.get()` returns an envelope aged 1h
|
||||
- **THEN** `webauthn_mds3.blob_age_ms` has a recorded value in the bucket containing `3_600_000`
|
||||
|
||||
#### Scenario: verify span carries algorithm attribute
|
||||
|
||||
- **WHEN** `verifyMetadataBlob` runs against an in-memory `TracerProvider`
|
||||
- **THEN** a `webauthn_mds3.verify` span ended with attribute `webauthn_mds3.alg === "RS256"`
|
||||
|
||||
**Verified by:** `src/observability/metrics.test.ts::"refresh_total records on success"` AND `src/observability/metrics.test.ts::"lookup_total records on miss"` AND `src/observability/metrics.test.ts::"blob_age_ms records on read"` AND `src/observability/spans.test.ts::"verify span carries algorithm attribute"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Universal-tier purity
|
||||
|
||||
The package SHALL NOT import any DOM, browser, or Node-only API. `FetchLike`, `StorageLike`, `Clock`, `LoggerLike`, and `EventBusLike` SHALL be injected through the public API surface so the package runs unmodified on Bun, Node, Cloudflare Workers, Deno, and inside a browser bundle.
|
||||
|
||||
#### Scenario: dep-graph harness sees no DOM or Node types
|
||||
|
||||
- **WHEN** the dep-graph test sweeps the public surface for `Window`, `Document`, `localStorage`, `Blob`, `node:*` references
|
||||
- **THEN** zero matches are found
|
||||
|
||||
**Verified by:** `src/dep-graph.test.ts::"public surface imports zero DOM or Node-only types"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports
|
||||
|
||||
The package SHALL expose subpath exports for `.`, `./fetch`, `./verify`, `./cache`, `./bootstrap`, `./refresher`, `./resolver`, `./status-filter`, `./events`, and `./errors` in its `package.json` `exports` map so consumers can tree-shake unused capabilities.
|
||||
|
||||
#### Scenario: every subpath resolves
|
||||
|
||||
- **WHEN** the package's `index.test.ts` imports from each subpath
|
||||
- **THEN** every import resolves to the expected module
|
||||
|
||||
**Verified by:** `src/index.test.ts::"every documented subpath resolves through the package's exports map"`
|
||||
@@ -0,0 +1,55 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: webauthn (webauthn)
|
||||
|
||||
**Package**: `@suaveplan/webauthn`
|
||||
**Status**: PROPOSED (companion delta of `complete-webauthn-mds3`)
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Attestation verification dispatch
|
||||
|
||||
The package SHALL export `verifyAttestation(input: VerifyAttestationInput): Promise<AttestationVerifyResult>` from the `./verifier` subpath. The function SHALL dispatch on `input.attestationObject.fmt` to the per-format verifier registered in `input.registry`, throwing `WEBAUTHN.ATTESTATION_FORMAT_UNSUPPORTED` when no verifier is registered. `VerifyAttestationInput` SHALL accept an optional `metadata?: MetadataResolver` field and an optional `statusFilter?: StatusFilter` field (both typed via `import type` from `@suaveplan/webauthn-mds3` — runtime-optional). `AttestationVerifyResult` SHALL carry an optional `metadata?: MetadataLookup` field. When `input.metadata` is `undefined` the returned `AttestationVerifyResult` SHALL be byte-identical to the pre-modification contract (strict back-compat). When `input.metadata` is supplied, after the per-format verifier returns, `verifyAttestation` SHALL resolve the AAGUID — or, for `fmt === "fido-u2f"` or an all-zero AAGUID, the SHA-1 SKI of the leaf attestation certificate (FIDO MDS3 §3.1.6) — call `lookup` / `lookupByCertKeyId` on the supplied resolver, set `result.metadata` to the returned `MetadataLookup` (or omit when `undefined`), and downgrade `result.trusted` to `false` when `(input.statusFilter ?? DEFAULT_STATUS_FILTER).accept(lookup.currentStatus) === false`.
|
||||
|
||||
#### Scenario: dispatches to registered format verifier
|
||||
|
||||
- **WHEN** `verifyAttestation({ attestationObject: { fmt: "packed", ... }, registry: defaultAttestationRegistry(), ... })` is called
|
||||
- **THEN** the result is produced by the `packed` verifier and includes `trustPath`, `attestationType`, `trusted`
|
||||
|
||||
#### Scenario: throws WEBAUTHN.ATTESTATION_FORMAT_UNSUPPORTED on unknown fmt
|
||||
|
||||
- **WHEN** `verifyAttestation({ attestationObject: { fmt: "unknown-fmt", ... }, registry: emptyRegistry })` is called
|
||||
- **THEN** the call rejects with `WEBAUTHN.ATTESTATION_FORMAT_UNSUPPORTED`
|
||||
|
||||
#### Scenario: omits metadata field when no resolver injected (back-compat)
|
||||
|
||||
- **WHEN** `verifyAttestation(input)` is called with `input.metadata === undefined`
|
||||
- **THEN** the resolved `AttestationVerifyResult` has `metadata === undefined` and is byte-for-byte identical to the legacy (pre-modification) result for the same inputs
|
||||
|
||||
#### Scenario: augments result with metadata when resolver injected
|
||||
|
||||
- **GIVEN** a `MetadataResolver` whose `lookup` returns a `MetadataLookup` with `currentStatus === "FIDO_CERTIFIED_L1"`
|
||||
- **WHEN** `verifyAttestation({ ..., metadata: resolver })` is called against a `packed` attestation carrying a known AAGUID
|
||||
- **THEN** `result.metadata` deep-equals the lookup AND `result.trusted` is the verifier's original value (unchanged)
|
||||
|
||||
#### Scenario: downgrades trusted=false when default filter rejects status
|
||||
|
||||
- **GIVEN** a `MetadataResolver` whose `lookup` returns `currentStatus === "REVOKED"` and no caller-supplied `statusFilter`
|
||||
- **WHEN** `verifyAttestation({ ..., metadata: resolver })` is called and the per-format verifier returned `trusted: true`
|
||||
- **THEN** the merged `result.trusted` is `false` AND `result.metadata.currentStatus === "REVOKED"`
|
||||
|
||||
#### Scenario: caller-supplied statusFilter overrides default
|
||||
|
||||
- **GIVEN** a `MetadataResolver` returning `currentStatus === "REVOKED"` and `statusFilter: createStatusFilter({ allow: new Set(["REVOKED"]) })`
|
||||
- **WHEN** `verifyAttestation({ ..., metadata: resolver, statusFilter })` is called and the verifier returned `trusted: true`
|
||||
- **THEN** the merged `result.trusted` is `true` AND `result.metadata.currentStatus === "REVOKED"`
|
||||
|
||||
#### Scenario: resolves U2F by attestation-cert SKI
|
||||
|
||||
- **GIVEN** a `MetadataResolver` and a `fido-u2f` attestation whose leaf cert has SKI `0xDEAD…`
|
||||
- **WHEN** `verifyAttestation({ ..., metadata: resolver })` is called
|
||||
- **THEN** the resolver's `lookupByCertKeyId(0xDEAD…)` is invoked exactly once AND `lookup` is never called
|
||||
|
||||
**Verified by:** `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"dispatches to registered format verifier"` AND `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"throws WEBAUTHN.ATTESTATION_FORMAT_UNSUPPORTED on unknown fmt"` AND `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"omits metadata field when no resolver injected (back-compat)"` AND `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"augments result with metadata when resolver injected"` AND `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"downgrades trusted=false when default filter rejects status"` AND `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"caller-supplied statusFilter overrides default"` AND `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"resolves U2F by attestation-cert SKI"`
|
||||
127
openspec/changes/complete-webauthn-mds3/tasks.md
Normal file
127
openspec/changes/complete-webauthn-mds3/tasks.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Tasks — `complete-webauthn-mds3`
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## 1. Package scaffolding
|
||||
|
||||
- [ ] 1.1 Create `packages/auth/webauthn-mds3/` per genesis package layout.
|
||||
- [ ] 1.2 `package.json` at `version: 0.0.0`, `sideEffects: false`, `workspace:^` for internal deps, `catalog:*` for external.
|
||||
- [ ] 1.3 `tsconfig.json` extending `@suaveplan/typescript-config` universal preset.
|
||||
- [ ] 1.4 `vitest.config.ts` via `createTestConfig()` from `@suaveplan/testing/config`; coverage 100/100/100/100; `provider: 'istanbul'`.
|
||||
- [ ] 1.5 Subpath exports: `.`, `./fetch`, `./verify`, `./cache`, `./bootstrap`, `./refresher`, `./resolver`, `./status-filter`, `./events`, `./errors`.
|
||||
- [ ] 1.6 Root `README.md` with one-paragraph description, install, quick-start, Modules section.
|
||||
|
||||
## 2. Constants + FIDO root pinning
|
||||
|
||||
- [ ] 2.1 `src/constants/fido-root.ts` + `.md` — pin FIDO Alliance MDS3 root CA DER bytes + SHA-256 fingerprint; export `FIDO_MDS3_DEFAULT_URL`, `DEFAULT_REFRESH_INTERVAL_MS`, `DEFAULT_DENY_STATUSES`, `JITTER_WINDOW_MS`.
|
||||
|
||||
## 3. Error codes (≥12; current: 14)
|
||||
|
||||
- [ ] 3.1 `src/errors/error-codes.ts` + `.md` — `defineCodes("WEBAUTHN_MDS3", [BLOB_FETCH_FAILED, BLOB_NOT_MODIFIED, BLOB_MALFORMED, BLOB_SIGNATURE_INVALID, BLOB_CHAIN_UNTRUSTED, BLOB_EXPIRED, BLOB_ALG_DISALLOWED, BLOB_SCHEMA_MISMATCH, CACHE_READ_FAILED, CACHE_WRITE_FAILED, CACHE_ROLLBACK_REFUSED, RESOLVER_LOOKUP_FAILED, REFRESHER_ALREADY_RUNNING, REFRESHER_ABORTED])`.
|
||||
- [ ] 3.2 `src/errors/errors.test.ts` — assert every code is reachable from a production path.
|
||||
|
||||
## 4. Schemas
|
||||
|
||||
- [ ] 4.1 `src/schemas/metadata-blob.schemas.ts` + `.md` — `MetadataBlobPayloadSchema`, `MetadataStatementSchema`, `AuthenticatorStatusSchema`, `StatusReportSchema`, `BiometricStatusReportSchema`, `EcdaaTrustAnchorSchema`, `MetadataBlobEntrySchema` tracking FIDO MDS3 §3.1.
|
||||
|
||||
## 5. Fetch (conditional)
|
||||
|
||||
- [ ] 5.1 `src/fetch/fetch-blob.ts` + `.md` — `fetchMetadataBlob({ fetch, url?, signal?, cache? })` returning `FetchOutcome`; sends `If-None-Match` / `If-Modified-Since` when validators present; maps `304` → `{ kind: "not-modified" }`; non-2xx (excluding 304) → `BLOB_FETCH_FAILED`.
|
||||
- [ ] 5.2 `src/fetch/fetch-blob.test.ts` — happy, 304, non-2xx, abort, conditional headers asserted.
|
||||
|
||||
## 6. Verify
|
||||
|
||||
- [ ] 6.1 `src/verify/verify-blob.ts` + `.md` — JWS via `@suaveplan/jws`; alg allow-list `{RS256, ES256, ES384}`; chain to pinned FIDO root via `@suaveplan/x509`; decode + Zod-validate payload; reject expired.
|
||||
- [ ] 6.2 `src/verify/verify-blob.test.ts` — happy, signature-flipped, chain-broken, expired, alg-disallowed, schema-mismatch.
|
||||
|
||||
## 7. Cache (rollback-protected)
|
||||
|
||||
- [ ] 7.1 `src/cache/metadata-cache.ts` + `.md` — `MetadataCache` over `StorageLike<CachedMetadataEnvelope>`; namespace `webauthn-mds3:blob:v1`; rejects writes whose `payload.no < cached.payload.no` with `CACHE_ROLLBACK_REFUSED`.
|
||||
- [ ] 7.2 `src/cache/metadata-cache.test.ts` — round-trip, rollback refusal, read/write failure mapping.
|
||||
|
||||
## 8. Bootstrap blob
|
||||
|
||||
- [ ] 8.1 `src/bootstrap/blob.ts` + `.md` — export `BOOTSTRAP_BLOB: string` (base64 JWS) plus a verified `BOOTSTRAP_PAYLOAD: MetadataBlobPayload` derived at module load.
|
||||
- [ ] 8.2 `src/bootstrap/blob.test.ts` — `verifyMetadataBlob(BOOTSTRAP_BLOB)` resolves; bootstrap is overridden by first successful refresh.
|
||||
|
||||
## 9. Refresher
|
||||
|
||||
- [ ] 9.1 `src/refresher/metadata-refresher.ts` + `.md` — `createMetadataRefresher({ fetch, storage, clock, logger, eventBus?, intervalMs?, signal? })`; reentrancy guard; jittered schedule at `nextUpdate - jitter`; conditional fetch; EventBus + OTel.
|
||||
- [ ] 9.2 `src/refresher/metadata-refresher.test.ts` — schedule, fallback intervalMs, reentrancy, abort, 304-skips-verify, verify-fail keeps prior envelope, EventBus + OTel emitted.
|
||||
|
||||
## 10. Resolver
|
||||
|
||||
- [ ] 10.1 `src/resolver/types.ts` + `.md` — `MetadataResolver`, `MetadataLookup`.
|
||||
- [ ] 10.2 `src/resolver/memory.ts` + `.md` + `.test.ts` — `createMemoryResolver({ payload })`; O(1) primary map by AAGUID; secondary O(1) map by SKI hex; constant-time AAGUID equality.
|
||||
- [ ] 10.3 `src/resolver/cached.ts` + `.md` + `.test.ts` — `createCachedResolver({ cache, refresher?, bootstrap? })`; lazy refresh on first lookup; falls back to bootstrap when cache empty AND fetch fails.
|
||||
- [ ] 10.4 `src/resolver/manual.ts` + `.md` + `.test.ts` — `createManualResolver({ jws })`; runs `verifyMetadataBlob` then delegates to memory resolver.
|
||||
|
||||
## 11. Status filter
|
||||
|
||||
- [ ] 11.1 `src/status-filter/status-filter.ts` + `.md` — `createStatusFilter({ allow?, deny? })`; deny wins; defaults `DEFAULT_DENY_STATUSES`.
|
||||
- [ ] 11.2 `src/status-filter/status-filter.test.ts` — allow-only, deny-only, both, default deny set, deny-wins precedence.
|
||||
|
||||
## 12. Events
|
||||
|
||||
- [ ] 12.1 `src/events/topics.ts` + `.md` — typed topics `webauthn-mds3.refresh.success`, `webauthn-mds3.refresh.failure`, `webauthn-mds3.lookup.hit`, `webauthn-mds3.lookup.miss` via `@suaveplan/events`.
|
||||
- [ ] 12.2 `src/events/topics.test.ts` — every topic fires from its production call site.
|
||||
|
||||
## 13. Observability
|
||||
|
||||
- [ ] 13.1 `src/observability/metrics.ts` + `.md` — counters `refresh_total{outcome}`, `lookup_total{outcome}`; histogram `blob_age_ms`. No-op when no exporter.
|
||||
- [ ] 13.2 `src/observability/spans.ts` + `.md` — spans `fetch`, `verify`, `refresh`, `lookup` with attribute set + status mapping.
|
||||
- [ ] 13.3 `src/observability/{metrics,spans}.test.ts` — every metric and span records on its production path.
|
||||
|
||||
## 14. Fixtures
|
||||
|
||||
- [ ] 14.1 `src/__fixtures__/mds3-sample-blob.jwt` — captured FIDO Alliance conformance sample.
|
||||
- [ ] 14.2 `src/__fixtures__/mds3-corrupted-blob.jwt` — signature-flipped.
|
||||
- [ ] 14.3 `src/__fixtures__/mds3-rolled-back-blob.jwt` — same payload with `no` decremented.
|
||||
- [ ] 14.4 `src/__fixtures__/index.ts` — typed exports.
|
||||
|
||||
## 15. Benchmarks
|
||||
|
||||
- [ ] 15.1 `src/__benchmarks__/verify-blob.bench.ts` — JWS verify throughput.
|
||||
- [ ] 15.2 `src/__benchmarks__/resolve-aaguid.bench.ts` — AAGUID lookup throughput.
|
||||
|
||||
## 16. Tier-purity + subpath harnesses
|
||||
|
||||
- [ ] 16.1 `src/dep-graph.test.ts` — zero DOM / Node-only imports across the public surface.
|
||||
- [ ] 16.2 `src/index.test.ts` — every documented subpath resolves.
|
||||
|
||||
## 17. Integration into `@suaveplan/webauthn`
|
||||
|
||||
- [ ] 17.1 Extend `VerifyAttestationInput` with optional `metadata?: MetadataResolver` and `statusFilter?: StatusFilter` (typed via `import type` — no runtime dep).
|
||||
- [ ] 17.2 Extend `AttestationVerifyResult` with optional `metadata?: MetadataLookup`.
|
||||
- [ ] 17.3 Wire post-verifier resolution: AAGUID for non-U2F; SKI for `fido-u2f` / all-zero AAGUID; downgrade `trusted=false` when filter rejects status.
|
||||
- [ ] 17.4 Update `packages/auth/webauthn/src/verifier/verify-attestation.test.ts` — add six scenarios per the modified delta.
|
||||
- [ ] 17.5 Refresh `verify-attestation.md` and the `packed.md` / `fido-u2f.md` / `none.md` anchors to point at `MetadataResolver` as the canonical seam.
|
||||
- [ ] 17.6 Add `@suaveplan/webauthn-mds3` to `packages/auth/webauthn/README.md` Modules section.
|
||||
|
||||
## 18. Pre-publish gate
|
||||
|
||||
- [ ] 18.1 `bunx turbo lint typecheck test --filter=@suaveplan/webauthn-mds3` green.
|
||||
- [ ] 18.2 `bunx turbo test:coverage --filter=@suaveplan/webauthn-mds3` 100/100/100/100.
|
||||
- [ ] 18.3 `bunx turbo lint typecheck test --filter=@suaveplan/webauthn` green (dependent sweep).
|
||||
- [ ] 18.4 Banned-pattern scan: zero matches (§13 step 4).
|
||||
- [ ] 18.5 Barrel-shape scan: zero matches (§13 step 4a).
|
||||
- [ ] 18.6 Test-runner import scan: zero matches (§13 step 4b).
|
||||
- [ ] 18.7 Missing-`.md` scan: zero misses.
|
||||
- [ ] 18.8 `bunx markdownlint-cli2 --fix` then no-fix pass exits 0.
|
||||
|
||||
## 19. Repo-root manifest refresh (§13b)
|
||||
|
||||
- [ ] 19.1 `bun scripts/folder-reorg/generate-manifest.ts` → `MANIFEST.md`.
|
||||
- [ ] 19.2 `bun scripts/generate-missing.ts` → `MISSING.yml`.
|
||||
- [ ] 19.3 `STATUS.md` add `@suaveplan/webauthn-mds3` row.
|
||||
- [ ] 19.4 `SUMMARY.md` paragraph added.
|
||||
|
||||
## 20. Fold + archive
|
||||
|
||||
- [ ] 20.1 Fold `specs/webauthn-mds3/spec.md` into `openspec/specs/webauthn-mds3/spec.md`.
|
||||
- [ ] 20.2 Fold `specs/webauthn/spec.md` `## MODIFIED Requirements` into `openspec/specs/webauthn/spec.md`.
|
||||
- [ ] 20.3 Move `openspec/changes/complete-webauthn-mds3/` → `openspec/archive/<yyyy-mm-dd>-complete-webauthn-mds3/`.
|
||||
Reference in New Issue
Block a user