Initial Commit
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
# 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
|
||||
|
||||
```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", [...])
|
||||
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
|
||||
|
||||
```ts
|
||||
// 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>;
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// new optional field on @suaveplan/webauthn's VerifyAttestationInput
|
||||
export interface VerifyAttestationInput {
|
||||
// ...existing fields...
|
||||
readonly metadata?: import("@suaveplan/webauthn-mds3").MetadataResolver;
|
||||
readonly revokedStatuses?: ReadonlySet<AuthenticatorStatus>; // override default
|
||||
}
|
||||
|
||||
// new optional field on AttestationVerifyResult
|
||||
export interface AttestationVerifyResult {
|
||||
readonly trusted: boolean;
|
||||
readonly attestationType: AttestationType;
|
||||
readonly trustPath: readonly Uint8Array[];
|
||||
readonly metadata?: MetadataLookup; // present only when resolver injected
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Refresh policy
|
||||
|
||||
| Trigger | Behaviour |
|
||||
|---|---|
|
||||
| First start (no cached blob) | Fetch immediately, verify, cache, schedule next at `nextUpdate - jitter` |
|
||||
| Scheduled tick | Fetch, verify, write-through cache; on verify-fail keep prior payload and emit `webauthn_mds3.refresh_total{outcome=verify_failed}` |
|
||||
| `nextUpdate` absent | Fall back to `intervalMs` (default 24h) |
|
||||
| Concurrent call while refresh in flight | Throw `REFRESHER_ALREADY_RUNNING` (no silent dedup — caller-visible) |
|
||||
| `signal.aborted` | Cancel pending refresh, throw `REFRESHER_ABORTED` |
|
||||
| Jitter | Uniform random in `[-5min, +5min]` to avoid thundering herd against the MDS endpoint |
|
||||
|
||||
The refresher is decoupled from the resolver — a resolver can be backed by a cache that some other process refreshes (e.g. a cron job, a Cloudflare Cron Trigger). `createCachedResolver` accepts an optional `refresher` that it `start()`s on first `resolve*` call.
|
||||
|
||||
## 5. Cache contract
|
||||
|
||||
The cache is a thin wrapper around `StorageLike<MetadataBlobPayload>` from `@suaveplan/types`:
|
||||
|
||||
- Key namespace `webauthn-mds3:blob:v1` — bump the trailing version on any schema migration.
|
||||
- Single key per environment (one blob = one MDS3 endpoint). Multi-tenancy is the consumer's problem — wrap the `StorageLike` with a tenant-prefixing decorator.
|
||||
- Read failures surface `CACHE_READ_FAILED` with the underlying cause attached; the resolver MAY treat a missing cache entry as "fetch now" if a refresher is wired.
|
||||
- Writes are atomic from the resolver's perspective — the cache writes the verified payload only after `verifyMetadataBlob` resolves successfully.
|
||||
|
||||
## 6. Attestation-time lookup flow
|
||||
|
||||
```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.resolveByAttestationCertificateKeyId(ski(result.trustPath[0]))
|
||||
: input.metadata.resolveByAaguid(attestedCredentialData.aaguid)
|
||||
4. if lookup === undefined: return { ...result, metadata: undefined }
|
||||
5. revoked = (input.revokedStatuses ?? DEFAULT_REVOCATION_STATUSES).has(lookup.currentStatus)
|
||||
6. return { ...result, trusted: result.trusted && !revoked, metadata: lookup }
|
||||
```
|
||||
|
||||
`DEFAULT_REVOCATION_STATUSES = new Set(["REVOKED", "USER_KEY_REMOTE_COMPROMISE", "USER_KEY_PHYSICAL_COMPROMISE", "ATTESTATION_KEY_COMPROMISE", "USER_VERIFICATION_BYPASS"])`.
|
||||
|
||||
`fido-u2f` authenticators are MDS3-keyed by attestation-cert SKI (FIDO MDS3 §3.1.6) because they predate AAGUID. The attestation cert SKI is computed inside `webauthn-mds3` so `webauthn` doesn't need an X.509 parser at runtime.
|
||||
|
||||
## 7. Security model
|
||||
|
||||
| Concern | Mitigation |
|
||||
|---|---|
|
||||
| Blob substitution (attacker serves a benign blob omitting a compromised authenticator) | Pin the FIDO Alliance root CA in `constants/fido-root.ts`; verify the JWS `x5c` chain terminates at the pinned root; reject any chain ending elsewhere |
|
||||
| Algorithm downgrade | Allow-list RS256 / ES256 / ES384 only; reject `none`, `HS256`, and any other alg with `BLOB_ALG_DISALLOWED` |
|
||||
| Stale blob masking a known-compromised authenticator | Reject when `now > nextUpdate + grace`; the refresher pulls new blobs on schedule; OTel exposes `webauthn_mds3.blob_age_ms` for monitoring |
|
||||
| Replay of a previous blob (`no` rollback) | Refuse to overwrite the cached blob with a payload whose `no` field is lower than the cached `no` |
|
||||
| Schema drift | All blob fields pass through Zod (`MetadataBlobPayloadSchema`); unknown fields are tolerated but typed-out — additive FIDO Alliance extensions don't crash the verifier |
|
||||
| TOCTOU between resolve and trust decision | The trust decision happens inline in `verifyAttestation`; the resolver returns a `MetadataLookup` snapshot, not a live handle, so concurrent refreshes can't flip the result mid-flight |
|
||||
| `FetchLike` impersonation in tests | Production wires a real `fetch`; tests inject deterministic stubs returning fixture bytes — no live network, no `vi.mock` (per genesis rule 2) |
|
||||
|
||||
## 8. Companion delta to `@suaveplan/webauthn`
|
||||
|
||||
Because the `VerifyAttestationInput` and `AttestationVerifyResult` shapes gain optional fields and the post-verifier flow grows two trust-altering steps, the integration is captured as a `## MODIFIED Requirements` block in `specs/webauthn/spec.md` rather than left implicit. The modified requirement keeps every original scenario (the back-compat path) and adds two new scenarios (resolver-injected augmentation; revoked-status downgrade), each with a `**Verified by:**` line pointing into the existing `verify-attestation.test.ts` (extended in task 13.4).
|
||||
|
||||
## 9. Tier purity
|
||||
|
||||
`@suaveplan/webauthn-mds3` is **universal-tier**:
|
||||
|
||||
- No `globalThis.fetch` reference — `FetchLike` is injected.
|
||||
- No `localStorage` / `IndexedDB` — `StorageLike` is injected.
|
||||
- No `Date.now()` — `Clock` is injected.
|
||||
- No DOM types in the public surface — `MetadataLookup` carries `Uint8Array` not `Blob`.
|
||||
- `tests/dep-graph.test.ts` (in the package) enforces zero DOM imports.
|
||||
|
||||
This means server-side relying parties (Bun, Node, Workers, Deno) and browser-tier consumers can both depend on it without polyfills.
|
||||
|
||||
## 10. What this change deliberately does NOT do
|
||||
|
||||
- Does **not** ship an MDS3 publishing tool (creating blobs is for the FIDO Alliance only).
|
||||
- Does **not** ship a CRL / OCSP poller for attestation roots — that's a separate trust-chain concern outside the FIDO metadata contract.
|
||||
- Does **not** wire MDS3 into the `vault` unlock path — `verifyAssertion` (the unlock path) does not consume attestation, so MDS3 has no role there.
|
||||
@@ -0,0 +1,126 @@
|
||||
# `@suaveplan/webauthn-mds3` — FIDO Metadata Service v3 integration
|
||||
|
||||
**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`)
|
||||
|
||||
## 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 to a follow-up:
|
||||
|
||||
> "MDS3 (FIDO Metadata Service) integration — `verifyAttestation` returns the `trustPath: X509[]` and `attestationType` so callers can perform MDS3 lookups against their own metadata source. Bundling MDS3 fetch + caching expands scope into a separate package and is a follow-up."
|
||||
> — `openspec/archive/2026-05-11-enterprise-webauthn/proposal.md`, "Out of scope"
|
||||
|
||||
Without MDS3 a relying party cannot answer the production-critical questions every passkey deployment eventually has to answer: is this AAGUID a known certified authenticator, is its certification still valid, is it on the revocation list, what attestation root CAs are trusted for this AAGUID, and what attachment / user-verification / key-protection statuses did the FIDO Alliance certify it at. Today every genesis consumer that wants those answers has to hand-roll the MDS3 blob fetch, JWS verification, FIDO root-CA pin, AAGUID lookup, and refresh schedule — exactly the duplication this monorepo exists to eliminate.
|
||||
|
||||
This change introduces a sibling package, `@suaveplan/webauthn-mds3`, that owns the MDS3 wire protocol end-to-end (download, JWS verify against the pinned FIDO Alliance root, cache, periodic refresh, AAGUID-keyed lookup) and exposes an injectable `MetadataResolver` that `@suaveplan/webauthn`'s attestation pipeline consumes through a new optional seam. Absent injection, the existing `verifyAttestation` contract is preserved byte-for-byte (strict back-compat).
|
||||
|
||||
## What changes
|
||||
|
||||
### New package: `@suaveplan/webauthn-mds3`
|
||||
|
||||
Universal-tier package at `packages/auth/webauthn-mds3/`. No browser-only or Node-only APIs — `FetchLike` and `StorageLike` are injected from `@suaveplan/types`.
|
||||
|
||||
#### MDS3 blob download + JWS verification
|
||||
|
||||
- ADDED: `fetchMetadataBlob({ fetch, url? }): Promise<RawBlob>` — fetches the MDS3 JWT (`application/jwt`) from `url` (default `https://mds.fidoalliance.org/`) using the injected `FetchLike` so consumers can layer in retries, proxies, or a Cloudflare-Workers `fetch`.
|
||||
- ADDED: `verifyMetadataBlob({ jws, trustAnchors? }): Promise<MetadataBlobPayload>` — verifies the compact-JWS signature via `@suaveplan/jws` using the FIDO Alliance MDS3 root CA chain. The root CA fingerprint is pinned in `constants/fido-root.ts`; consumers can override via `trustAnchors` for staging / private metadata services. Verifies `x5c` chain to root, validates not-before / not-after, enforces RS256 / ES256 alg allow-list, decodes the JSON payload, and Zod-validates it against `MetadataBlobPayloadSchema` (FIDO MDS3 §3.1.7).
|
||||
- ADDED: `MetadataBlobPayload`, `MetadataStatement`, `AuthenticatorStatus`, `StatusReport`, `BiometricStatusReport`, `EcdaaTrustAnchor` — Zod schemas tracking the published MDS3 v3 schema verbatim.
|
||||
|
||||
#### Cache + scheduled refresh
|
||||
|
||||
- ADDED: `MetadataCache` — `StorageLike<MetadataBlobPayload>`-backed cache with `legalHeader`, `no`, `nextUpdate`, and `entries` preserved. Cache key namespace `webauthn-mds3:blob:v1`.
|
||||
- ADDED: `createMetadataRefresher({ fetch, storage, clock, logger, intervalMs?, signal? }): MetadataRefresher` — schedules refresh at `payload.nextUpdate - jitter`; falls back to `intervalMs` (default 24h) when the blob omits a `nextUpdate`. Honors `AbortSignal`; never starts a second refresh while one is in flight; emits OTel `webauthn_mds3.refresh_total{outcome}` counter.
|
||||
|
||||
#### AAGUID lookup API
|
||||
|
||||
- ADDED: `MetadataResolver` interface — `resolveByAaguid(aaguid: Uint8Array): Promise<MetadataLookup | undefined>` and `resolveByAttestationCertificateKeyId(keyId: Uint8Array): Promise<MetadataLookup | undefined>` (U2F authenticators are MDS3-keyed by attestation cert SKI, not AAGUID — FIDO MDS3 §3.1.6).
|
||||
- ADDED: `MetadataLookup` — `{ statement, currentStatus, statusReports, attestationRootCertificates: Uint8Array[], aaguid?, attestationCertificateKeyIds?, lastUpdate }`.
|
||||
- ADDED: `createMemoryResolver({ blob }): MetadataResolver` — synchronous in-memory resolver over a verified payload. Used by tests and by consumers that ship their own refresh loop.
|
||||
- ADDED: `createCachedResolver({ cache, refresher? }): MetadataResolver` — production resolver that reads the cache and optionally drives a refresher.
|
||||
|
||||
#### Integration seam back into `@suaveplan/webauthn`
|
||||
|
||||
- ADDED (in `@suaveplan/webauthn`): optional `metadata?: MetadataResolver` field on `VerifyAttestationInput`. When supplied, after the per-format verifier returns a `trustPath`, `verifyAttestation` resolves the AAGUID (or attestation-cert SKI for `fido-u2f`) and, when a `MetadataLookup` is returned, augments the result with `metadata: MetadataLookup` and downgrades `trusted` to `false` when `currentStatus` is `REVOKED`, `USER_KEY_REMOTE_COMPROMISE`, `USER_KEY_PHYSICAL_COMPROMISE`, `ATTESTATION_KEY_COMPROMISE`, or any other FIDO `AuthenticatorStatus` in the configurable revocation set.
|
||||
- ADDED (in `@suaveplan/webauthn`): `AttestationVerifyResult.metadata?: MetadataLookup` field. Absent when no resolver is injected — strict back-compat.
|
||||
|
||||
#### Errors
|
||||
|
||||
- ADDED: `WEBAUTHN_MDS3.*` codes via `defineCodes`: `BLOB_FETCH_FAILED`, `BLOB_MALFORMED`, `BLOB_SIGNATURE_INVALID`, `BLOB_CHAIN_UNTRUSTED`, `BLOB_EXPIRED`, `BLOB_ALG_DISALLOWED`, `BLOB_SCHEMA_MISMATCH`, `CACHE_READ_FAILED`, `CACHE_WRITE_FAILED`, `RESOLVER_LOOKUP_FAILED`, `REFRESHER_ALREADY_RUNNING`, `REFRESHER_ABORTED`.
|
||||
|
||||
#### Test strategy
|
||||
|
||||
- Vectors of the FIDO Alliance MDS3 sample blob (`__fixtures__/mds3-sample-blob.jwt` + parsed payload) sourced offline from the FIDO Alliance conformance suite. Tests use the `FetchLike` injection seam to return the fixture bytes — no live network in CI.
|
||||
- Negative fixtures: signature-flipped blob, chain-broken blob, expired `nextUpdate`, schema-violation payload.
|
||||
|
||||
### Subpath exports
|
||||
|
||||
- `.` — root barrel: `MetadataResolver`, `createMemoryResolver`, `createCachedResolver`, every type, every error code.
|
||||
- `./fetch` — `fetchMetadataBlob`.
|
||||
- `./verify` — `verifyMetadataBlob`, `MetadataBlobPayloadSchema`.
|
||||
- `./cache` — `MetadataCache`, cache-key constants.
|
||||
- `./refresher` — `createMetadataRefresher`, `MetadataRefresher`.
|
||||
- `./errors` — `WEBAUTHN_MDS3_CODES`, `WebAuthnMds3Error`.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `@suaveplan/types workspace:^` — `FetchLike`, `StorageLike`, `Clock`, `LoggerLike`.
|
||||
- `@suaveplan/error workspace:^` — `defineCodes`, base error.
|
||||
- `@suaveplan/jws workspace:^` — JWS verification with `x5c` chain.
|
||||
- `@suaveplan/x509 workspace:^` — chain validation to pinned FIDO root.
|
||||
- `@suaveplan/crypto-hash workspace:^` — SHA-256 for AAGUID key derivation and cert thumbprints.
|
||||
- `@suaveplan/crypto-utils workspace:^` — `constantTimeEqual`, base64url.
|
||||
- `@opentelemetry/api catalog:observability` — refresh + lookup metrics.
|
||||
- `tslib catalog:build`.
|
||||
- `zod catalog:forms` — boundary validation.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **MDS v2 / MDS v1 compatibility.** MDS3 is the only metadata version on the FIDO Alliance roadmap; bundling deprecated MDS2 expands scope and ships dead code for new deployments.
|
||||
- **Per-RP custom metadata sources.** The package only speaks the FIDO Alliance MDS3 wire format. Custom metadata catalogues (enterprise allow-lists) should implement `MetadataResolver` directly — that interface is the integration seam, not the MDS3 wire format.
|
||||
- **Authenticator-status policy decisions.** The package surfaces `currentStatus` and `statusReports`; the relying party decides what status set to treat as revocation. The default revocation set is opinionated but configurable.
|
||||
- **CRL / OCSP polling for attestation-root chains.** Out of band for MDS3 — root pinning + the published metadata `nextUpdate` cadence is the FIDO-Alliance-blessed freshness model.
|
||||
|
||||
## Source mapping
|
||||
|
||||
| Requirement | Source | Notes |
|
||||
|---|---|---|
|
||||
| Why an MDS3 integration is needed | `openspec/archive/2026-05-11-enterprise-webauthn/proposal.md` "Out of scope" | Verbatim deferral text |
|
||||
| `trustPath` + `attestationType` consumed downstream | `packages/auth/webauthn/src/verifier/registry.ts` L14-L18 | `AttestationVerifyResult` is the integration seam |
|
||||
| AAGUID parse + carry on attested-credential-data | `packages/auth/webauthn/src/parser/authenticator-data.ts` L73-L84 | Already available at attestation-verification time |
|
||||
| `none`/`fido-u2f`/`packed` doc anchors flag MDS3 as caller responsibility | `packages/auth/webauthn/src/attestation/{packed,fido-u2f,none}/*.md` | Today's wording moves into the `MetadataResolver` |
|
||||
| MDS3 wire format | FIDO Alliance "Metadata Service Specification" v3.0 §3.1 | External standard anchor |
|
||||
| FIDO Alliance root CA pin | <https://mds.fidoalliance.org/Root.cer> | Pinned in `constants/fido-root.ts` |
|
||||
| JWS verification | RFC 7515 + `@suaveplan/jws` | Internal contract |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Every requirement in `specs/webauthn-mds3/spec.md` and every modified requirement in `specs/webauthn/spec.md` carries a `**Verified by:**` line naming planned test files. Test files (all paths relative to `packages/auth/webauthn-mds3/` unless noted):
|
||||
|
||||
- `src/fetch/fetch-blob.test.ts` — happy path via injected `FetchLike`; non-2xx surfaces `BLOB_FETCH_FAILED`; `AbortSignal` cancellation.
|
||||
- `src/verify/verify-blob.test.ts` — sample-blob signature verifies; flipped signature → `BLOB_SIGNATURE_INVALID`; broken chain → `BLOB_CHAIN_UNTRUSTED`; expired `nextUpdate` → `BLOB_EXPIRED`; HS256 → `BLOB_ALG_DISALLOWED`; schema mismatch → `BLOB_SCHEMA_MISMATCH`.
|
||||
- `src/cache/metadata-cache.test.ts` — round-trip through `StorageLike`; read/write failure mapping.
|
||||
- `src/refresher/metadata-refresher.test.ts` — refreshes at `nextUpdate - jitter`; falls back to `intervalMs`; reentrancy guard fires `REFRESHER_ALREADY_RUNNING`; `signal.abort()` fires `REFRESHER_ABORTED`; OTel counter records per outcome.
|
||||
- `src/resolver/memory.test.ts` — AAGUID lookup hit / miss; `attestationCertificateKeyId` lookup for U2F.
|
||||
- `src/resolver/cached.test.ts` — reads cache; lazy-refreshes when stale.
|
||||
- `src/errors/errors.test.ts` — every `WEBAUTHN_MDS3.*` code is thrown by at least one production path.
|
||||
- `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"augments result with metadata when resolver injected"` AND `"::"downgrades trusted=false when currentStatus is revoked"` AND `"::"omits metadata field when no resolver injected (back-compat)"` — integration into the existing `@suaveplan/webauthn` verifier.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/webauthn-mds3` after landing — at draft time the package is new.
|
||||
|
||||
- `@suaveplan/webauthn` — [x] tests run / [x] docs reviewed / [ ] no action needed. Gains the optional `metadata?: MetadataResolver` field on `VerifyAttestationInput` and the optional `metadata?: MetadataLookup` field on `AttestationVerifyResult`. Both are additive; existing call sites that do not inject a resolver see byte-identical behaviour.
|
||||
- `@suaveplan/vault` — [ ] tests run / [x] docs reviewed / [x] no action needed (reason: vault unlock consumes `verifyAssertion`, not `verifyAttestation`; MDS3 surfaces only on registration paths the vault doesn't drive).
|
||||
- `@suaveplan/react-auth` — [ ] tests run / [x] docs reviewed / [x] no action needed (reason: hook surface unchanged; MDS3 plugs into server-side verifier).
|
||||
|
||||
## Migration
|
||||
|
||||
No prior MDS3 surface — additive only. Behaviour change for `verifyAttestation` consumers is opt-in (only fires when `metadata: MetadataResolver` is passed). Consumers that previously open-coded an MDS3 lookup over the returned `trustPath` MAY migrate to the injected seam at their own pace; the old "ignore `metadata`, do it yourself" path is permanently supported.
|
||||
@@ -0,0 +1,222 @@
|
||||
<!-- 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
|
||||
|
||||
The package SHALL export `fetchMetadataBlob({ fetch, url?, signal? }): Promise<Uint8Array>` from the `./fetch` subpath. The function SHALL call the injected `FetchLike` (per `@suaveplan/types`) against `url` (default `https://mds.fidoalliance.org/`), return the response body as a `Uint8Array`, map any non-2xx status to `WEBAUTHN_MDS3.BLOB_FETCH_FAILED` carrying the status code in `context.status`, and forward `signal` so caller-side cancellation triggers the underlying `AbortController` path.
|
||||
|
||||
#### Scenario: returns blob bytes on 200
|
||||
|
||||
- **WHEN** `fetchMetadataBlob({ fetch: stub200WithBytes, url: "https://example.test/mds" })` is called
|
||||
- **THEN** the resolved value `equalsBytes(stubBytes)`
|
||||
|
||||
#### Scenario: maps non-2xx to BLOB_FETCH_FAILED
|
||||
|
||||
- **WHEN** `fetchMetadataBlob({ fetch: stub503 })` is called
|
||||
- **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 the rejection's code is `WEBAUTHN_MDS3.BLOB_FETCH_FAILED` with `cause.name === "AbortError"`
|
||||
|
||||
**Verified by:** `src/fetch/fetch-blob.test.ts::"returns blob bytes on 200"` 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? }): 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` and `@suaveplan/crypto-rsa`/`@suaveplan/crypto-curve` per the header `alg`, decode the JSON payload, Zod-validate against `MetadataBlobPayloadSchema`, and reject with `WEBAUTHN_MDS3.BLOB_EXPIRED` when `now > payload.nextUpdate`.
|
||||
|
||||
#### Scenario: verifies the FIDO sample blob
|
||||
|
||||
- **WHEN** `verifyMetadataBlob({ jws: SAMPLE_BLOB_JWT })` is called against the bundled FIDO Alliance conformance sample
|
||||
- **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 against the bundled signature-flipped variant
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_SIGNATURE_INVALID`
|
||||
|
||||
#### Scenario: rejects untrusted chain
|
||||
|
||||
- **WHEN** `verifyMetadataBlob({ jws })` is called with a JWS whose `x5c` chain terminates at a non-pinned root
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_CHAIN_UNTRUSTED`
|
||||
|
||||
#### Scenario: rejects disallowed algorithm
|
||||
|
||||
- **WHEN** `verifyMetadataBlob({ jws })` is called with a JWS whose header `alg === "HS256"`
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_ALG_DISALLOWED`
|
||||
|
||||
#### Scenario: rejects expired blob
|
||||
|
||||
- **WHEN** `verifyMetadataBlob({ jws })` is called and `Clock.now() > payload.nextUpdate`
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_EXPIRED`
|
||||
|
||||
#### Scenario: rejects schema mismatch
|
||||
|
||||
- **WHEN** `verifyMetadataBlob({ jws })` is called against a payload 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 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: Metadata cache via injected StorageLike
|
||||
|
||||
The package SHALL export `MetadataCache` from the `./cache` subpath that wraps an injected `StorageLike<MetadataBlobPayload>` under the 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 refuse to write a payload whose `no` field is strictly lower than the currently-cached payload's `no` (rollback protection).
|
||||
|
||||
#### Scenario: round-trips a verified payload
|
||||
|
||||
- **WHEN** `cache.set(payload)` then `cache.get()` is called against an in-memory `StorageLike`
|
||||
- **THEN** the returned payload deep-equals the written payload
|
||||
|
||||
#### 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 a payload with `no: 42`
|
||||
- **WHEN** `cache.set(payloadWithNo41)` is called
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.CACHE_WRITE_FAILED` and `error.context.reason === "rollback"`
|
||||
|
||||
**Verified by:** `src/cache/metadata-cache.test.ts::"round-trips a verified payload"` 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"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Scheduled metadata refresher
|
||||
|
||||
The package SHALL export `createMetadataRefresher({ fetch, storage, clock, logger, 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` when `refresh()` is called while a refresh is in flight, and SHALL reject the pending refresh with `WEBAUTHN_MDS3.REFRESHER_ABORTED` when the injected `AbortSignal` fires.
|
||||
|
||||
#### Scenario: schedules at nextUpdate minus jitter
|
||||
|
||||
- **WHEN** a refresher is started against a payload with `nextUpdate = now + 1h`
|
||||
- **THEN** the next `clock.setTimeout` call is in the range `[55min, 65min]` from `now`
|
||||
|
||||
#### Scenario: falls back to intervalMs when nextUpdate absent
|
||||
|
||||
- **WHEN** a refresher is started against a payload with `nextUpdate: undefined` and `intervalMs: 1h`
|
||||
- **THEN** the next `clock.setTimeout` call is at 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** a refresher started with `signal: ac.signal`
|
||||
- **WHEN** `ac.abort()` is called mid-fetch
|
||||
- **THEN** the pending refresh rejects with `WEBAUTHN_MDS3.REFRESHER_ABORTED`
|
||||
|
||||
#### Scenario: records refresh outcome counter
|
||||
|
||||
- **WHEN** a refresh completes successfully
|
||||
- **THEN** the OTel counter `webauthn_mds3.refresh_total{outcome="ok"}` increments by 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::"records refresh outcome counter"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AAGUID lookup API
|
||||
|
||||
The package SHALL export a `MetadataResolver` interface with `resolveByAaguid(aaguid: Uint8Array): Promise<MetadataLookup | undefined>` and `resolveByAttestationCertificateKeyId(keyId: Uint8Array): Promise<MetadataLookup | undefined>`. The package SHALL ship two factories: `createMemoryResolver({ blob })` (synchronous in-memory over a verified payload) and `createCachedResolver({ cache, refresher? })` (production resolver that reads through the cache and optionally starts the refresher on first access). The AAGUID comparator SHALL use `constantTimeEqual` from `@suaveplan/crypto-utils`. The `attestationCertificateKeyId` comparator SHALL match on the byte-equal SHA-1 SKI of the leaf attestation certificate (FIDO MDS3 §3.1.6 — U2F authenticators have no AAGUID).
|
||||
|
||||
#### Scenario: AAGUID hit returns a typed MetadataLookup
|
||||
|
||||
- **GIVEN** a memory resolver seeded with a payload containing an entry whose AAGUID is `0x11223344...`
|
||||
- **WHEN** `resolveByAaguid(0x11223344...)` is called
|
||||
- **THEN** the resolved value has `statement.aaguid === "11223344-..."`, `currentStatus` set, `attestationRootCertificates.length >= 1`, and `lastUpdate > 0`
|
||||
|
||||
#### Scenario: AAGUID miss returns undefined
|
||||
|
||||
- **WHEN** `resolveByAaguid(zerosBytes)` is called against a resolver whose payload has no zero-AAGUID entry
|
||||
- **THEN** the resolved value is `undefined`
|
||||
|
||||
#### Scenario: U2F attestation-cert SKI lookup
|
||||
|
||||
- **GIVEN** a memory resolver seeded with a payload containing an entry whose `attestationCertificateKeyIdentifiers` includes a specific SHA-1 SKI
|
||||
- **WHEN** `resolveByAttestationCertificateKeyId(thatSki)` is called
|
||||
- **THEN** the resolved value's `statement` matches that entry
|
||||
|
||||
#### Scenario: cached resolver lazy-refreshes when cache empty
|
||||
|
||||
- **GIVEN** a cached resolver whose cache is empty and whose refresher is wired
|
||||
- **WHEN** `resolveByAaguid(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"` 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: Error-code registry
|
||||
|
||||
The package SHALL declare its error codes via `defineCodes("WEBAUTHN_MDS3", [...])` covering `BLOB_FETCH_FAILED`, `BLOB_MALFORMED`, `BLOB_SIGNATURE_INVALID`, `BLOB_CHAIN_UNTRUSTED`, `BLOB_EXPIRED`, `BLOB_ALG_DISALLOWED`, `BLOB_SCHEMA_MISMATCH`, `CACHE_READ_FAILED`, `CACHE_WRITE_FAILED`, `RESOLVER_LOOKUP_FAILED`, `REFRESHER_ALREADY_RUNNING`, and `REFRESHER_ABORTED`. Every declared code SHALL be thrown by at least one production call site exercised by an existing 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: Observability counters and histogram
|
||||
|
||||
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`. When no `MeterProvider` is registered the metrics 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** `resolveByAaguid` returns `undefined`
|
||||
- **THEN** `webauthn_mds3.lookup_total{outcome="miss"}` is `1`
|
||||
|
||||
#### Scenario: blob_age_ms records on read
|
||||
|
||||
- **WHEN** `cache.get()` returns a payload aged 1h
|
||||
- **THEN** `webauthn_mds3.blob_age_ms` has a recorded value in the bucket containing 3_600_000
|
||||
|
||||
**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"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Universal-tier purity
|
||||
|
||||
The package SHALL NOT import any DOM, browser, or Node-only API. `FetchLike`, `StorageLike`, `Clock`, and `LoggerLike` 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 types
|
||||
|
||||
- **WHEN** the dep-graph test sweeps the public surface for `Window`, `Document`, `localStorage`, `Blob` 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`, `./refresher`, 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 and the bundler tree-shakes unused entries
|
||||
|
||||
**Verified by:** `src/index.test.ts::"every documented subpath resolves through the package's exports map"`
|
||||
@@ -0,0 +1,49 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: webauthn (webauthn)
|
||||
|
||||
**Package**: `@suaveplan/webauthn`
|
||||
**Status**: PROPOSED (companion delta of `add-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 (typed via `import type` from `@suaveplan/webauthn-mds3` — runtime-optional) and an optional `revokedStatuses?: ReadonlySet<AuthenticatorStatus>` field. `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 `resolveByAaguid` / `resolveByAttestationCertificateKeyId` on the supplied resolver, set `result.metadata` to the returned `MetadataLookup` (or omit when `undefined`), and downgrade `result.trusted` to `false` when the lookup's `currentStatus` is in `revokedStatuses ?? DEFAULT_REVOCATION_STATUSES`.
|
||||
|
||||
#### 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 `resolveByAaguid` 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 currentStatus is revoked
|
||||
|
||||
- **GIVEN** a `MetadataResolver` whose `resolveByAaguid` returns a `MetadataLookup` with `currentStatus === "REVOKED"`
|
||||
- **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: 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 `resolveByAttestationCertificateKeyId(0xDEAD…)` is invoked exactly once and `resolveByAaguid` 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 currentStatus is revoked"` AND `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"resolves U2F by attestation-cert SKI"`
|
||||
@@ -0,0 +1,98 @@
|
||||
# Tasks — `add-webauthn-mds3`
|
||||
|
||||
## 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` using `createTestConfig()` from `@suaveplan/testing/config`; coverage thresholds 100 / 100 / 100 / 100; `provider: 'istanbul'`.
|
||||
- [ ] 1.5 Subpath exports map in `package.json`: `.`, `./fetch`, `./verify`, `./cache`, `./refresher`, `./errors`.
|
||||
- [ ] 1.6 Root `README.md` with one-paragraph description, install, quick-start, Modules section linking to every co-located `.md`.
|
||||
|
||||
## 2. Constants + FIDO root pinning
|
||||
|
||||
- [ ] 2.1 `src/constants/fido-root.ts` + `.md` — pin the FIDO Alliance MDS3 root CA DER bytes + SHA-256 fingerprint; export `FIDO_MDS3_DEFAULT_URL`, `DEFAULT_REFRESH_INTERVAL_MS`, `DEFAULT_REVOCATION_STATUSES`.
|
||||
|
||||
## 3. Error codes
|
||||
|
||||
- [ ] 3.1 `src/errors/error-codes.ts` + `.md` — `defineCodes("WEBAUTHN_MDS3", [...])` with every code from the spec delta; export `WebAuthnMds3Error`.
|
||||
- [ ] 3.2 `src/errors/errors.test.ts` — assert every code is reachable from a production path (call-site grep harness).
|
||||
|
||||
## 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
|
||||
|
||||
- [ ] 5.1 `src/fetch/fetch-blob.ts` + `.md` — `fetchMetadataBlob({ fetch, url?, signal? })`; maps non-2xx → `BLOB_FETCH_FAILED`; honors `AbortSignal`.
|
||||
- [ ] 5.2 `src/fetch/fetch-blob.test.ts` — happy path; non-2xx; abort.
|
||||
|
||||
## 6. Verify
|
||||
|
||||
- [ ] 6.1 `src/verify/verify-blob.ts` + `.md` — verify compact JWS via `@suaveplan/jws`; pin alg allow-list (RS256, ES256, ES384); validate `x5c` chain to FIDO root via `@suaveplan/x509`; check `nextUpdate`; decode + Zod-validate payload.
|
||||
- [ ] 6.2 `src/verify/verify-blob.test.ts` — happy, signature-flipped, chain-broken, expired, alg-disallowed, schema-mismatch (use sample-blob fixture).
|
||||
|
||||
## 7. Cache
|
||||
|
||||
- [ ] 7.1 `src/cache/metadata-cache.ts` + `.md` — wraps injected `StorageLike<MetadataBlobPayload>` with key namespace `webauthn-mds3:blob:v1`; surfaces `CACHE_READ_FAILED` / `CACHE_WRITE_FAILED`.
|
||||
- [ ] 7.2 `src/cache/metadata-cache.test.ts` — round-trip; injected storage failure mapping.
|
||||
|
||||
## 8. Refresher
|
||||
|
||||
- [ ] 8.1 `src/refresher/metadata-refresher.ts` + `.md` — `createMetadataRefresher({ fetch, storage, clock, logger, intervalMs?, signal? })`; reentrancy guard; OTel counter; jittered scheduling at `nextUpdate - jitter`.
|
||||
- [ ] 8.2 `src/refresher/metadata-refresher.test.ts` — schedule, reentrancy guard, abort, OTel counter, fallback to `intervalMs`.
|
||||
|
||||
## 9. Resolver
|
||||
|
||||
- [ ] 9.1 `src/resolver/types.ts` + `.md` — `MetadataResolver`, `MetadataLookup` interfaces (no name collisions per AGENTS.md §10 — folder is `resolver/`, files use feature names not `*-resolver.ts`).
|
||||
- [ ] 9.2 `src/resolver/memory.ts` + `.md` + `memory.test.ts` — `createMemoryResolver` for verified payloads.
|
||||
- [ ] 9.3 `src/resolver/cached.ts` + `.md` + `cached.test.ts` — `createCachedResolver` reading the cache + optional refresher drive.
|
||||
|
||||
## 10. Observability
|
||||
|
||||
- [ ] 10.1 `src/observability/metrics.ts` + `.md` — `webauthn_mds3.refresh_total{outcome}`, `webauthn_mds3.lookup_total{outcome}` counters; `webauthn_mds3.blob_age_ms` histogram. No-op when no exporter.
|
||||
- [ ] 10.2 `src/observability/metrics.test.ts` — every metric records on its production path against an in-memory `MeterProvider`.
|
||||
|
||||
## 11. Fixtures
|
||||
|
||||
- [ ] 11.1 `src/__fixtures__/mds3-sample-blob.jwt` — captured FIDO Alliance MDS3 conformance-suite sample blob.
|
||||
- [ ] 11.2 `src/__fixtures__/mds3-corrupted-blob.jwt` — signature-flipped variant for negative tests.
|
||||
- [ ] 11.3 `src/__fixtures__/index.ts` — typed fixture exports.
|
||||
|
||||
## 12. Benchmarks
|
||||
|
||||
- [ ] 12.1 `src/__benchmarks__/verify-blob.bench.ts` — JWS verify throughput on the sample blob.
|
||||
- [ ] 12.2 `src/__benchmarks__/resolve-aaguid.bench.ts` — AAGUID lookup throughput.
|
||||
|
||||
## 13. Integration into `@suaveplan/webauthn`
|
||||
|
||||
- [ ] 13.1 Extend `VerifyAttestationInput` with optional `metadata?: MetadataResolver` field (typed via `import type` from `@suaveplan/webauthn-mds3` — no runtime dep).
|
||||
- [ ] 13.2 Extend `AttestationVerifyResult` with optional `metadata?: MetadataLookup` field.
|
||||
- [ ] 13.3 Wire post-verifier resolution: AAGUID for non-U2F formats, attestation-cert SKI for `fido-u2f`; downgrade `trusted=false` on revoked statuses; configurable revocation set with default from `DEFAULT_REVOCATION_STATUSES`.
|
||||
- [ ] 13.4 Update `packages/auth/webauthn/src/verifier/verify-attestation.test.ts` — three new tests: resolver injected augments result; revoked status downgrades trust; no resolver = byte-identical legacy behaviour.
|
||||
- [ ] 13.5 Refresh `packages/auth/webauthn/src/verifier/verify-attestation.md` and the three attestation doc anchors (`packed.md`, `fido-u2f.md`, `none.md`) so the "caller wires MDS3" language now mentions `MetadataResolver` as the canonical seam.
|
||||
- [ ] 13.6 Add `@suaveplan/webauthn-mds3` to `packages/auth/webauthn/README.md` Modules section as the recommended MDS3 sidecar.
|
||||
|
||||
## 14. Pre-publish gate
|
||||
|
||||
- [ ] 14.1 `bunx turbo lint typecheck test --filter=@suaveplan/webauthn-mds3` green.
|
||||
- [ ] 14.2 `bunx turbo test:coverage --filter=@suaveplan/webauthn-mds3` 100 / 100 / 100 / 100.
|
||||
- [ ] 14.3 `bunx turbo lint typecheck test --filter=@suaveplan/webauthn` green (dependent sweep — §13 step 10).
|
||||
- [ ] 14.4 Banned-pattern scan: zero matches per §13 step 4.
|
||||
- [ ] 14.5 Barrel-shape scan (§13 step 4a): zero matches.
|
||||
- [ ] 14.6 Test-runner import scan (§13 step 4b): zero matches.
|
||||
- [ ] 14.7 Missing-`.md` scan: zero misses.
|
||||
- [ ] 14.8 `bunx markdownlint-cli2 --fix` then second pass with no `--fix` exits 0.
|
||||
|
||||
## 15. Repo-root manifest refresh (§13b)
|
||||
|
||||
- [ ] 15.1 `bun scripts/folder-reorg/generate-manifest.ts` → `MANIFEST.md` refreshed.
|
||||
- [ ] 15.2 `bun scripts/generate-missing.ts` → `MISSING.yml` refreshed.
|
||||
- [ ] 15.3 `STATUS.md` updated: add `@suaveplan/webauthn-mds3` row.
|
||||
- [ ] 15.4 `SUMMARY.md` paragraph for `webauthn-mds3` added.
|
||||
|
||||
## 16. Fold + archive
|
||||
|
||||
- [ ] 16.1 Fold `specs/webauthn-mds3/spec.md` into `openspec/specs/webauthn-mds3/spec.md`.
|
||||
- [ ] 16.2 Fold `specs/webauthn/spec.md` `## MODIFIED Requirements` into the canonical `openspec/specs/webauthn/spec.md`.
|
||||
- [ ] 16.3 Move `openspec/changes/add-webauthn-mds3/` → `openspec/archive/<yyyy-mm-dd>-add-webauthn-mds3/`.
|
||||
Reference in New Issue
Block a user