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,66 @@
# `jwkToCryptoKey`
## Purpose
Parse an RFC 7517 §4 / RFC 7518 §6 JSON Web Key into the typed key shape expected by the JWS algorithm impl for the declared `alg`. `kty` / `crv` / coordinate lengths are validated against the declared algorithm; mismatch throws `JWS.JWK_INVALID` for shape issues or `JWS.KEY_ALG_MISMATCH` for valid-shape-but-wrong-algorithm cases.
## Features
- RSA — `kty: "RSA"` → `RsaPublicKey` via `@suaveplan/crypto-rsa.parseRsaPublicKeyJwk`.
- EC — `kty: "EC"` with `crv: "P-256" | "P-384" | "P-521"` → uncompressed SEC1 bytes for the JWS ECDSA impl.
- OKP — `kty: "OKP"` with `crv: "Ed25519"` → 32-byte raw key for `EdDSA`.
- Symmetric — `kty: "oct"` with `k` → `Uint8Array` for `HS256/384/512`.
- Coordinate length validation per curve.
- Ed448 explicitly rejected with `KEY_ALG_MISMATCH`.
## Usage: Basic Example
```ts
import { jwkToCryptoKey } from "@suaveplan/jws";
const parsed = jwkToCryptoKey(
{
kty: "EC",
crv: "P-256",
x: "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
y: "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
},
"ES256"
);
// parsed.kind === "ec"; parsed.key is uncompressed SEC1.
```
## Usage: Advanced Example
```ts
import { JwsError, jwkToCryptoKey } from "@suaveplan/jws";
// Algorithm-confusion defence: RSA JWK declared for an ES256 token rejected.
try {
jwkToCryptoKey({ kty: "RSA", n: "...", e: "AQAB" }, "ES256");
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.JWK_INVALID") {
// kty: RSA vs alg: ES256 mismatch.
}
}
// Ed448 rejected:
try {
jwkToCryptoKey({ kty: "OKP", crv: "Ed448", x: "..." }, "EdDSA");
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.KEY_ALG_MISMATCH") {
// Only Ed25519 supported today.
}
}
```
## API Reference
- `jwkToCryptoKey(jwk: Record<string, unknown>, alg: string): ParsedKey`
- `ParsedKey = { kind: "rsa", key: RsaPublicKey } | { kind: "ec", key: Uint8Array } | { kind: "okp", key: Uint8Array }`
- Throws `JwsError(JWS.JWK_INVALID)` for shape issues.
- Throws `JwsError(JWS.KEY_ALG_MISMATCH)` for valid-shape-but-wrong-curve cases.
## Implementation Notes
Coordinate lengths (32/48/66 bytes for P-256/P-384/P-521) are strictly enforced. The P-521 curve uses 66-byte coordinates because the curve order is 521 bits (66 bytes), not the often-assumed 64; producers that left-pad to 64 fail validation here, which is correct — those tokens are malformed per RFC 7518 §6.2.1.2 even though many lenient libraries accept them. RSA keys route through `@suaveplan/crypto-rsa.parseRsaPublicKeyJwk` so the same key-policy checks (min/max modulus length, validated public exponent, malformed-base64url rejection) apply uniformly across the project.

View File

@@ -0,0 +1,59 @@
# `KeyResolver`
## Purpose
Caller-supplied key-resolution function consulted when `header.kid` is present and no key has been supplied via `opts.key`, `header.jwk`, or `header.x5c`. The resolver receives the parsed header so dispatch can branch on additional parameters (`iss`, custom tenant headers, environment-specific kid namespaces). Returning `undefined` surfaces as `JwsError(JWS.KEY_NOT_FOUND)`; throwing propagates verbatim so callers can map their own error taxonomy onto JWS verification.
## Features
- Async or sync — both signatures are accepted.
- Receives `(kid, header)`; the header is the parsed `JoseHeader`, including unknown parameters.
- `undefined` / `null` return → typed `JWS.KEY_NOT_FOUND` thrown by the pipeline.
## Usage: Basic Example
```ts
import { compactVerify, type KeyResolver } from "@suaveplan/jws";
const keyResolver: KeyResolver = async (kid) => keyStore.get(kid);
await compactVerify(token, undefined, {
algorithms: ["RS256"],
keyResolver,
});
```
## Usage: Advanced Example
```ts
import { compactVerify, JwsError, type KeyResolver } from "@suaveplan/jws";
// Dispatch on a custom tenant header:
const keyResolver: KeyResolver = async (kid, header) => {
const tenant = header["tenant"] as string | undefined;
if (tenant === "acme") return acmeKeyStore.get(kid);
if (tenant === "globex") return globexKeyStore.get(kid);
return undefined; // → KEY_NOT_FOUND
};
try {
await compactVerify(token, undefined, {
algorithms: ["RS256"],
keyResolver,
});
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.KEY_NOT_FOUND") {
// Inspect err.context.kid for the unresolved id.
}
}
```
## API Reference
- `type KeyResolver = (kid: string, header: JoseHeader) => Promise<unknown> | unknown`
Returning anything other than `undefined` / `null` is treated as the verification key and passed to the algorithm impl unchanged. The pipeline performs no type-check on the returned value — it relies on the algorithm impl to throw `JWS.KEY_ALG_MISMATCH` if the resolved key doesn't match the declared `alg`.
## Implementation Notes
Resolution priority in the verify pipeline is `opts.key → header.jwk → header.x5c → opts.keyResolver(header.kid)`. The resolver only runs when none of the earlier sources resolved a key; this means a token carrying both `kid` AND `jwk` will route through `jwk` first (assuming `allowEmbeddedJwk: true`). That ordering is deliberate: when a producer self-describes a key in the token, the consumer's stored key for the same `kid` should be re-verified against the embedded one — but doing that match is the consumer's responsibility, not this library's. The pipeline simply uses whichever source the caller has opted in to first.

