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,46 @@
# Built-in algorithm seeding
## Purpose
Seeds the default `AlgorithmRegistry` with the 13 built-in algorithm implementations required by RFC 7518 §3 and RFC 8037: `HS256`/`HS384`/`HS512`, `RS256`/`RS384`/`RS512`, `PS256`/`PS384`/`PS512`, `ES256`/`ES384`/`ES512`, `EdDSA`, plus the dual-flag-opt-in `none`. The seeding runs once at module load via a side-effect import from both `algorithms/index.ts` and the package root `index.ts`, so the registry is always populated before any verify or sign call. Idempotent via a module-scoped boolean — safe to call multiple times if a test reload cycles the module graph.
## Features
- Seeds 14 algorithms total (13 RFC-mandated + `none`).
- Idempotent: `seedBuiltinAlgorithms()` is a no-op after the first call.
- Side-effect import from `algorithms/index.ts` and `src/index.ts`.
## Usage: Basic Example
```ts
import { defaultRegistry, seedBuiltinAlgorithms } from "@suaveplan/jws";
// Already seeded by the side-effect import — these are introspection-only:
defaultRegistry.algorithms();
// ["HS256", "HS384", "HS512", "RS256", "RS384", "RS512",
// "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "EdDSA", "none"]
```
## Usage: Advanced Example
```ts
import { AlgorithmRegistry, seedBuiltinAlgorithms } from "@suaveplan/jws";
// Build an isolated registry for an integration test, seeded identically to
// the default. (Useful when you want to register additional experimental
// algorithms without polluting the global registry.)
const isolated = new AlgorithmRegistry();
// Manually re-register the built-ins:
// (Note: `seedBuiltinAlgorithms` seeds the *default* registry, not an arbitrary
// one; tests needing isolation use `defaultRegistry` directly or build the
// isolated registry up from named imports of `hs256` / `rs256` / etc.)
seedBuiltinAlgorithms(); // idempotent no-op if already seeded
```
## API Reference
- `seedBuiltinAlgorithms(): void` — idempotent; seeds the package-shared `defaultRegistry`.
## Implementation Notes
The side-effect import `import "./builtin.js"` at the top of `algorithms/index.ts` AND `src/index.ts` is load-bearing — without it, the first `compactVerify` call would throw `JWS.ALG_UNSUPPORTED` because the registry would be empty. The dual-import is defence in depth: any consumer importing from either the package root or the `./algorithms` subpath triggers seeding. The implementation guards against double-registration via the idempotency flag rather than via the registry's own duplicate-rejection logic — that way a `seedBuiltinAlgorithms()` call from a test after a registry reset doesn't throw.

View File

