Initial Commit

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

View File

@@ -0,0 +1,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"`

View File

@@ -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"`