first commit
This commit is contained in:
46
docs/security-stack/security/jws/src/algorithms/builtin.md
Normal file
46
docs/security-stack/security/jws/src/algorithms/builtin.md
Normal 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.
|
||||
60
docs/security-stack/security/jws/src/algorithms/ecdsa.md
Normal file
60
docs/security-stack/security/jws/src/algorithms/ecdsa.md
Normal 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.
|
||||
52
docs/security-stack/security/jws/src/algorithms/eddsa.md
Normal file
52
docs/security-stack/security/jws/src/algorithms/eddsa.md
Normal 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`.
|
||||
54
docs/security-stack/security/jws/src/algorithms/hmac.md
Normal file
54
docs/security-stack/security/jws/src/algorithms/hmac.md
Normal 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.
|
||||
59
docs/security-stack/security/jws/src/algorithms/none.md
Normal file
59
docs/security-stack/security/jws/src/algorithms/none.md
Normal 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.
|
||||
59
docs/security-stack/security/jws/src/algorithms/registry.md
Normal file
59
docs/security-stack/security/jws/src/algorithms/registry.md
Normal 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.
|
||||
57
docs/security-stack/security/jws/src/algorithms/rsa-pss.md
Normal file
57
docs/security-stack/security/jws/src/algorithms/rsa-pss.md
Normal 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.
|
||||
53
docs/security-stack/security/jws/src/algorithms/rsa.md
Normal file
53
docs/security-stack/security/jws/src/algorithms/rsa.md
Normal 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.
|
||||
Reference in New Issue
Block a user