@@ -0,0 +1,60 @@
# ECDSA algorithms (`ES256` / `ES384` / `ES512`)
## Purpose
RFC 7518 §3.4 ECDSA signing and verification over NIST P-256, P-384, and P-521. The signing input is hashed with the curve-matched SHA-2 variant, signed via `@suaveplan/crypto-curve`'s curve primitives, and serialised as the fixed-length concatenation `R || S` of the two coordinates. DER-encoded signatures in the JOSE position are rejected with `JWS.SIG_FORMAT_INVALID` — silently parsing them would hide protocol drift between producers, which is the source of multiple JOSE interop CVEs.
## Features
- `ES256` → P-256 + SHA-256, 64-byte `R||S`.
- `ES384` → P-384 + SHA-384, 96-byte `R||S`.
- `ES512` → P-521 + SHA-512, 132-byte `R||S` (R and S are 66 bytes each).
- DER-encoded signature in JOSE position → `JWS.SIG_FORMAT_INVALID`.
- Key shape: raw uncompressed SEC1 public-key bytes (`0x04 || X || Y`) for verify; raw scalar for sign.
## Usage: Basic Example
```ts
import { p256 } from "@suaveplan/crypto-curve";
import { compactSign, compactVerify } from "@suaveplan/jws";
const priv = p256.randomPrivateKey();
const pub = p256.getPublicKey(priv);
const token = await compactSign("hello", priv, "ES256");
await compactVerify(token, pub, { algorithms: ["ES256"] });
```
## Usage: Advanced Example
```ts
import { es256, JwsError } from "@suaveplan/jws";
// Reject DER-encoded signature in JOSE position:
const derSig = new Uint8Array([0x30, 0x46, /* ... */]);
try {
await es256.verify(signingInput, derSig, pub);
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.SIG_FORMAT_INVALID") {
// DER detected by 0x30 lead byte + length mismatch.
}
}
// Wrong-length raw R||S also rejected:
try {
await es256.verify(signingInput, new Uint8Array(63), pub);
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.SIG_FORMAT_INVALID") {
// Generic length mismatch.
}
}
```
## API Reference
- `es256: AlgorithmImpl<Uint8Array>` — verify `(input, sig: 64 bytes, pub)`, sign `(input, priv: 32 bytes)`.
- `es384: AlgorithmImpl<Uint8Array>` — sig 96 bytes, priv 48 bytes.
- `es512: AlgorithmImpl<Uint8Array>` — sig 132 bytes, priv 66 bytes.
## Implementation Notes
Public keys MUST be uncompressed SEC1 (`0x04 || X || Y` of the relevant coordinate length). The verifier delegates to `@suaveplan/crypto-curve`'s `p256.verify` / `p384.verify` / `p521.verify`, which use the Noble curves library with constant-time scalar multiplication. Signing uses RFC 6979 deterministic `k` (no per-signature randomness required), which makes ECDSA JWS production deterministic for a given `(privateKey, payload)` pair — this is a feature for reproducible-build test vectors but means consumers should NOT reuse `k` across messages outside this library. The 132-byte length for ES512 surprises producers expecting 128: P-521 coordinates are 66 bytes each, not 64.

View File

