Initial Commit
This commit is contained in:
@@ -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"`
|
||||
Reference in New Issue
Block a user