View File

@@ -0,0 +1,61 @@
# `extractX5cLeafKey`
## Purpose
Decode an RFC 7515 §4.1.6 `x5c` certificate chain, delegate trust-path validation to `@suaveplan/x509.validateChain`, and extract the leaf `SubjectPublicKeyInfo` shaped to match the declared JOSE `alg`. This is the entry point that `@suaveplan/webauthn` will call for SafetyNet attestation verification: SafetyNet tokens carry their attestation cert chain in `x5c`, and the verifier needs to anchor it against Google's pinned attestation roots before trusting the embedded RSA public key.
## Features
- Accepts either standard-base64 (RFC's preference) or base64url-encoded chain entries.
- Delegates chain validation entirely to `@suaveplan/x509.validateChain`.
- Leaf-public-key algorithm matched against declared JOSE `alg`.
- Chain failure → `JWS.X5C_CHAIN_INVALID` with the underlying x509 error chained as `cause`.
- Leaf-alg mismatch → `JWS.KEY_ALG_MISMATCH`.
## Usage: Basic Example
```ts
import { extractX5cLeafKey } from "@suaveplan/jws";
import { parseCertificate } from "@suaveplan/x509";
const trustAnchors = [parseCertificate(googleAttestationRootDer)];
const leaf = await extractX5cLeafKey(token.protected.x5c, "RS256", {
trustAnchors,
});
// leaf.kind === "rsa"; leaf.key is the SafetyNet leaf cert's RSA public key.
```
## Usage: Advanced Example
```ts
import { compactVerify, extractX5cLeafKey, JwsError } from "@suaveplan/jws";
// Use through compactVerify's automatic x5c resolution:
const result = await compactVerify(safetynetToken, undefined, {
algorithms: ["RS256"],
allowX5c: true,
x5cTrustAnchors: trustAnchors,
});
// Direct call with a deadline:
try {
const ac = new AbortController();
setTimeout(() => ac.abort(), 1_000);
await extractX5cLeafKey(chain, "RS256", { trustAnchors, signal: ac.signal });
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.X5C_CHAIN_INVALID") {
// err.cause is the underlying X509Error — inspect cause.code for
// CERT_EXPIRED / NAME_CHAIN_BROKEN / etc.
}
}
```
## API Reference
- `extractX5cLeafKey(x5c: readonly string[], alg: string, options: ExtractX5cOptions): Promise<ParsedKey>`
- `interface ExtractX5cOptions { trustAnchors: readonly X509Certificate[]; clock?: Clock; signal?: AbortSignal }`
- Returns the same `ParsedKey` union as `jwkToCryptoKey`.
## Implementation Notes
`x5c` array entries are base64-encoded DER certificates per RFC 7515 §4.1.6. The decoder accepts both alphabets because real-world producers vary; the strict choice is base64 standard alphabet with optional padding, but base64url is also surfaced from some Google attestation chains. The leaf-alg check is intentionally separate from chain validation: a malicious producer could submit a valid Google-rooted chain whose leaf is an ECDSA cert and claim `alg: "RS256"` — chain validation would pass (Google did sign that cert), but the JOSE-level alg doesn't match the leaf SPKI, and the verifier MUST reject. `validateChain` runs the full RFC 5280 §6.1 algorithm (expiry, name chaining, key usage, EKU, name constraints, optional OCSP), so this module owns only the JOSE-layer "does the leaf match `alg`" check.