@@ -0,0 +1,52 @@
# EdDSA algorithm
## Purpose
RFC 8037 §3.1 EdDSA over Ed25519. The signing input is signed directly by Ed25519 (no caller-side pre-hash — Ed25519 hashes internally via SHA-512), producing a 64-byte signature. EdDSA is the preferred modern algorithm for greenfield JWS deployments: fast, no `k`-randomness footgun, no salt-length confusion, no signature-format dispute (always raw 64 bytes).
## Features
- `EdDSA` → Ed25519, 64-byte signature.
- Key shape: 32-byte raw `Uint8Array` (priv or pub).
- Ed448 not supported — `@suaveplan/crypto-curve` ships only Ed25519; tokens with `crv: "Ed448"` are rejected at JWK parsing with `JWS.KEY_ALG_MISMATCH`.
## Usage: Basic Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { compactSign, compactVerify } from "@suaveplan/jws";
const priv = ed25519.randomPrivateKey();
const pub = ed25519.getPublicKey(priv);
const token = await compactSign("hello", priv, "EdDSA");
await compactVerify(token, pub, { algorithms: ["EdDSA"] });
```
## Usage: Advanced Example
```ts
import { eddsa, JwsError } from "@suaveplan/jws";
// Direct impl call:
const sig = await eddsa.sign!(new TextEncoder().encode("hdr.payload"), priv);
const ok = await eddsa.verify(new TextEncoder().encode("hdr.payload"), sig, pub);
// Wrong key type:
try {
await eddsa.verify(input, sig, { kty: "OKP" } as never);
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.KEY_ALG_MISMATCH") {
// EdDSA expects raw 32-byte Uint8Array.
}
}
```
## API Reference
- `eddsa: AlgorithmImpl<Uint8Array>` — verify `(input, sig: 64 bytes, pub: 32 bytes)`, sign `(input, priv: 32 bytes)`.
`verify` returns `Promise<boolean>` (`false` for any malformed signature — never throws); `sign` returns `Promise<Uint8Array>` of length 64.
## Implementation Notes
Ed25519 verification is exception-safe: `@suaveplan/crypto-curve.ed25519.verify` wraps Noble's verify in a `try/catch` that returns `false` on any signature-decoding error, so a malformed 64-byte input surfaces as `JWS.SIG_INVALID` at the pipeline level rather than a low-level decoding exception. Per RFC 8037 §3.1 the `alg: "EdDSA"` parameter is overloaded for both Ed25519 and Ed448 — disambiguation requires inspecting the `header.jwk.crv` value (or, for `x5c`, the leaf SPKI algorithm OID). This package currently only implements Ed25519; an Ed448 token routes the same `EdDSA` alg through the JWK layer, which catches the `crv: "Ed448"` and throws `KEY_ALG_MISMATCH` — extending to Ed448 only requires adding an Ed448 primitive to `@suaveplan/crypto-curve` and branching this impl on `header.jwk.crv`.

View File

@@ -0,0 +1,54 @@
# HMAC algorithms (`HS256` / `HS384` / `HS512`)
## Purpose
RFC 7518 §3.2 HMAC-with-SHA-2 signing and verification. The signing input is HMAC'd with the caller-supplied symmetric key; the verifier recomputes the tag and compares in constant time. Constant-time compare via `@suaveplan/crypto-utils.constantTimeEqual` is mandatory — `Buffer.compare`, `===`, and any short-circuiting comparison leak the tag's prefix to a timing adversary, and that's been the root cause of multiple practical key-recovery attacks against permissive JOSE libraries.
## Features
- `HS256` → HMAC-SHA-256, 32-byte tag.
- `HS384` → HMAC-SHA-384, 48-byte tag.
- `HS512` → HMAC-SHA-512, 64-byte tag.
- Constant-time tag comparison.
- Key shape: `Uint8Array` (the raw HMAC key); mismatch → `JWS.KEY_ALG_MISMATCH`.
## Usage: Basic Example
```ts
import { compactSign, compactVerify } from "@suaveplan/jws";
const secret = new TextEncoder().encode("a-shared-secret-of-at-least-32-bytes");
const token = await compactSign("hello", secret, "HS256");
const r = await compactVerify(token, secret, { algorithms: ["HS256"] });
// r.payloadString === "hello"
```
## Usage: Advanced Example
```ts
import { compactVerify, JwsError, hs256 } from "@suaveplan/jws";
// Direct algorithm impl access (advanced — verify pipelines should be your
// default).
const signingInput = new TextEncoder().encode("hdr.payload");
const sig = await hs256.sign!(signingInput, secret);
const ok = await hs256.verify(signingInput, sig, secret);
// ok === true
// Tampered tag:
sig[sig.length - 1] ^= 0x01;
const stillOk = await hs256.verify(signingInput, sig, secret);
// stillOk === false (constant-time compare returned false)
```
## API Reference
- `hs256: AlgorithmImpl<Uint8Array>` — `{ alg: "HS256", verify, sign }`
- `hs384: AlgorithmImpl<Uint8Array>` — same shape.
- `hs512: AlgorithmImpl<Uint8Array>` — same shape.
Each `verify(signingInput, signature, key)` returns `Promise<boolean>`; each `sign(signingInput, key)` returns `Promise<Uint8Array>`. Non-`Uint8Array` keys throw `JwsError(JWS.KEY_ALG_MISMATCH)` synchronously.
## Implementation Notes
HMAC is implemented over `@suaveplan/crypto-hash.hmac`, which routes through the package's primitive-selector layer (Noble JS by default; native via the build-time `crypto.config.ts` switch). HMAC keys are required to be `Uint8Array`; passing the raw shared-secret string is a common footgun (its UTF-8 encoding is the actual key, not the string itself), and surfacing a typed `KEY_ALG_MISMATCH` is friendlier than silently HMAC-ing the wrong bytes. Per RFC 7518 §3.2 production deployments SHOULD use keys ≥ the hash output length (32/48/64 bytes); this package does not enforce a minimum because some test vectors use shorter keys, but consumers SHOULD layer that check at their boundary.

View File

@@ -0,0 +1,59 @@
# `none` algorithm
## Purpose
RFC 7518 §3.6 `"none"` algorithm. The `none` value declares "no integrity protection," which is virtually never what a verifier wants but is occasionally needed for non-security-critical contexts (signed-by-policy local development, deliberately-unsigned debugging payloads). This implementation exists only so the registry is complete; the verify pipeline rejects `none` by default and requires BOTH `algorithms: ["none"]` AND `acceptNone: true` to opt in, neutralising the classic "alg: none" downgrade attack.
## Features
- `verify` returns `true` if-and-only-if the signature segment is empty (zero bytes after base64url-decode).
- `sign` always emits empty signature bytes.
- Default rejection: pipeline throws `JWS.ALG_NONE_DISABLED` unless both flags are set.
## Usage: Basic Example
```ts
import { compactSign, compactVerify } from "@suaveplan/jws";
const token = await compactSign("hello", undefined, "none");
// token has the form "<header>.<payload>." — empty signature segment.
await compactVerify(token, undefined, {
algorithms: ["none"],
acceptNone: true,
});
```
## Usage: Advanced Example
```ts
import { compactVerify, JwsError } from "@suaveplan/jws";
const noneToken = "eyJhbGciOiJub25lIn0.eyJzdWIiOjF9.";
// Default: rejected.
try {
await compactVerify(noneToken, undefined, { algorithms: ["HS256"] });
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.ALG_NONE_DISABLED") {
// CVE-class defence in depth.
}
}
// Allowlisted but no acceptNone — also rejected.
try {
await compactVerify(noneToken, undefined, { algorithms: ["none"] });
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.ALG_NONE_DISABLED") {
// Two flags required.
}
}
```
## API Reference
- `none: AlgorithmImpl<undefined>` — verify `(input, sig)` returns `Promise<boolean>` (`true` iff `sig.length === 0`); sign `(input, _)` returns `Promise<Uint8Array>` of length 0.
## Implementation Notes
The two-flag opt-in design is a deliberate defence against the historic JWT-CVE class where a caller passes `algorithms: ["HS256"]` AND a public key, then an attacker mints a token with `alg: "none"` and the library accepts it because the allowlist is parsed permissively. Here, even a deliberate `algorithms: ["none"]` requires the second flag `acceptNone: true` — the second flag is intentionally separate so a single typo or merged configuration cannot accidentally accept unsigned tokens. The implementation also requires the signature segment to be empty, not "not present" — a token like `"<hdr>.<payload>.deadbeef"` with `alg: "none"` is rejected because the signature bytes are non-zero.

View File

@@ -0,0 +1,59 @@
# `AlgorithmRegistry`
## Purpose
In-package mapping from `alg` identifier to the concrete `AlgorithmImpl` that knows how to verify and (optionally) sign. The registry is seeded at module load with the 13 built-in algorithms required by RFC 7518 §3 and RFC 8037 (`HS256/384/512`, `RS256/384/512`, `PS256/384/512`, `ES256/384/512`, `EdDSA`, `none`). Downstream packages register experimental algorithms (e.g. ML-DSA, secp256k1) via `AlgorithmRegistry.register` without forking this package.
## Features
- `defaultRegistry` — the package-wide seeded registry consumed by the verify/sign pipelines.
- `register(alg, impl)` — add an algorithm; duplicate registration throws `JWS.ALG_UNSUPPORTED`.
- `lookup(alg)` — returns the impl or `undefined`.
- `lookupOrThrow(alg)` — returns the impl or throws `JWS.ALG_UNSUPPORTED`.
- `has(alg)` / `algorithms()` — introspection helpers.
## Usage: Basic Example
```ts
import { defaultRegistry } from "@suaveplan/jws";
defaultRegistry.has("RS256"); // true
defaultRegistry.has("ML-DSA-44"); // false
```
## Usage: Advanced Example
```ts
import {
AlgorithmRegistry,
type AlgorithmImpl,
defaultRegistry,
} from "@suaveplan/jws";
const mlDsa44: AlgorithmImpl<Uint8Array> = {
alg: "ML-DSA-44",
verify: async (signingInput, sig, pub) => myMlDsaImpl.verify(pub, sig, signingInput),
sign: async (signingInput, priv) => myMlDsaImpl.sign(priv, signingInput),
};
defaultRegistry.register("ML-DSA-44", mlDsa44);
// Or build an isolated registry for an integration test:
const isolated = new AlgorithmRegistry();
isolated.register("HS256", myHsImpl);
```
## API Reference
- `class AlgorithmRegistry`
- `register<K>(alg: string, impl: AlgorithmImpl<K>): void`
- `lookup(alg: string): AlgorithmImpl<unknown> | undefined`
- `lookupOrThrow(alg: string): AlgorithmImpl<unknown>`
- `has(alg: string): boolean`
- `algorithms(): readonly string[]`
- `const defaultRegistry: AlgorithmRegistry`
- `interface AlgorithmImpl<K = unknown> { alg: string; verify(signingInput, signature, key: K): Promise<boolean>; sign?(signingInput, key: K): Promise<Uint8Array> }`
- `seedBuiltinAlgorithms(): void` — idempotent seeding (already invoked at module load).
## Implementation Notes
Built-ins are seeded by the side-effect import `import "./builtin.js"` at the root of `algorithms/index.ts` AND `src/index.ts`. Idempotent via a module-scoped boolean so the registry survives test reload cycles. Registering an algorithm twice throws rather than silently overwriting, because silent overwrite would be an algorithm-confusion vector — a malicious downstream package could redefine `RS256` to delegate to an attacker-controlled impl. The registry is mutable but only via `register`; there is no `unregister` for the same reason.

View File

@@ -0,0 +1,57 @@
# RSASSA-PSS algorithms (`PS256` / `PS384` / `PS512`)
## Purpose
RFC 7518 §3.5 RSASSA-PSS signing and verification. The signing input is hashed, EMSA-PSS-encoded with a random salt, and signed via `@suaveplan/crypto-rsa.rsaSignPss`. The salt length is fixed at the hash output length per RFC 7518 §3.5 ("Salt Length shall be equal to the size of the hash output") — externally-produced signatures with non-matching salt lengths simply fail verify rather than being silently accepted, which catches producers that defaulted to a different PSS profile.
## Features
- `PS256` → RSASSA-PSS + SHA-256 + 32-byte salt.
- `PS384` → RSASSA-PSS + SHA-384 + 48-byte salt.
- `PS512` → RSASSA-PSS + SHA-512 + 64-byte salt.
- MGF1 hash matches the message hash (RFC 7518 §3.5 default).
- Delegates to `@suaveplan/crypto-rsa.rsaSignPss` / `rsaVerifyPss`.
## Usage: Basic Example
```ts
import { generateRsaKeyPair } from "@suaveplan/crypto-rsa";
import { compactSign, compactVerify } from "@suaveplan/jws";
const { publicKey, privateKey } = await generateRsaKeyPair({ modulusLength: 2048 });
const token = await compactSign("hello", privateKey, "PS256");
await compactVerify(token, publicKey, { algorithms: ["PS256"] });
```
## Usage: Advanced Example
```ts
import { ps256 } from "@suaveplan/jws";
// Signature produced with a 16-byte salt instead of 32:
const externalSig = produceFromBadProducer(message, priv, /*saltLen*/ 16);
const ok = await ps256.verify(new TextEncoder().encode("hdr.payload"), externalSig, pub);
// ok === false — salt length mismatch caught by EMSA-PSS-Verify.
// Algorithm-confusion defence: passing an HMAC byte-key throws.
import { JwsError } from "@suaveplan/jws";
try {
await ps256.verify(input, sig, new Uint8Array(32));
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.KEY_ALG_MISMATCH") {
// PSS verify expects an RsaPublicKey, not a raw byte buffer.
}
}
```
## API Reference
- `ps256: AlgorithmImpl<RsaPublicKey | RsaPrivateKey>`
- `ps384: AlgorithmImpl<RsaPublicKey | RsaPrivateKey>`
- `ps512: AlgorithmImpl<RsaPublicKey | RsaPrivateKey>`
`verify` returns `Promise<boolean>`; `sign` returns `Promise<Uint8Array>` whose length matches the modulus (256 bytes for 2048-bit keys).
## Implementation Notes
PSS salt is drawn by `@suaveplan/crypto-rsa.rsaSignPss` via either the caller's optional `Rng` injection or the default RNG (`crypto.getRandomValues`). This means PSS signatures are probabilistic — the same payload signed twice produces two different tokens; tests that need determinism inject a deterministic `Rng`. Verifying with a fixed salt length and rejecting mismatched-salt signatures is a deliberate departure from libraries that auto-detect salt length from the signature length: that auto-detection is the source of many JWT-PSS interop bugs because the auto-detect heuristic disagrees on edge-case modulus sizes.

View File

@@ -0,0 +1,53 @@
# RSASSA-PKCS1-v1_5 algorithms (`RS256` / `RS384` / `RS512`)
## Purpose
RFC 7518 §3.3 RSASSA-PKCS1-v1_5 signing and verification. The signing input is hashed with SHA-256/SHA-384/SHA-512 respectively, EMSA-PKCS1-v1_5-encoded to the modulus length, and signed via the modular-exponentiation primitive from `@suaveplan/crypto-rsa`. This is the dominant JWS algorithm in production (OAuth/OIDC JWT signing, SafetyNet, Apple App Attest, GitHub workflow tokens) and the algorithm `webauthn` is unblocked on.
## Features
- `RS256` → RSASSA-PKCS1-v1_5 + SHA-256.
- `RS384` → RSASSA-PKCS1-v1_5 + SHA-384.
- `RS512` → RSASSA-PKCS1-v1_5 + SHA-512.
- Delegates to `@suaveplan/crypto-rsa`'s `rsaVerifyPkcs1v15` / `rsaSignPkcs1v15`.
- Key shape: `RsaPublicKey` (verify) / `RsaPrivateKey` (sign) — both from `@suaveplan/crypto-rsa`.
## Usage: Basic Example
```ts
import { generateRsaKeyPair, parseRsaPublicKeyJwk } from "@suaveplan/crypto-rsa";
import { compactSign, compactVerify } from "@suaveplan/jws";
const { publicKey, privateKey } = await generateRsaKeyPair({ modulusLength: 2048 });
const token = await compactSign("hello", privateKey, "RS256");
const r = await compactVerify(token, publicKey, { algorithms: ["RS256"] });
```
## Usage: Advanced Example
```ts
import { parseRsaPublicKey } from "@suaveplan/crypto-rsa";
import { compactVerify, rs256 } from "@suaveplan/jws";
// Verify a JWS whose key arrived as a DER-encoded SPKI from a registry:
const spkiDer = await fetch("/.well-known/jwks-spki.der").then((r) => r.bytes());
const pub = parseRsaPublicKey(spkiDer, { format: "spki" });
const result = await compactVerify(token, pub, {
algorithms: ["RS256", "RS384"],
});
// Direct-impl access (e.g. signing custom binary payloads):
const sig = await rs256.sign!(signingInput, priv);
```
## API Reference
- `rs256: AlgorithmImpl<RsaPublicKey | RsaPrivateKey>`
- `rs384: AlgorithmImpl<RsaPublicKey | RsaPrivateKey>`
- `rs512: AlgorithmImpl<RsaPublicKey | RsaPrivateKey>`
Each `verify` returns `Promise<boolean>`; each `sign` returns `Promise<Uint8Array>` of length `Math.ceil(bitLength/8)`. Non-RSA-shaped keys throw `JwsError(JWS.KEY_ALG_MISMATCH)`.
## Implementation Notes
This module is a thin façade over `@suaveplan/crypto-rsa` — it never reimplements PKCS#1 v2.2 or modular arithmetic. Verification swallows any low-level `CryptoRsaError` and returns `false` so structural malformation surfaces as `JWS.SIG_INVALID` at the pipeline level rather than leaking RSA-package-internal codes; signing propagates because a sign-side error means the caller's key is structurally wrong (modulus too short, missing CRT parameters, etc.) and they need the precise diagnostic. RSA private operations in `@suaveplan/crypto-rsa` run through CRT and apply RSA blinding (Kocher 1996 timing-attack defence) by default.