first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
# Attestation format registry
## Purpose
Defines the pluggable contract every per-format attestation verifier implements (`AttestationFormatVerifier`), the shared input bundle (`AttestationVerifierInput`), and the canonical result shape (`AttestationVerifyResult`). Consumers can `registerAttestationFormat(registry, name, verifier)` to add a new IANA-registered format (e.g. `apple-appattest`) or override a built-in without forking the package.
## Features
- Three-type public contract: `AttestationType` (`basic | self | attCA | anonCA | none`), `AttestationVerifyResult` (`{ trusted, attestationType, trustPath }`), `AttestationFormatVerifier` (`(input) => Result | Promise<Result>`).
- `AttestationFormatRegistry = Map<string, AttestationFormatVerifier>` — opaque mutable handle the dispatcher consumes.
- Last-write-wins registration so `registerAttestationFormat(registry, "packed", custom)` cleanly replaces the built-in.
- Allows synchronous or async verifiers — `none` is synchronous, `tpm` is async, callers do not need to know the difference.
## Usage: Basic Example
```ts
import {
defaultAttestationRegistry,
registerAttestationFormat,
} from "@suaveplan/webauthn/verifier";
const registry = defaultAttestationRegistry();
registerAttestationFormat(registry, "apple-appattest", customVerifier);
```
## Usage: Advanced Example
```ts
import {
type AttestationFormatVerifier,
registerAttestationFormat,
} from "@suaveplan/webauthn/verifier";
const counted: AttestationFormatVerifier = async (input) => {
metrics.count(input.attestationObject.fmt);
return builtin(input);
};
registerAttestationFormat(registry, "packed", counted);
```
## API Reference
- `AttestationType` — literal union.
- `AttestationVerifyResult` — `{ trusted: boolean, attestationType: AttestationType, trustPath: readonly Uint8Array[] }`.
- `AttestationVerifierInput` — `{ attestationObject, clientDataHash, expectedRpIdHash }`.
- `AttestationFormatVerifier` — `(input) => Result | Promise<Result>`.
- `AttestationFormatRegistry` — `Map<string, AttestationFormatVerifier>`.
- `registerAttestationFormat(registry, name, verifier)` — register or override.
## Implementation Notes
- `trustPath` is the leaf-first X.509 chain as raw DER byte strings. Empty when the format does not provide a chain (`self`, `none`). Consumers feed the chain into their own metadata-trust source (FIDO MDS3, attestation-CA allow-list) — the verifier is intentionally agnostic about which roots to trust.
- The registry is a plain `Map` rather than a custom class so consumers can iterate / inspect / serialise it freely. The contract is the value types, not the container.
- Returning a discriminated `attestationType` rather than a boolean lets the caller adjust policy: `basic` and `attCA` are stronger than `self`, and `none` is the weakest (but still acceptable when the RP's policy permits anonymous registration).

View File

@@ -0,0 +1,50 @@
# Sign-counter regression detector
## Purpose
Implements the W3C WebAuthn L3 §6.1.1 sign-counter regression check. Authenticators that maintain a monotonically-increasing counter MUST surface a larger value on every successful assertion; a counter that fails to increase indicates either a cloned authenticator or an authenticator software bug. The check is deliberately a pure function so the policy decision (accept-and-warn vs revoke-and-reject) lives in the caller's identity layer.
## Features
- Three-state semantics: stored=0 & asserted=0 (skip), asserted<=stored & at least one non-zero (regression), asserted>stored (ok).
- Pure synchronous function — no IO, no observability emission, no exceptions.
- Boundary-correct comparison: `<=` so the equal-counter case is also a regression, matching the W3C-defined behaviour for authenticators that never rotate their counter.
## Usage: Basic Example
```ts
import { detectSignCounterRegression } from "@suaveplan/webauthn/verifier";
if (detectSignCounterRegression(stored.signCounter, result.signCount)) {
revokeCredential(stored.id);
throw new Error("sign-counter regression");
}
```
## Usage: Advanced Example
```ts
import { detectSignCounterRegression } from "@suaveplan/webauthn/verifier";
function classifyAuthenticatorCounter(
stored: number,
asserted: number
): "static-counter-ok" | "regression" | "advanced" {
if (stored === 0 && asserted === 0) {
return "static-counter-ok";
}
return detectSignCounterRegression(stored, asserted)
? "regression"
: "advanced";
}
```
## API Reference
- `detectSignCounterRegression(stored: number, asserted: number): boolean` — `true` if the assertion's counter regressed, `false` otherwise.
## Implementation Notes
- W3C deliberately keeps the policy ambiguous: an RP MAY treat regression as a soft warning (for testing-tier authenticators) or as a hard revoke (for production). The package surfaces the boolean and leaves the policy to the caller; `verifyAssertion` consumes this and returns `{ ok: false, code: SIGN_COUNTER_REGRESSION }` so the caller can branch.
- The zero-zero exception matters: several major authenticators (Touch ID, Windows Hello on platform-bound credentials) deliberately emit `signCount === 0` on every assertion to avoid leaking usage frequency to the RP. Without the zero-zero exception every such login would falsely flag as a regression.
- The function is intentionally branchless except for the two arithmetic comparisons so its execution time is invariant of the inputs — there is no plausible side-channel here, but the discipline is part of the package's no-naive-equality posture.

View File

@@ -0,0 +1,68 @@
# Assertion verifier
## Purpose
Implements `verifyAssertion`, the cryptographic check at the heart of the WebAuthn authentication ceremony (W3C WebAuthn L3 §7.2). The function ingests the authenticator's assertion (authenticatorData, clientDataJSON, signature) plus the relying-party's stored credential state (public key, expected origin, expected rpIdHash, sign counter) and returns a discriminated-union `VerifyResult`. The dispatcher routes to per-algorithm verifiers backed by `@suaveplan/crypto-curve` and `@suaveplan/crypto-rsa` — every supported COSE algorithm (ES256, ES384, ES512, EdDSA, RS256) goes through real cryptographic verification.
## Features
- Discriminated-union return: `{ ok: true } | { ok: false, code }` — callers branch without unwrapping thrown errors.
- Five algorithms supported: ES256 (P-256 + SHA-256), ES384 (P-384 + SHA-384), ES512 (P-521 + SHA-512), EdDSA (Ed25519), RS256 (RSA-PKCS1-v1.5 + SHA-256).
- DER ECDSA decoding via `@suaveplan/x509#decodeEcdsaSignature` — WebAuthn emits DER-encoded ECDSA signatures, which we convert to the compact `r||s` form expected by `@suaveplan/crypto-curve`.
- Constant-time `rpIdHash` compare via `constantTimeEqual` — a mismatched RP hash is the canonical phishing signal and the compare time must not leak the byte index of the first difference.
- Sign-counter regression check via `detectSignCounterRegression` — `{ ok: false, code: "WEBAUTHN.SIGN_COUNTER_REGRESSION" }` on monotonicity violation.
- Client-data revalidation via `validateClientDataJSON` so type / origin / challenge are also checked.
- `verifyOverCoseKey(key, message, signature)` helper exported for per-format attestation verifiers (packed self-attestation, fido-u2f, tpm).
## Usage: Basic Example
```ts
import { verifyAssertion } from "@suaveplan/webauthn/verifier";
const result = await verifyAssertion({
authenticatorData,
clientDataJSON,
signature,
storedPublicKey,
expectedChallenge,
expectedOrigin: "https://example.com",
expectedRpIdHash,
signCounter: stored.signCounter,
});
if (!result.ok) {
if (result.code === "WEBAUTHN.SIGN_COUNTER_REGRESSION") {
revokeCredential();
}
throw new Error(result.code);
}
```
## Usage: Advanced Example
```ts
import {
verifyAssertion,
verifyOverCoseKey,
} from "@suaveplan/webauthn/verifier";
// Direct signature check (used by per-format attestation verifiers):
const ok = await verifyOverCoseKey(
storedKey,
concatBytes(authData, sha256(clientDataJSON)),
derSignatureBytes,
);
```
## API Reference
- `VerifyResult` — `{ ok: true } | { ok: false, code: "WEBAUTHN.SIGNATURE_INVALID" | "WEBAUTHN.RPID_HASH_MISMATCH" | "WEBAUTHN.SIGN_COUNTER_REGRESSION" }`.
- `VerifyAssertionInput` — `{ authenticatorData, clientDataJSON, signature, storedPublicKey, expectedChallenge, expectedOrigin, expectedRpIdHash, signCounter }`.
- `verifyAssertion(input): Promise<VerifyResult>` — the main entry point.
- `verifyOverCoseKey(key, message, signature): Promise<boolean>` — per-algorithm signature check, exported for attestation verifiers.
## Implementation Notes
- WebAuthn ECDSA signatures are ASN.1 DER-encoded, not the JOSE-form `r||s` that `@suaveplan/crypto-curve` accepts. The package delegates DER decoding to `decodeEcdsaSignature` from `@suaveplan/x509` (the same routine used in TLS / X.509) so the structural validation is shared with the rest of the security family.
- EC2 public keys are reconstructed in uncompressed form `0x04 || x || y` before passing to `noble-curves`; the COSE key labels `x` and `y` as separate byte strings.
- RSA verification consumes the COSE `n` and `e` byte strings via `os2ip` to produce `bigint` modulus + exponent, then calls `rsaVerifyPkcs1v15` with `hash: "SHA-256"`. Any structural failure (wrong sig length, modulus mismatch) collapses to `false` — semantic failures never throw out of `verifyOverCoseKey`.
- The dispatch order is: parse authData → validate client data → constant-time rpIdHash compare → sign-counter check → signature verify. The order matters: a mismatched rpIdHash is the strongest phishing signal so it surfaces before the expensive signature operation.

View File

@@ -0,0 +1,69 @@
# Attestation verification dispatcher
## Purpose
Implements `verifyAttestation` (W3C WebAuthn L3 §6.5.4) and `defaultAttestationRegistry()`. The dispatcher reads `attestationObject.fmt`, looks up the corresponding verifier in the registry, computes the `clientDataHash` once, and invokes the verifier. It is the single entry point into the per-format machinery — consumers do not import individual verifiers unless they want to override behaviour for a specific format.
## Features
- Built-in coverage of every IANA-registered format in W3C WebAuthn L3 §8: `packed`, `fido-u2f`, `tpm`, `android-key`, `android-safetynet`, `apple`, `none`.
- `WEBAUTHN.ATTESTATION_FORMAT_UNSUPPORTED` thrown on missing registry entry — distinct from `MALFORMED_RESPONSE` so dashboards can distinguish policy denials from protocol violations.
- `webauthn.attestation_format_total{fmt}` OTel counter incremented per dispatch with cardinality bounded to the seven registered formats plus `unknown`.
- `clientDataHash` computed once via `@suaveplan/crypto-hash#sha256` and passed to every verifier so the per-format code does not re-hash.
## Usage: Basic Example
```ts
import {
defaultAttestationRegistry,
parseAttestationObject,
verifyAttestation,
} from "@suaveplan/webauthn";
const env = parseAttestationObject(rawAttestationObject);
const result = await verifyAttestation({
attestationObject: env,
clientDataJSON,
expectedRpIdHash,
registry: defaultAttestationRegistry(),
});
```
## Usage: Advanced Example
```ts
import {
defaultAttestationRegistry,
registerAttestationFormat,
verifyAttestation,
} from "@suaveplan/webauthn";
const registry = defaultAttestationRegistry();
registerAttestationFormat(registry, "apple-appattest", async (input) => {
// custom verifier for Apple's App Attest format
return {
trusted: true,
attestationType: "anonCA",
trustPath: [],
};
});
const result = await verifyAttestation({
attestationObject,
clientDataJSON,
expectedRpIdHash,
registry,
});
```
## API Reference
- `defaultAttestationRegistry(): AttestationFormatRegistry` — registry pre-populated with every built-in format.
- `VerifyAttestationInput` — `{ attestationObject, clientDataJSON, expectedRpIdHash, registry }`.
- `verifyAttestation(input): Promise<AttestationVerifyResult>` — dispatch + invoke + return.
## Implementation Notes
- The registry is a fresh `Map` per call so consumers can mutate it independently; production callers typically build it once and cache.
- The OTel counter is emitted BEFORE the dispatch so a verifier that throws still contributes to the format count — this matters for diagnosing "X happened N times, M of which failed" without needing two counters.
- `clientDataHash` is the SHA-256 of the raw clientDataJSON bytes, NOT of the parsed JSON. Authenticators sign over the wire-encoded bytes, so re-encoding would break the signature check.
- The dispatcher does NOT validate the client data type / origin / challenge — that is the registration ceremony's job and lives in `register`. The dispatcher is a pure attestation primitive.