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,34 @@
# __benchmarks__/blinded_vs_unblinded_2048
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Mitata-driven benchmark measuring the wall-clock cost of RSA blinding
relative to the bare CRT private operation on a 2048-bit key. Validates
the < 5% blinding-overhead claim from the change proposal so any future
regression in the blinding path is visible at PR-review time.
## Features
- Generates a fresh 2048-bit key per run (no committed key bytes)
- Measures both `rsaPrivateOperationCrt(m, key)` and the blinded path
(`blindMessage` → `rsaPrivateOperationCrt` → `unblindResult`)
- Single-process wall-clock comparison; not statistical to mitata's
full rigor — see the bench output for confidence intervals
## Usage
```bash
bun src/__benchmarks__/blinded_vs_unblinded_2048.ts
```
## Implementation Notes
- NOT wired into CI — purely a developer benchmark for tracking
regressions in the blinding path. The 5% overhead headroom in the
spec is the gate; if a refactor pushes overhead past that, the
benchmark surfaces the regression here before anything ships.
- Uses `defaultRng()` for the blinding factor so the timing reflects
production behaviour (CSPRNG-backed sampling).

View File

@@ -0,0 +1,42 @@
# __fixtures__/rsa-2048-key
## Purpose
Real 2048-bit RSA keypair generated once at module-load time and exposed
as canonical `RsaPublicKey` / `RsaPrivateKey` shapes for Stage 2+ tests
(sign / verify / encrypt / decrypt). The 16-bit synthetic
`TINY_PRIVATE_KEY` fixture in `test-keys.ts` cannot host a SHA-256 EM
(32 bytes is wider than its 2-byte modulus), so signature tests need a
real-sized key.
## Features
- Real 2048-bit keypair via `node:crypto.generateKeyPairSync`
- Decoded into canonical bigint form via `finalisePrivateKey` /
`finalisePublicKey`
- Stable within a single test run (deterministic across all tests in the
same process); not deterministic across processes
- Re-run cost amortised across the entire Stage 2+ suite (≈ 50 ms once)
## Usage
```ts
import {
RSA_2048_PRIVATE_KEY,
RSA_2048_PUBLIC_KEY,
} from "../__fixtures__/rsa-2048-key.js";
```
## API Reference
### `RSA_2048_PUBLIC_KEY: RsaPublicKey`
### `RSA_2048_PRIVATE_KEY: RsaPrivateKey`
## Implementation Notes
- Tests that need byte-exact reference vectors must use one of the RFC
8017 Appendix C / Wycheproof / CAVP fixture files (added incrementally
).
- The fixture is not committed bytes-on-disk; regeneration on every
test process amortises down to noise once the rest of the suite runs.

View File

@@ -0,0 +1,40 @@
# __fixtures__/test-keys
## Purpose
Tiny synthetic RSA keypair for unit tests that need an `RsaPrivateKey` /
`RsaPublicKey` shape but don't need to run a real cryptographic
operation. Modulus is 16 bits (`n = 251 * 241 = 60 491`) so the
modulus-length policy gate's sub-1024-bit reject path can fire without
colliding with real signing operations.
## Features
- Hand-computed CRT parameters (`d`, `p`, `q`, `dp`, `dq`, `qInv`)
- Round-trip-safe through every parser/serialiser
- 16-bit modulus exercises the `MODULUS_TOO_SHORT` rejection path
## Usage
```ts
import {
TINY_PRIVATE_KEY,
TINY_PUBLIC_KEY,
} from "../__fixtures__/test-keys.js";
```
## API Reference
### `TINY_PUBLIC_KEY: RsaPublicKey`
### `TINY_PRIVATE_KEY: RsaPrivateKey`
## Implementation Notes
- NOT a real cryptographic key. Do not use for any signature /
encryption operation that needs to interoperate with real RSA keys —
the modulus is far below the FIPS / industry minimum.
- The 16-bit modulus is chosen specifically so it cannot host a SHA-256
encoded message (the EM is 32 bytes, the modulus is 2 bytes); this
triggers the canonical `MODULUS_TOO_SHORT` failure path without
needing to construct adversarial inputs.

View File

@@ -0,0 +1,88 @@
# `asn1/reader`
## Purpose
Strict-DER reader scoped to the RSA key shapes defined in RFC 8017 Appendix A
(PKCS#1), RFC 5280 §4.1 + Appendix A (`SubjectPublicKeyInfo`), and RFC 5208 / RFC 5958
(PKCS#8 `PrivateKeyInfo` / `OneAsymmetricKey`). The reader rejects every BER
construction that DER forbids — indefinite length, non-minimal length, non-minimal
integer encoding, non-zero unused-bits in `BIT STRING`, trailing data — by raising
`CryptoRsaError(CRYPTO_RSA.INVALID_KEY)`.
The implementation is intentionally minimal. It implements exactly the subset needed
to decode RSA keys; it is not a general-purpose ASN.1 library.
## Features
- Stateful cursor with `position`, `remaining`, `hasMore`, `peekTag`, `assertEnd`.
- `readInteger`, `readSequence`, `readObjectIdentifier`, `readBitString`,
`readOctetString`, `readNull`, `readContextSpecific(tagNumber)`.
- Strict DER length parsing — non-minimal long-form lengths are rejected.
- Strict DER integer parsing — leading-`0x00` only when MSB requires it; negative
encodings rejected (RSA INTEGERs are non-negative).
- `BIT STRING` reader copies the body so callers cannot mutate the input buffer.
- Sub-readers returned by `readSequence` / `readContextSpecific` are scoped to the
child body for ergonomic recursive descent.
## Usage: Basic Example
```ts
import { DerReader } from "@suaveplan/crypto-rsa/asn1";
const reader = new DerReader(pkcs1PublicKeyDer);
const seq = reader.readSequence();
const modulus = seq.readInteger();
const exponent = seq.readInteger();
seq.assertEnd();
reader.assertEnd();
```
## Usage: Advanced Example
```ts
import { DerReader, TAG_INTEGER } from "@suaveplan/crypto-rsa/asn1";
const reader = new DerReader(pkcs8Bytes);
const outer = reader.readSequence();
// version is OPTIONAL DEFAULT 0 in some PKCS#8 dialects
const version =
outer.peekTag() === TAG_INTEGER ? outer.readInteger() : 0n;
const algSeq = outer.readSequence();
const oid = algSeq.readObjectIdentifier();
algSeq.readNull();
const privateKeyOctet = outer.readOctetString();
const inner = new DerReader(privateKeyOctet).readSequence();
// … recurse into RSAPrivateKey body.
```
## API Reference
- `class DerReader { constructor(bytes: Uint8Array, offset?: number) }`
- `position: number` — current cursor offset (read-only).
- `remaining: number` — bytes remaining in the buffer.
- `hasMore(): boolean` — true if the cursor has not reached the end.
- `peekTag(): number | undefined` — tag byte at the cursor, or `undefined` at EOF.
- `readInteger(): bigint` — consumes a DER `INTEGER`; throws `INVALID_KEY` on bad encoding.
- `readSequence(): DerReader` — body sub-reader for the next `SEQUENCE`.
- `readObjectIdentifier(): string` — dotted-decimal OID (e.g. `"1.2.840.113549.1.1.1"`).
- `readBitString(): Uint8Array` — payload bytes; throws when unused-bits ≠ 0.
- `readOctetString(): Uint8Array` — payload bytes (defensive copy).
- `readNull(): void` — consumes a DER `NULL`; throws on non-empty body.
- `readContextSpecific(tagNumber: 0..30): DerReader` — body sub-reader for `[n]`.
- `assertEnd(): void` — throws `INVALID_KEY` if any unread bytes remain.
All failure modes throw `CryptoRsaError` with `code === CRYPTO_RSA.INVALID_KEY` and
a structured `context` payload (e.g. `{ expected, actual }`, `{ unusedBits }`).
## Implementation Notes
The reader rejects BER-only constructs even when they are syntactically unambiguous
because RSA wire formats are defined in DER and any tooling that emits BER is
non-conformant. This matters for security: silently accepting non-canonical encodings
is a known fingerprinting and signature-malleability vector.
Sub-readers returned by `readSequence` are constructed over `Uint8Array.subarray`
(a view), not a copy, so the parser remains zero-allocation on the hot path. The
single defensive copy is in `readBitString` because callers receive the payload
directly and the SPKI bit-string carries the inner PKCS#1 body which is then
re-parsed.

View File

@@ -0,0 +1,87 @@
# `asn1/writer`
## Purpose
Strict-DER encoder mirroring `asn1/reader`. Encodes the same minimal subset of
ASN.1 needed to serialise RSA keys in PKCS#1, SPKI, and PKCS#8 wire formats with
byte-identical canonical form, so `parse(serialise(key))` round-trips bit-for-bit.
The writer never emits BER constructions even when the underlying X.690 grammar
permits them; minimal definite-length encoding, minimal integer encoding, and
zero-unused-bit `BIT STRING` are the only forms produced.
## Features
- `encodeLength` — minimal definite-length form per X.690 §8.1.3.
- `encodeInteger(bigint)` — non-negative `bigint` with leading-`0x00` insertion
when the high bit is set (so positive interpretation is unambiguous).
- `encodeSequence(...children)` — variadic concatenation of pre-encoded child TLVs.
- `encodeObjectIdentifier(oid)` — dotted-decimal OID with base-128 sub-identifier
encoding and validation of the first two arcs.
- `encodeBitString` — primitive `BIT STRING` carrying a whole-byte payload.
- `encodeOctetString`, `encodeNull`, `encodeContextSpecific(tagNumber, body)`.
## Usage: Basic Example
```ts
import {
encodeInteger,
encodeSequence,
} from "@suaveplan/crypto-rsa/asn1";
const pkcs1Public = encodeSequence(
encodeInteger(modulus),
encodeInteger(exponent)
);
```
## Usage: Advanced Example
```ts
import {
encodeBitString,
encodeNull,
encodeObjectIdentifier,
encodeSequence,
} from "@suaveplan/crypto-rsa/asn1";
const RSA_ENCRYPTION_OID = "1.2.840.113549.1.1.1";
const algorithmIdentifier = encodeSequence(
encodeObjectIdentifier(RSA_ENCRYPTION_OID),
encodeNull()
);
const subjectPublicKeyInfo = encodeSequence(
algorithmIdentifier,
encodeBitString(pkcs1PublicKeyDer)
);
```
## API Reference
- `encodeLength(length: number): Uint8Array` — DER length octet(s).
- `encodeInteger(value: bigint): Uint8Array` — full INTEGER TLV.
- `encodeSequence(...children: Uint8Array[]): Uint8Array` — full SEQUENCE TLV.
- `encodeObjectIdentifier(oid: string): Uint8Array` — full OID TLV.
- `encodeBitString(payload: Uint8Array): Uint8Array` — full BIT STRING TLV.
- `encodeOctetString(payload: Uint8Array): Uint8Array` — full OCTET STRING TLV.
- `encodeNull(): Uint8Array` — `0x05 0x00`.
- `encodeContextSpecific(tagNumber: 0..30, body: Uint8Array): Uint8Array` — `[n]` wrapping.
All inputs are validated. Negative integers, malformed OID strings, lengths that
overflow, and out-of-range context tag numbers throw
`CryptoRsaError(CRYPTO_RSA.INVALID_KEY)`.
## Implementation Notes
The writer is fully synchronous and allocates one `Uint8Array` per TLV node plus
one final concatenation through `concatBytes` from `@suaveplan/crypto-utils`.
For the small key-shape graph (≤ 8 INTEGERs for an RSA private key) this is well
below any allocation budget.
OID encoding implements the standard base-128 sub-identifier rule: the high bit of
every byte except the last is set, so each sub-identifier reconstructs as a
big-endian variable-length integer. The first two arcs are folded into a single
byte (`arc1 * 40 + arc2`) per X.690 §8.19; arc1 is constrained to `{0, 1, 2}`,
and arc2 to `< 40` when arc1 is 0 or 1. Both constraints are enforced.

View File

@@ -0,0 +1,133 @@
# `blinding`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
RSA blinding (RFC 8017 §5.1.2 informational note) is the standard
defence against remote-timing attacks on RSA private operations
(decrypt and sign). Without it, the wall-clock time of a
`c^d mod n` computation correlates with the bits of the secret
exponent `d` and with the value of `c` — well-published attacks
(Brumley/Boneh 2003, Aciicmez et al. 2007) recovered RSA private
keys from a single network-attached server in minutes.
This module exposes the two halves of the defence — `blindMessage`
that randomises the input and `unblindResult` that recovers the
true output — as standalone helpers so they can be wired into
every private-operation surface in the package (`rsaDecryptPkcs1v15`
and `rsaDecryptOaep` here , `rsaSignPkcs1v15` and
`rsaSignPss` once that wave lands).
## Features
- Uniform rejection sampling of the blinding factor `r` over
`Z*_n` (drop any candidate `≥ n`, drop any candidate sharing a
prime factor with `n` so `r^-1 mod n` always exists).
- Exhaustion guard — `MAX_REJECTIONS = 1024` candidates before
surfacing `KEYGEN_FAILED`. For a well-formed RSA modulus this
never fires; the cap exists only to keep a malformed key (e.g.
`n` with very small factors) from looping forever.
- Pluggable `Rng` source — works with both `crypto.getRandomValues`
CSPRNGs (use the optional `nextBytes` fast path) and seeded
deterministic test RNGs (fall back to `nextUint32` extraction).
- Helper `nextBytesViaRng` exported for reuse by keygen and other
modules that need RNG byte extraction.
## Usage: Basic Example
```ts
import { blindMessage, unblindResult } from "@suaveplan/crypto-rsa/blinding";
import {
rsaPrivateOperationCrt,
type RsaPrivateKey,
type RsaPublicKey,
} from "@suaveplan/crypto-rsa";
import { defaultByteSource } from "@suaveplan/crypto-utils";
declare const m: bigint;
declare const publicKey: RsaPublicKey;
declare const privateKey: RsaPrivateKey;
const csprng = {
nextUint32: () =>
new DataView(defaultByteSource(4).buffer).getUint32(0),
nextFloat: () => Math.random(),
nextBytes: (n: number) => defaultByteSource(n),
};
const { mPrime, rInverse } = blindMessage(m, publicKey, csprng);
const sBlinded = rsaPrivateOperationCrt(mPrime, privateKey);
const s = unblindResult(sBlinded, rInverse, publicKey.n);
// s === m^d mod n, but the modPow ran on the random m' instead of m
```
## Usage: Advanced Example
A deterministic seeded `Rng` for tests — `blindMessage` will use
`nextUint32` extraction when `nextBytes` is omitted, so the same
seed produces the same `(mPrime, rInverse)` every run:
```ts
import { blindMessage, unblindResult } from "@suaveplan/crypto-rsa/blinding";
let state = 0x12345678 >>> 0;
const seededRng = {
nextUint32: () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state;
},
nextFloat: () => state / 0xffffffff,
};
const { mPrime, rInverse } = blindMessage(0x42n, publicKey, seededRng);
// ... feed into rsaPrivateOperationCrt, then unblindResult(...)
```
## API Reference
### `blindMessage(m, key, rng): BlindResult`
- `m: bigint` — message representative in `[0, n)`. Throws
`CRYPTO_RSA.INVALID_KEY` if out of range.
- `key: RsaPublicKey` — public key carrying `n` and `e`.
- `rng: Rng` — randomness source (canonical
`@suaveplan/types` interface).
- Returns `{ mPrime, rInverse }` — the blinded message and the
modular inverse of `r` to be passed back into `unblindResult`.
- Throws `CRYPTO_RSA.KEYGEN_FAILED` after `MAX_REJECTIONS` failed
rejection-sampling attempts. Indicates a pathological modulus,
not a normal failure mode.
### `unblindResult(s, rInverse, n): bigint`
Pure modular multiplication — `(s * rInverse) % n`. Constant-time
to the extent JavaScript's bigint arithmetic allows.
### `nextBytesViaRng(rng, n): Uint8Array`
Helper that extracts `n` bytes from an arbitrary `Rng`,
preferring the optional `nextBytes` fast path when present.
## Implementation Notes
- We use noble's `invert` for `r^-1 mod n` rather than rolling our
own extended-Euclidean — noble already implements the Bezout
algorithm with bigint-correct sign handling.
- `gcd(r, n)` rejection guards against the (vanishingly rare)
case where the random `r` shares a prime factor with `n`.
Without this, `invert` would throw and we'd lose the candidate
anyway, but the gcd check is cheaper than catching the throw
in a tight loop.
- The blinding factor `r` is sampled byte-wise with a top-bit
mask matching `n`'s bit length so the rejection rate stays
below 50% per draw.
- This module is constant-time only to the extent JavaScript's
bigint arithmetic allows. Bigint multiplication and modular
reduction over 2048+ bit values are NOT constant-time in V8 or
JavaScriptCore. Blinding still meaningfully degrades the
signal-to-noise ratio of timing attacks because the secret no
longer correlates with the input — but for FIPS-grade
side-channel resistance, defer to a hardware engine
(`crypto-browser` WebCrypto, `crypto-sodium` libsodium).

View File

@@ -0,0 +1,86 @@
# `decrypt/oaep`
## Purpose
RSAES-OAEP decryption per RFC 8017 §7.1.2 — counterpart to
`encrypt/oaep`. Modern default for RSA public-key encryption.
The defining concern of this module is the **Manger defence**:
every padding-format failure surfaces the SAME
`CRYPTO_RSA.DECRYPT_FAILED` error so a remote attacker cannot
mount the chosen-ciphertext attack Manger described against
naive OAEP implementations in 2001.
## Features
- Combined error path — leading-byte mismatch, `lHash` mismatch
(wrong label), missing `0x01` separator, non-zero byte in
`PS` before separator, integer ≥ `n`, ciphertext length ≠ `k`,
modulus too small for the chosen hash: every failure raises
the single `DECRYPT_FAILED` code.
- Constant-time `lHash` comparison via `constantTimeEqual` from
`@suaveplan/crypto-utils` (the comparison we DO have a
guarantee about — the rest of the unwrap is best-effort).
- RSA blinding wired in by default; `{ blinding: false }` opt-out
emits the `crypto_rsa.blinding_disabled_total{operation="decrypt"}`
counter.
- CRT-accelerated private operation.
- `crypto_rsa.decrypt_duration_ms{scheme="oaep",hash}` histogram.
## Usage: Basic Example
```ts
import { rsaDecryptOaep } from "@suaveplan/crypto-rsa/decrypt/oaep";
const plaintext = rsaDecryptOaep(privateKey, ciphertext, {
hash: "SHA-256",
});
```
## Usage: Advanced Example
OAEP-SHA-384 with explicit label — the matching encrypt MUST
have used the same hash, MGF hash, AND label or this throws
`DECRYPT_FAILED`:
```ts
import { rsaDecryptOaep } from "@suaveplan/crypto-rsa/decrypt/oaep";
const plaintext = rsaDecryptOaep(privateKey, ciphertext, {
hash: "SHA-384",
mgfHash: "SHA-384",
label: new TextEncoder().encode("session-2026-05-11"),
allowLegacy: false,
});
```
## API Reference
### `rsaDecryptOaep(key, ciphertext, options?): Uint8Array`
- `key: RsaPrivateKey`
- `ciphertext: Uint8Array` — must equal `k = ceil(bitLength / 8)`.
- `options.hash?: RsaHashAlgorithm` — defaults to `"SHA-256"`.
- `options.mgfHash?: RsaHashAlgorithm` — defaults to `hash`.
- `options.label?: Uint8Array` — defaults to empty.
- `options.rng?: Rng` — randomness source for blinding.
- `options.blinding?: boolean` — defaults to `true`.
- `options.allowLegacy?: boolean` — accept 1024-bit moduli.
- Returns the recovered plaintext.
- Throws `CRYPTO_RSA.DECRYPT_FAILED` on every padding failure.
## Implementation Notes
- The combined error path is enforced by routing every failure
through the private `decryptFailed()` helper — a single
callsite makes future drift detectable in code review.
- `constantTimeEqual` from `@suaveplan/crypto-utils` covers the
`lHash` byte comparison; the surrounding unwrap loop is NOT
constant-time because JavaScript bigint and `Uint8Array.slice`
are not. This is the same practical limitation as PKCS1-v1_5
decryption — for FIPS-grade side-channel resistance, defer to
`crypto-browser` (WebCrypto) or `crypto-sodium`.
- The OAEP separator scan walks `db` from index `hLen` onward
and rejects on either a non-zero, non-0x01 byte before the
separator or no separator at all. This implements RFC 8017
§7.1.2 step 3.g exactly.

View File

@@ -0,0 +1,88 @@
# `decrypt/pkcs1v15`
## Purpose
RSAES-PKCS1-v1_5 decryption per RFC 8017 §7.2.2. Counterpart to
`encrypt/pkcs1v15`. Provides legacy interop for protocols that
predate OAEP (TLS 1.0–1.2 RSA key transport, JWE `RSA1_5` legacy
mode). Modern callers MUST prefer `decrypt/oaep`.
The defining concern of this module is the **Bleichenbacher
defence**: every padding-format failure surfaces the SAME
`CRYPTO_RSA.DECRYPT_FAILED` error so a remote attacker observing
the typed-error code (or the OTel error counter) cannot mount a
million-message chosen-ciphertext attack.
## Features
- Combined error path — wrong leading byte, wrong block-type
marker, missing `0x00` separator, `PS` shorter than 8 bytes,
ciphertext length ≠ `k`, post-decrypt integer ≥ `n`: every
failure throws the single `DECRYPT_FAILED` code.
- RSA blinding wired in by default — `{ blinding: false }` opt-out
emits the `crypto_rsa.blinding_disabled_total{operation="decrypt"}`
counter so production dashboards can flag any caller that
disables the timing-side-channel defence.
- CRT-accelerated private operation (`rsaPrivateOperationCrt`).
- Modulus-length policy gate runs first.
- `crypto_rsa.decrypt_duration_ms{scheme="pkcs1v15"}` histogram.
## Usage: Basic Example
```ts
import { rsaDecryptPkcs1v15 } from "@suaveplan/crypto-rsa/decrypt/pkcs1v15";
import type { RsaPrivateKey } from "@suaveplan/crypto-rsa";
declare const privateKey: RsaPrivateKey;
declare const ciphertext: Uint8Array;
const plaintext = rsaDecryptPkcs1v15(privateKey, ciphertext, {
allowLegacy: false,
});
```
## Usage: Advanced Example
Disable blinding (NOT recommended outside benchmarks) — the
counter increment surfaces the choice in production telemetry:
```ts
import { rsaDecryptPkcs1v15 } from "@suaveplan/crypto-rsa/decrypt/pkcs1v15";
const plaintext = rsaDecryptPkcs1v15(privateKey, ciphertext, {
blinding: false, // emits crypto_rsa.blinding_disabled_total{operation="decrypt"}
allowLegacy: true,
});
```
## API Reference
### `rsaDecryptPkcs1v15(key, ciphertext, options?): Uint8Array`
- `key: RsaPrivateKey` — modulus must satisfy the policy gate.
- `ciphertext: Uint8Array` — must be exactly `k` bytes; any
other length throws `DECRYPT_FAILED`.
- `options.rng?: Rng` — randomness source for blinding.
- `options.blinding?: boolean` — defaults to `true`. Set to
`false` only for benchmark comparisons.
- `options.allowLegacy?: boolean` — accept 1024-bit moduli.
- Returns the recovered plaintext (`em.length - separator - 1`
bytes).
- Throws `CRYPTO_RSA.DECRYPT_FAILED` on every padding failure.
## Implementation Notes
- The combined error path is enforced by routing every failure
through the private `decryptFailed()` helper — a single
callsite for `new CryptoRsaError(DECRYPT_FAILED, ...)` makes
it impossible for a future edit to accidentally introduce a
differentiated error.
- True constant-time decryption is not achievable in JavaScript
without WebCrypto (V8/JSC bigint ops are not constant-time).
We mitigate with blinding and with eager-throw bucketing all
padding failures under one error class. For FIPS-grade
side-channel resistance, route through `crypto-browser`
(WebCrypto) or `crypto-sodium`.
- The `c >= key.n` check is RFC 8017 §5.1.2's "ciphertext
representative out of range" condition — also bucketed under
`DECRYPT_FAILED` so callers cannot distinguish "padding bad"
from "ciphertext outside `Z*_n`".

View File

@@ -0,0 +1,84 @@
# emsa-pkcs1-v1\_5
## Purpose
Implements EMSA-PKCS1-v1\_5-ENCODE from RFC 8017 §9.2 — the encoding
method underlying RSASSA-PKCS1-v1\_5 signatures. This module also pins
the four DigestInfo prefix byte sequences from §9.2 note 1 as named
constants so consumers and tests can audit the bytes against the RFC.
## Features
- Hard-coded `DIGEST_INFO_PREFIX_*` constants for SHA-1, SHA-256, SHA-384, SHA-512
- `digestInfoPrefix(hash)` lookup — throws `CRYPTO_RSA.UNSUPPORTED_HASH` on unknown algorithm
- `emsaPkcs1v15Encode(message, emLen, hash)` — RFC 8017 §9.2 conformant encoder
- Detects modulus-too-short conditions (`emLen < tLen + 11`) and throws `CRYPTO_RSA.INVALID_KEY`
- Returns a fresh `Uint8Array` of length exactly `emLen`
## Usage: Basic Example
```ts
import { emsaPkcs1v15Encode } from "@suaveplan/crypto-rsa/encoding/emsa-pkcs1-v1_5";
const message = new TextEncoder().encode("hello");
const em = emsaPkcs1v15Encode(message, 256, "SHA-256");
console.log(em.length); // 256
console.log(em[0], em[1]); // 0x00, 0x01
```
## Usage: Advanced Example
The encoded message is the input to the RSA private-key operation in
`rsaSignPkcs1v15`. To verify a signature, the same encoder is run on the
candidate message and the result is constant-time compared against the
public-key recovery of the signature.
```ts
import { emsaPkcs1v15Encode, digestInfoPrefix } from "@suaveplan/crypto-rsa/encoding/emsa-pkcs1-v1_5";
import { toHex } from "@suaveplan/crypto-utils";
const prefix = digestInfoPrefix("SHA-256");
console.log(toHex(prefix as Uint8Array));
// 3031300d060960864801650304020105000420
const em1 = emsaPkcs1v15Encode(new Uint8Array([1, 2, 3]), 256, "SHA-256");
const em2 = emsaPkcs1v15Encode(new Uint8Array([1, 2, 3]), 256, "SHA-256");
// em1 and em2 are byte-identical (encoding is deterministic)
```
## API Reference
### `emsaPkcs1v15Encode(message, emLen, hash): Uint8Array`
- `message: Uint8Array` — the bytes that will be digested
- `emLen: number` — the target encoded-message length, equal to `byteLength(n)`
- `hash: RsaHashAlgorithm` — `"SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"`
- **returns** `Uint8Array` of length exactly `emLen`
- **throws** `CryptoRsaError(CRYPTO_RSA.INVALID_KEY)` when `emLen < tLen + 11` (modulus too short for the chosen hash)
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` on unknown algorithm
### `digestInfoPrefix(hash): Readonly<Uint8Array>`
Returns the immutable RFC 8017 §9.2 note 1 DigestInfo prefix for `hash`.
### `DIGEST_INFO_PREFIX_SHA1` / `_SHA256` / `_SHA384` / `_SHA512`
Read-only `Uint8Array` constants holding the byte-exact DigestInfo
prefix sequences from RFC 8017 §9.2 note 1.
## Implementation Notes
- The encoded message structure is `0x00 || 0x01 || PS || 0x00 || T`,
where `PS` is at least 8 bytes of `0xff` (per RFC 8017 §9.2 step 3).
The `tLen + 11` floor encodes the 2-byte header (`0x00 || 0x01`), the
1-byte separator (`0x00`), and the 8-byte minimum for `PS`.
- The DigestInfo prefix bytes are pinned as literal byte arrays — not
ASN.1 encoder calls — so a single misbyte in the RFC table would be
caught by the bytewise prefix tests against the RFC reference.
- The `emLen` parameter is supplied by the caller (= `byteLength(n)` for
RSASSA-PKCS1-v1\_5). This module does not compute `emLen` from a key;
that responsibility belongs to the higher-level signature scheme.
- Encoding is deterministic — the same `(message, emLen, hash)` triple
always produces the same `EM` bytes.
- Hash dispatch goes through `digestWith` from `mgf1.ts`, so SHA-2
flows through `@suaveplan/crypto-hash` and SHA-1 through Noble.

View File

@@ -0,0 +1,78 @@
# emsa-pss-encode
## Purpose
Implements the EMSA-PSS encoding operation defined in RFC 8017 §9.1.1.
Given a message digest, an encoded-message bit length, a salt, and the
two hash choices (message hash and MGF1 hash), this function produces
the byte-string `EM` that is then converted to an integer and signed
with the RSA private-key operation in `rsaSignPss`.
## Features
- RFC 8017 §9.1.1 conformant — produces `EM = maskedDB || H || 0xbc`
- Caller-supplied salt — randomness is the responsibility of the signature scheme
- Configurable MGF1 hash distinct from the message hash
- Detects modulus-too-short conditions (`emLen < hLen + sLen + 2`) and throws `CRYPTO_RSA.INVALID_KEY`
- Validates the input digest length against the hash's documented `hLen`
## Usage: Basic Example
```ts
import { emsaPssEncode } from "@suaveplan/crypto-rsa/encoding/emsa-pss-encode";
import { sha256 } from "@suaveplan/crypto-hash";
const message = new TextEncoder().encode("hello");
const mHash = sha256(message);
const salt = new Uint8Array(32); // would be random in production
const em = emsaPssEncode(mHash, 2047, salt, "SHA-256", "SHA-256");
console.log(em.length); // 256
console.log(em[em.length - 1]); // 0xbc
```
## Usage: Advanced Example
PSS allows the message hash and the MGF1 hash to differ. This is rare
but legal per RFC 8017 §9.1.1, and `rsaSignPss` exposes the choice
through its `mgfHash` option.
```ts
import { emsaPssEncode } from "@suaveplan/crypto-rsa/encoding/emsa-pss-encode";
import { sha256 } from "@suaveplan/crypto-hash";
const mHash = sha256(new TextEncoder().encode("audit-log-entry"));
const salt = crypto.getRandomValues(new Uint8Array(32));
// Mismatched hashes: SHA-256 for the message, SHA-1 for MGF1.
const em = emsaPssEncode(mHash, 2047, salt, "SHA-256", "SHA-1");
```
## API Reference
### `emsaPssEncode(mHash, emBits, salt, hash, mgfHash): Uint8Array`
- `mHash: Uint8Array` — pre-computed `Hash(message)`; length must equal `hashOutputLength(hash)`
- `emBits: number` — encoded-message bit length, typically `modulusBits - 1`
- `salt: Uint8Array` — caller-supplied salt, any non-negative length up to `emLen - hLen - 2`
- `hash: RsaHashAlgorithm` — message hash; also used to digest `M'`
- `mgfHash: RsaHashAlgorithm` — MGF1 hash for `dbMask`
- **returns** `Uint8Array` of length exactly `ceil(emBits / 8)`
- **throws** `CryptoRsaError(CRYPTO_RSA.INVALID_SIGNATURE)` when `mHash.length !== hashOutputLength(hash)`
- **throws** `CryptoRsaError(CRYPTO_RSA.INVALID_KEY)` when `emLen < hLen + sLen + 2` (modulus too short)
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` on unknown hash via the `mgf1` dispatcher
## Implementation Notes
- The `M'` construction prefixes 8 zero bytes before `mHash` and the
salt — this is the RFC-mandated domain separator that prevents
cross-protocol collisions with constructions that hash the message
alone.
- The most-significant `8 * emLen - emBits` bits of `maskedDB`'s
leftmost byte are cleared per RFC 8017 §9.1.1 step 11. For a 2048-bit
modulus, `emBits = 2047` and `8 * 256 - 2047 = 1`, so the top bit of
byte 0 is cleared. The verifier MUST accept only encodings where this
bit is zero, otherwise the encoding is rejected.
- The trailing byte is always `0xbc` — the PSS trailer field. The
verifier rejects any other value.
- This function does NOT generate randomness — the salt is supplied by
the caller. `rsaSignPss` is responsible for drawing the salt from a
CSPRNG (or using a caller-provided `Rng` from `@suaveplan/types`).

View File

@@ -0,0 +1,89 @@
# emsa-pss-verify
## Purpose
Implements the EMSA-PSS verification operation defined in RFC 8017
§9.1.2. Given the message digest, the candidate encoded message `EM`,
the encoded-message bit length, the expected salt length, and the
hash + MGF1 hash pair, this function returns whether `EM` is a valid
PSS encoding of the digest.
## Features
- RFC 8017 §9.1.2 conformant — full structure, padding, and trailer checks
- Returns `false` on every malformed input rather than throwing — the
signature scheme decides how to escalate
- Constant-time final compare via `constantTimeEqual` from `@suaveplan/crypto-utils`
- Validates leftmost-bit clearance (RFC 8017 §9.1.2 step 6)
- Validates the `0xbc` trailer
- Validates the all-zero PS region and the `0x01` separator
## Usage: Basic Example
```ts
import { emsaPssVerify } from "@suaveplan/crypto-rsa/encoding/emsa-pss-verify";
import { emsaPssEncode } from "@suaveplan/crypto-rsa/encoding/emsa-pss-encode";
import { sha256 } from "@suaveplan/crypto-hash";
const mHash = sha256(new TextEncoder().encode("hello"));
const salt = new Uint8Array(32);
const em = emsaPssEncode(mHash, 2047, salt, "SHA-256", "SHA-256");
console.log(emsaPssVerify(mHash, em, 2047, 32, "SHA-256", "SHA-256")); // true
```
## Usage: Advanced Example
A bit-flipped EM is rejected at one of several gates: the trailer check,
the leftmost-bit check, the PS region check, the separator check, or
the final salt+hash compare. All of these gates fail closed (return
`false`) without revealing which gate caught the divergence.
```ts
import { emsaPssVerify } from "@suaveplan/crypto-rsa/encoding/emsa-pss-verify";
import { emsaPssEncode } from "@suaveplan/crypto-rsa/encoding/emsa-pss-encode";
import { sha256 } from "@suaveplan/crypto-hash";
const mHash = sha256(new TextEncoder().encode("hello"));
const em = emsaPssEncode(
mHash,
2047,
new Uint8Array(32),
"SHA-256",
"SHA-256"
);
const tampered = new Uint8Array(em);
tampered[0] = tampered[0] ^ 0x01;
console.log(emsaPssVerify(mHash, tampered, 2047, 32, "SHA-256", "SHA-256"));
// false
```
## API Reference
### `emsaPssVerify(mHash, em, emBits, saltLength, hash, mgfHash): boolean`
- `mHash: Uint8Array` — `Hash(message)`; length must equal `hashOutputLength(hash)` (otherwise returns `false`)
- `em: Uint8Array` — candidate encoded message; length must equal `ceil(emBits / 8)` (otherwise returns `false`)
- `emBits: number` — encoded-message bit length, typically `modulusBits - 1`
- `saltLength: number` — expected salt length, must match the value used by `emsaPssEncode`
- `hash: RsaHashAlgorithm` — message hash; also used to digest `M'`
- `mgfHash: RsaHashAlgorithm` — MGF1 hash for `dbMask`
- **returns** `true` when `EM` is a valid PSS encoding of `mHash` under the triple, `false` otherwise
## Implementation Notes
- The function never throws on encoding-level failures — every
malformed input returns `false`. This matches the RFC 8017 §9.1.2
contract: the caller is responsible for distinguishing between
expected verify failures and protocol errors.
- The final `H == H'` comparison is constant-time. The earlier
structural checks (trailer byte, PS region, separator byte) short-
circuit on first failure — that is acceptable because the public
inputs determining those bytes are known to the verifier; only the
hash output carries the secret-derived signal that timing attacks
would target.
- The `leftmostBitsToClear` check (RFC 8017 §9.1.2 step 6) prevents a
trivial signature-malleability attack where an attacker flips bits
in the cleared region without being detected.
- Returning `false` on a length mismatch (`em.length !== emLen` or
`mHash.length !== hLen`) preserves the same constant-effort failure
shape as a structurally-valid mismatch.

View File

@@ -0,0 +1,84 @@
# mgf1
## Purpose
Implements the MGF1 mask-generation function defined in RFC 8017 §B.2.1.
MGF1 is the building block used by EMSA-PSS and RSAES-OAEP to expand a
short seed into an arbitrary-length pseudorandom mask. The same module
also exports the hash-dispatch helper (`digestWith`) and the per-hash
output-length lookup (`hashOutputLength`) shared by every encoding and
signature function in `@suaveplan/crypto-rsa`.
## Features
- RFC 8017 §B.2.1 conformant — counter-based hash chain over `seed || I2OSP(counter, 4)`
- Supports SHA-1, SHA-256, SHA-384, SHA-512 (the four `RsaHashAlgorithm` values)
- Routes SHA-2 through `@suaveplan/crypto-hash` (engine-pluggable)
- Routes SHA-1 through `@noble/hashes/sha1` directly (crypto-hash omits SHA-1)
- Enforces the `maskLen <= 2^32 * hLen` upper bound from RFC 8017 §B.2.1 step 1
- Throws `CRYPTO_RSA.MGF_FAILURE` on negative / non-integer `maskLen` or oversize `maskLen`
- Throws `CRYPTO_RSA.UNSUPPORTED_HASH` on unknown hash names via `digestWith` / `hashOutputLength`
## Usage: Basic Example
```ts
import { mgf1 } from "@suaveplan/crypto-rsa/encoding/mgf1";
const seed = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
const mask = mgf1(seed, 32, "SHA-256");
console.log(mask.length); // 32
```
## Usage: Advanced Example
The encoding modules call `mgf1` internally with a hash that is
typically — but not always — the same as the one used to digest the
message. EMSA-PSS allows distinct hash and MGF hash, OAEP does the same.
The MGF hash is independently configurable in both schemes.
```ts
import { mgf1 } from "@suaveplan/crypto-rsa/encoding/mgf1";
import { sha256 } from "@suaveplan/crypto-hash";
// Reproduce the dbMask used by EMSA-PSS-Encode for a 256-byte salted DB
const mHashSeed = sha256(new TextEncoder().encode("hello"));
const dbMask = mgf1(mHashSeed, 222, "SHA-1");
console.log(dbMask.length); // 222
```
## API Reference
### `mgf1(seed, maskLen, hash): Uint8Array`
- `seed: Uint8Array` — input seed, any non-zero length
- `maskLen: number` — desired output length in bytes; must be a non-negative integer ≤ `2^32 * hLen`
- `hash: RsaHashAlgorithm` — `"SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"`
- **returns** `Uint8Array` of length exactly `maskLen`
- **throws** `CryptoRsaError(CRYPTO_RSA.MGF_FAILURE)` on out-of-range `maskLen`
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` on unknown hash
### `digestWith(hash, data): Uint8Array`
Dispatches `data` to the appropriate hash implementation. Used internally
by `mgf1` and by every encoding / signature function in this package.
### `hashOutputLength(hash): number`
Returns the byte length of a digest produced by `hash`: 20, 32, 48, or 64.
## Implementation Notes
- The internal counter buffer is allocated once (`seed.length + 4` bytes)
and reused across the loop, avoiding per-iteration allocation.
- The block count is `ceil(maskLen / hLen)` and the final block is
truncated to the requested length so the result is always exactly
`maskLen` bytes.
- The `2^32 * hLen` bound is the conservative upper limit specified by
RFC 8017 (the actual MGF1 limit is `2^32`, but `2^32 * hLen` covers the
combined output bound and matches the implementation note in
RFC 8017 §B.2.1).
- SHA-1 routes through `@noble/hashes/sha1` because `@suaveplan/crypto-hash`
deliberately ships only SHA-2 in its public surface; SHA-1 is needed
here for legacy WebAuthn-TPM signature compatibility (RSASSA-PKCS1-v1\_5
with SHA-1) and for the rare PSS variant that uses SHA-1 as the MGF
hash with SHA-256 as the message hash.

View File

@@ -0,0 +1,64 @@
# `default-rng`
## Purpose
Single canonical adapter that lifts the `crypto.getRandomValues`
CSPRNG into the `@suaveplan/types` `Rng` interface every Stage 3
surface accepts. Centralising the conversion here keeps the
package's CSPRNG entry point auditable — exactly one callsite,
exactly one set of tests, and any future change (e.g. routing
through a `crypto-utils.defaultByteSource` override for hardware
RNGs) flows from here outward.
## Features
- Routes byte production through `defaultByteSource` from
`@suaveplan/crypto-utils` so override hooks installed at the
utils layer also affect every RSA surface.
- Implements `nextUint32`, `nextFloat`, and the optional
`nextBytes` fast path so blinding / OAEP padding always take
the cheap byte-extraction route.
- Pure factory — never caches; returns a fresh object on every
call. Cheap to allocate; safe to retain across operations.
## Usage: Basic Example
```ts
import { defaultRng } from "@suaveplan/crypto-rsa/encrypt/default-rng";
const rng = defaultRng();
console.log(rng.nextBytes?.(16));
```
## Usage: Advanced Example
Override the byte source repo-wide via `@suaveplan/crypto-utils`
without touching this module:
```ts
import { defaultRng } from "@suaveplan/crypto-rsa/encrypt/default-rng";
// Any hardware-RNG override installed at the crypto-utils layer
// also reaches every Stage 3 surface that calls defaultRng().
const rng = defaultRng();
```
## API Reference
### `defaultRng(): Rng`
Returns a fresh `Rng` instance backed by
`@suaveplan/crypto-utils.defaultByteSource`. Implements
`nextUint32`, `nextFloat`, and `nextBytes`.
## Implementation Notes
- The `nextUint32` path allocates 4 bytes per call. Callers that
need a tight loop should prefer `rng.nextBytes(n)` and slice
from there to avoid the per-call allocation overhead.
- `nextFloat` divides by `2^32` so the returned value is strictly
less than 1 even for the all-ones `nextUint32` draw — matches
the `Rng` interface contract from `@suaveplan/types`.
- Returning a fresh object per call keeps state isolated; two
callers receive independent RNG handles even though both wrap
the same global `crypto.getRandomValues` source.

View File

@@ -0,0 +1,91 @@
# `encrypt/oaep`
## Purpose
RSAES-OAEP encryption per RFC 8017 §7.1.1. The Optimal Asymmetric
Encryption Padding (OAEP) scheme is the modern default for
RSA public-key encryption — provably secure under the random-oracle
model when the underlying hash is collision-resistant, and
immune to the chosen-ciphertext attacks (Bleichenbacher, Manger)
that plague PKCS1-v1_5.
This module provides the encryption side; the matching decrypt
lives in `decrypt/oaep` and applies the Manger-safe combined
error path.
## Features
- RFC 8017 §7.1.1 reference algorithm with caller-overridable
hash (`SHA-256` default), MGF hash (defaults to the OAEP
hash), and optional label (defaults to empty).
- Routes hashing through `@suaveplan/crypto-hash` for SHA-256/384/512
and `@noble/hashes/sha1` for SHA-1; same dispatcher Stage 2's
`mgf1` uses, so OAEP and MGF1 stay in lock-step.
- Modulus-length policy gate runs first; sub-1024-bit keys
rejected outright.
- Plaintext oversize → `CRYPTO_RSA.INVALID_KEY` with `{ mLen, max }`
context for diagnosability.
- `crypto_rsa.encrypt_duration_ms{scheme="oaep",hash}` histogram.
## Usage: Basic Example
```ts
import { rsaEncryptOaep } from "@suaveplan/crypto-rsa/encrypt/oaep";
const ciphertext = rsaEncryptOaep(
publicKey,
new TextEncoder().encode("hello"),
{ hash: "SHA-256" }
);
```
## Usage: Advanced Example
OAEP with SHA-384 hashing and a non-empty label (mirrors a
common JWE `RSA-OAEP-384` deployment with a context-bound
label):
```ts
import { rsaEncryptOaep } from "@suaveplan/crypto-rsa/encrypt/oaep";
const label = new TextEncoder().encode("session-2026-05-11");
const ct = rsaEncryptOaep(publicKey, plaintext, {
hash: "SHA-384",
mgfHash: "SHA-384",
label,
allowLegacy: false,
});
```
## API Reference
### `rsaEncryptOaep(key, plaintext, options?): Uint8Array`
- `key: RsaPublicKey`
- `plaintext: Uint8Array` — at most `k - 2*hLen - 2` bytes.
- `options.hash?: RsaHashAlgorithm` — defaults to `"SHA-256"`.
- `options.mgfHash?: RsaHashAlgorithm` — defaults to `hash`.
- `options.label?: Uint8Array` — defaults to empty.
- `options.rng?: Rng` — defaults to `defaultRng()`.
- `options.allowLegacy?: boolean` — accept 1024-bit moduli.
- Throws `CRYPTO_RSA.INVALID_KEY` on plaintext oversize,
`CRYPTO_RSA.UNSUPPORTED_HASH` on unknown algorithm,
`CRYPTO_RSA.MGF_FAILURE` on absurd mask-length input,
modulus-length policy violations on bad key.
## Implementation Notes
- The seed is drawn from the injected `Rng` via the shared
`nextBytesViaRng` helper (same path as blinding) so seeded
test RNGs work transparently.
- We share the `mgf1` and `digestWith` implementations from
Stage 2's `src/encoding/mgf1.ts` rather than duplicating —
RFC 8017 §B.2.1 is short but having two copies invites drift.
- `db` is allocated once and built in place; XOR with the mask
produces a new buffer rather than mutating in place because
`xor` from `@suaveplan/crypto-utils` returns a fresh allocation
(length-mismatch guard).
- The OAEP encoding allocates `O(k)` — for 4096-bit keys this
is 512 bytes. Hot-loop callers (e.g. JWE bulk re-encryption)
should prefer keeping a single `RsaPublicKey` and amortising
the modpow cost over many ciphertexts.

View File

@@ -0,0 +1,90 @@
# `encrypt/pkcs1v15`
## Purpose
RSAES-PKCS1-v1_5 encryption per RFC 8017 §7.2.1 — the legacy
public-key encryption padding scheme historically used by
TLS 1.0–1.2 ClientKeyExchange and JWE `RSA1_5` algorithm. Modern
deployments should prefer OAEP (`@suaveplan/crypto-rsa/encrypt/oaep`)
because the matching decrypt path is vulnerable to
Bleichenbacher chosen-ciphertext attacks; encryption itself is
straightforward padding.
## Features
- Block-type-2 padding: `0x00 || 0x02 || PS || 0x00 || M` where
`PS` is `k - mLen - 3` non-zero random bytes (RFC 8017 §7.2.1
step 2 mandates non-zero).
- Modulus-length policy gate runs first — sub-1024-bit keys
rejected outright, 1024-bit accepted only with
`{ allowLegacy: true }`.
- Plaintext oversize → `CRYPTO_RSA.INVALID_KEY` with
`{ mLen, max }` context for diagnosability.
- Pluggable `Rng` for tests; defaults to `crypto.getRandomValues`
via `defaultRng()`.
- Observability — `crypto_rsa.encrypt_duration_ms{scheme="pkcs1v15"}`
histogram + `outcome_total{operation="encrypt"}` counter on
success, `errors_total{code}` on every typed-error throw.
## Usage: Basic Example
```ts
import { rsaEncryptPkcs1v15 } from "@suaveplan/crypto-rsa/encrypt/pkcs1v15";
import type { RsaPublicKey } from "@suaveplan/crypto-rsa";
declare const publicKey: RsaPublicKey; // ≥ 2048-bit
const ciphertext = rsaEncryptPkcs1v15(
publicKey,
new TextEncoder().encode("hello"),
{ allowLegacy: false }
);
// ciphertext.length === Math.ceil(publicKey.bitLength / 8)
```
## Usage: Advanced Example
Deterministic round-trip in a unit test using a seeded Rng:
```ts
import { rsaEncryptPkcs1v15 } from "@suaveplan/crypto-rsa/encrypt/pkcs1v15";
import { rsaDecryptPkcs1v15 } from "@suaveplan/crypto-rsa/decrypt/pkcs1v15";
let s = 1;
const seededRng = {
nextUint32: () => (s = (s * 1664525 + 1013904223) >>> 0),
nextFloat: () => Math.random(),
};
const ct = rsaEncryptPkcs1v15(publicKey, plaintext, {
rng: seededRng,
allowLegacy: true,
});
const pt = rsaDecryptPkcs1v15(privateKey, ct, { allowLegacy: true });
// pt deepEquals plaintext
```
## API Reference
### `rsaEncryptPkcs1v15(key, plaintext, options?): Uint8Array`
- `key: RsaPublicKey` — modulus must satisfy the policy gate.
- `plaintext: Uint8Array` — at most `k - 11` bytes where
`k = ceil(bitLength / 8)`.
- `options.rng?: Rng` — randomness source for the `PS` block.
- `options.allowLegacy?: boolean` — accept 1024-bit moduli.
- Returns the `k`-byte ciphertext.
- Throws `CRYPTO_RSA.INVALID_KEY` on plaintext oversize,
`MODULUS_TOO_SHORT` / `MODULUS_TOO_LARGE` on policy violations.
## Implementation Notes
- The non-zero `PS` is generated by drawing `2 * remaining`
bytes per loop iteration; the rejection rate is `1/256` per
byte so a single draw covers almost every case.
- The whole pad-and-modpow path is non-constant-time. PKCS1-v1_5
encryption is a public operation so timing leaks have no
security impact — all the danger lives on the decryption side.
- `byteLength(key)` derives from `bitLength` so partial-byte
moduli (e.g. 2049-bit) round up to the next whole byte.
- We do not blind during encryption — only the private operation
(decrypt / sign) leaks information through timing.

View File

@@ -0,0 +1,90 @@
# engine/noble
## Purpose
Default `RsaCryptoEngine` backed by the pure-JS noble primitives this
package ships natively. Provides the `name: "noble"` engine label and
delegates `sign` / `verify` / `encrypt` / `decrypt` / `modPow` to the
canonical `rsa*` functions, giving engine-aware consumers identical
semantics to direct callers.
## Features
- Implements every method on `RsaCryptoEngine`
- Routes through `rsaSignPkcs1v15`, `rsaSignPss`, `rsaVerifyPkcs1v15`,
`rsaVerifyPss`, `rsaEncryptPkcs1v15`, `rsaEncryptOaep`,
`rsaDecryptPkcs1v15`, `rsaDecryptOaep`
- Discriminated `RsaSchemeOptions` union routes per-scheme parameters
- Rejects scheme/operation mismatches (e.g. `sign({ scheme: "oaep" })`)
with `UNSUPPORTED_FORMAT`
- `modPow` exposed for advanced consumers (e.g. consumer-side
signature audit, key-pair sanity checks)
## Usage: Basic Example
```ts
import { nobleEngine } from "@suaveplan/crypto-rsa/engine";
const sig = await nobleEngine.sign(privateKey, message, {
scheme: "pkcs1v15",
hash: "SHA-256",
});
const ok = await nobleEngine.verify(publicKey, message, sig, {
scheme: "pkcs1v15",
hash: "SHA-256",
});
```
## Usage: Advanced Example
Use the engine surface to abstract over backend selection in higher-
level code:
```ts
import {
nobleEngine,
type RsaCryptoEngine,
} from "@suaveplan/crypto-rsa/engine";
async function signWithEngine(
engine: RsaCryptoEngine,
privateKey: RsaPrivateKey,
message: Uint8Array
): Promise<Uint8Array> {
return engine.sign(privateKey, message, {
scheme: "pss",
hash: "SHA-512",
saltLength: 64,
});
}
const sig = await signWithEngine(nobleEngine, key, msg);
```
## API Reference
### `nobleEngine: RsaCryptoEngine`
- `name: "noble"`
- `sign(privateKey, message, options)` — `RsaSchemeOptions` with
`scheme: "pkcs1v15" | "pss"`
- `verify(publicKey, message, signature, options)` — same scheme set
- `encrypt(publicKey, plaintext, options)` — `scheme: "pkcs1v15" | "oaep"`
- `decrypt(privateKey, ciphertext, options)` — same scheme set
- `modPow(base, exponent, modulus): bigint`
## Implementation Notes
- The engine is a stateless singleton — no construction overhead, no
per-call setup.
- Scheme/operation mismatches throw `UNSUPPORTED_FORMAT` synchronously
inside the async wrapper. Defensive by design — the discriminated
union forbids the mismatch at the type level, but runtime callers
can bypass the type system (`as never`).
- `modPow` is exposed to give advanced consumers a single uniform
modpow surface across engines. The implementation matches
`math/modpow` exactly (both delegate to
`@noble/curves/abstract/modular.pow`).
- Sign / verify route through the package's existing functions with
blinding enabled by default; the caller cannot disable blinding via
the engine surface.

View File

@@ -0,0 +1,76 @@
# engine/select
## Purpose
Resolve the default `RsaCryptoEngine` for the host runtime. When
`globalThis.crypto.subtle` exposes the RSA algorithms this package
needs, `subtleEngine` is returned for hardware-accelerated performance;
otherwise the pure-JS `nobleEngine` is used. Increments
`crypto_rsa.engine_selected_total{engine}` on every call so production
dashboards can track engine distribution per deployment.
## Features
- Auto-detects `subtle` availability at runtime
- `prefer: "noble"` forces the pure-JS engine even when subtle is
available (useful for deterministic-test scenarios)
- `prefer: "subtle"` forces subtle and throws `ENGINE_UNAVAILABLE` if
absent (caller commits to subtle, surfaces failure synchronously)
- Counter increments on every call regardless of branch taken
## Usage: Basic Example
```ts
import { defaultEngine } from "@suaveplan/crypto-rsa/engine";
const engine = defaultEngine();
const sig = await engine.sign(privateKey, message, {
scheme: "pkcs1v15",
hash: "SHA-256",
});
```
## Usage: Advanced Example
Force noble in test environments to make signature outputs reproducible
(subtle's RSA-PSS internally generates the salt and is non-determinstic;
noble's PSS path accepts a custom `Rng`):
```ts
import { defaultEngine } from "@suaveplan/crypto-rsa/engine";
const engine = defaultEngine({ prefer: "noble" });
// Reproducible PSS signatures.
```
## API Reference
### `defaultEngine(options?): RsaCryptoEngine`
- `options.prefer?: "noble" | "subtle"` — forces the engine choice
- **returns** `RsaCryptoEngine`
- **throws** `CryptoRsaError(CRYPTO_RSA.ENGINE_UNAVAILABLE)` when
`prefer: "subtle"` is set and `globalThis.crypto.subtle` is missing
### `DefaultEngineOptions`
- `prefer?: RsaEngineName`
### `resetEngineSelectionCounterForTesting()`
Test-only helper.
## Implementation Notes
- The selection rule is: if `prefer` is set, use it (subtle requested
but missing → throw); otherwise prefer subtle, fall back to noble.
The counter records the actual choice made, not the request.
- Detecting subtle availability is a single `globalThis.crypto.subtle`
read. Cached lookups would be incorrect — runtimes can polyfill
subtle mid-process (e.g. test setup adding a Web Crypto polyfill).
- The counter cardinality is bounded to two label values
(`noble`/`subtle`) so dashboards aggregate cleanly.
- This selector is the recommended default entry point for engine
consumers; calling `nobleEngine` / `subtleEngine` directly is
appropriate only when the caller has committed to a specific engine
for the lifetime of the operation.

View File

@@ -0,0 +1,96 @@
# engine/subtle
## Purpose
`RsaCryptoEngine` adapter over `globalThis.crypto.subtle`. When the
host runtime exposes WebCrypto, this engine routes RSA operations
through the platform-native (often hardware-accelerated) implementation
— faster than pure-JS modpow on every modern runtime (browsers, Node
≥ 16, Bun, Deno, Cloudflare Workers).
## Features
- Implements every method on `RsaCryptoEngine`
- Maps each scheme to the matching subtle algorithm (`RSASSA-PKCS1-v1_5`,
`RSA-PSS`, `RSA-OAEP`)
- Imports keys via JWK so the canonical `RsaPublicKey` / `RsaPrivateKey`
shape works without manual key marshalling
- `extractable: false` by default — the imported `CryptoKey` cannot be
exported back to raw bytes
- `extractable: true` opt-in increments `crypto_rsa.subtle_extractable_total`
so production dashboards can flag the unsafe path
- Throws `ENGINE_UNAVAILABLE` synchronously when subtle is missing —
caller can fall back to noble immediately
- Rejects `encrypt({ scheme: "pkcs1v15" })` and `decrypt(...)` —
WebCrypto deliberately removed RSA-PKCS1-v1\_5 encryption from the
standard (Bleichenbacher defence at the spec level)
## Usage: Basic Example
```ts
import { subtleEngine } from "@suaveplan/crypto-rsa/engine";
const eng = subtleEngine();
const sig = await eng.sign(privateKey, message, {
scheme: "pkcs1v15",
hash: "SHA-256",
});
const ok = await eng.verify(publicKey, message, sig, {
scheme: "pkcs1v15",
hash: "SHA-256",
});
```
## Usage: Advanced Example
For workflows that need `extractable: true` (e.g. exporting keys to
non-WebCrypto runtimes mid-test):
```ts
import { subtleEngine } from "@suaveplan/crypto-rsa/engine";
const eng = subtleEngine({ extractable: true });
// Note: every call increments crypto_rsa.subtle_extractable_total —
// production dashboards flag this as unsafe.
const sig = await eng.sign(privateKey, message, {
scheme: "pss",
hash: "SHA-512",
saltLength: 64,
});
```
## API Reference
### `subtleEngine(options?): RsaCryptoEngine`
- `options.extractable?: boolean` — defaults to `false`; setting `true`
imports keys with the `extractable` bit set and emits the
`crypto_rsa.subtle_extractable_total` counter
- **returns** `RsaCryptoEngine` with `name: "subtle"`
- **throws** `CryptoRsaError(CRYPTO_RSA.ENGINE_UNAVAILABLE)` when
`globalThis.crypto.subtle` is undefined
### `SubtleEngineOptions`
- `extractable?: boolean`
### `resetSubtleCountersForTesting()`
Test-only helper.
## Implementation Notes
- WebCrypto's `RSA-PSS` requires `saltLength` at sign/verify time; this
adapter defaults to `hashOutputBytes(hash)` (the RFC 8017 §8.1
recommendation) when the caller omits it.
- `RSA-OAEP` accepts an optional `label` (`OAEP-DSP` "P" parameter);
when omitted, subtle uses the empty label automatically.
- `RSASSA-PKCS1-v1_5` for encryption is deliberately rejected — the
WebCrypto spec removed it after the Bleichenbacher 1998 / 2001
attacks. Use OAEP for encryption with subtle; pure-JS PKCS1v15
encrypt remains available via the noble engine for legacy interop.
- `modPow` falls back to `math/modpow` (which routes through noble) —
WebCrypto exposes no standalone `modPow` primitive.
- `importKey` runs every call. For very-high-frequency callers, build
a key cache outside the engine; this adapter prioritises correctness
and statelessness over import-amortisation.

View File

@@ -0,0 +1,87 @@
# `errors`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the canonical error registry and concrete `CryptoRsaError` class for the entire
`@suaveplan/crypto-rsa` package. The 17-code surface is declared up-front so consumer
switches over `error.code` are exhaustive even before later stages of the package wire
up the production paths that raise the codes.
This module is a pure registry — no runtime behaviour beyond constructing errors. Every
public-API entry-point in `@suaveplan/crypto-rsa` ultimately throws a `CryptoRsaError`,
never a bare `Error`, so consumers get stable codes and i18n message keys.
## Features
- Frozen `CRYPTO_RSA_CODES` literal-typed map produced via `defineCodes("CRYPTO_RSA", …)`.
- Exhaustive 17-member `CryptoRsaCode` discriminated union covering every code Stages
1-4 of the proposal will raise.
- `CryptoRsaError` subclass extending `SuaveplanError<CryptoRsaCode>` so `instanceof`
narrows the code union for error handlers.
- Stable `code` strings (`CRYPTO_RSA.<REASON>`) safe to log, count, and switch on.
## Usage: Basic Example
```ts
import { CRYPTO_RSA_CODES, CryptoRsaError } from "@suaveplan/crypto-rsa/errors";
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key"
);
```
## Usage: Advanced Example
```ts
import { CRYPTO_RSA_CODES, CryptoRsaError } from "@suaveplan/crypto-rsa/errors";
try {
parseRsaPublicKey(bytes, { format: "pkcs1" });
} catch (error) {
if (error instanceof CryptoRsaError) {
switch (error.code) {
case CRYPTO_RSA_CODES.INVALID_KEY:
metrics.invalidKey.add(1);
break;
case CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT:
metrics.unsupportedFormat.add(1);
break;
default:
metrics.otherError.add(1, { code: error.code });
}
}
throw error;
}
```
## API Reference
### `CRYPTO_RSA_CODES`
Frozen object mapping each reason string (e.g. `"INVALID_KEY"`) to its fully-qualified
code (`"CRYPTO_RSA.INVALID_KEY"`). Indexable by reason key for type-safe access.
### `type CryptoRsaCode`
Union of every value in `CRYPTO_RSA_CODES`. Use as the discriminant on switch statements.
### `class CryptoRsaError extends SuaveplanError<CryptoRsaCode>`
Constructor accepts `(code, messageKey, options?)`. `options` follows
`SuaveplanErrorOptions` (`context`, `cause`, `traceId`, `clock`).
## Implementation Notes
The full registry is declared even though only five codes
(`MODULUS_TOO_SHORT`, `MODULUS_TOO_LARGE`, `INVALID_KEY`, `UNSUPPORTED_FORMAT`,
`PEM_MALFORMED`) are reachable from production code yet. The remaining 12 codes
(`INVALID_SIGNATURE`, `SIGNATURE_INVALID`, `UNSUPPORTED_HASH`, `DECRYPT_FAILED`,
`MGF_FAILURE`, `KEYGEN_FAILED`, `PRIME_INVALID`, `KEY_OP_NOT_PERMITTED`,
`PROFILE_VIOLATION`, `ENGINE_UNAVAILABLE`, `FINGERPRINT_FAILED`, `ABORTED`)
become reachable as Stages 2-4 land sign/verify/encrypt/decrypt/keygen/engine/profile
modules. Declaring them now keeps the public surface stable and prevents downstream
consumers from having to recompile their switch statements.

View File

@@ -0,0 +1,80 @@
# fingerprint/format
## Purpose
Render a fingerprint byte sequence as a human- and machine-readable
string. Provides four output formats covering every common operator
display: `hex` (lowercase, no separators), `base64` (standard alphabet
with `=` padding), `base64url` (URL-safe, no padding), and `colon-hex`
(lowercase hex with `:` between bytes — matches `ssh-keygen -lf` legacy
MD5 output and `gpg --fingerprint`).
## Features
- Four output formats covering every common ops convention
- Increments `crypto_rsa.fingerprint_total{format}` counter on every
call so dashboards can aggregate per-format usage
- Pairs with `computeSpkiFingerprint` for end-to-end fingerprinting
- Throws `CRYPTO_RSA.FINGERPRINT_FAILED` for unknown formats (defensive
guard for callers bypassing the typed union)
## Usage: Basic Example
```ts
import { formatFingerprint } from "@suaveplan/crypto-rsa/fingerprint";
const display = formatFingerprint(digest, "colon-hex");
// "ab:cd:12:34:..."
```
## Usage: Advanced Example
Switch format dynamically based on a runtime configuration option:
```ts
import {
computeSpkiFingerprint,
formatFingerprint,
type FingerprintFormat,
} from "@suaveplan/crypto-rsa/fingerprint";
function displayKey(
key: RsaPublicKey,
format: FingerprintFormat
): string {
const digest = computeSpkiFingerprint(key);
return formatFingerprint(digest, format);
}
```
## API Reference
### `formatFingerprint(bytes, format)`
- `bytes: Uint8Array`
- `format: "hex" | "base64" | "base64url" | "colon-hex"`
- **returns** `string`
- **throws** `CryptoRsaError(CRYPTO_RSA.FINGERPRINT_FAILED)` on unknown
format (defensive — TypeScript narrows the union; only reachable via
type-system bypass)
### `FingerprintFormat`
Union type of the four supported format names.
### `resetFingerprintCounterForTesting()`
Test-only helper. Production code never calls this.
## Implementation Notes
- The counter (`crypto_rsa.fingerprint_total{format}`) is incremented
unconditionally on every call. The format attribute is bounded to
the four-element union so cardinality stays low.
- `colon-hex` is implemented as `toHex(bytes)` plus a 2-character
re-grouping with `:` separators — the same convention used by
`ssh-keygen -lf` for MD5 fingerprints (pre-OpenSSH 6.8).
- The `default` arm of the format switch is reachable only when a
caller deliberately bypasses the type system — defensive guard
rather than load-bearing. Coverage is exercised by the test suite
via `as never` type-narrowing escape.

View File

@@ -0,0 +1,73 @@
# fingerprint/spki
## Purpose
Compute a deterministic fingerprint of an RSA public key by hashing
its SPKI DER encoding (RFC 5280 §4.1 `SubjectPublicKeyInfo`). The
resulting digest uniquely identifies the key independently of any PEM
armor, JWK packaging, or other transport framing — semantically
equivalent to the `ssh-keygen -lf` fingerprint of an OpenSSH-format
key.
## Features
- Hashes the canonical SPKI DER bytes (RFC 5280)
- Default SHA-256; SHA-1 / SHA-384 / SHA-512 supported
- Wraps SPKI serialiser failures as `FINGERPRINT_FAILED` so callers see
a single failure class regardless of underlying cause
- Pairs with `formatFingerprint` for human-readable output
## Usage: Basic Example
```ts
import { computeSpkiFingerprint } from "@suaveplan/crypto-rsa/fingerprint";
const digest = computeSpkiFingerprint(publicKey);
// 32 bytes (SHA-256)
```
## Usage: Advanced Example
Combine with `formatFingerprint` to render a colon-separated hex
string for ops dashboards and certificate-pinning workflows:
```ts
import {
computeSpkiFingerprint,
formatFingerprint,
} from "@suaveplan/crypto-rsa/fingerprint";
const digest = computeSpkiFingerprint(publicKey, { hash: "SHA-256" });
const display = formatFingerprint(digest, "colon-hex");
// "ab:12:cd:34:..."
```
## API Reference
### `computeSpkiFingerprint(publicKey, options?)`
- `publicKey: RsaPublicKey`
- `options.hash?: RsaHashAlgorithm` — defaults to `"SHA-256"`
- **returns** `Uint8Array` — digest bytes
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` on bad hash;
`CRYPTO_RSA.FINGERPRINT_FAILED` when SPKI serialisation throws (e.g.
malformed `n` / `e`).
### `SpkiFingerprintOptions`
- `hash?: RsaHashAlgorithm`
## Implementation Notes
- The fingerprint is computed over the SPKI DER bytes — NOT the PEM
armor. PEM-armored input produces the same fingerprint as the
underlying DER.
- SHA-256 is the default to match modern OpenSSH conventions
(`SHA256:base64url(...)` since OpenSSH 6.8 / 2015).
- SHA-1 is supported for legacy interop (Ed25519 SPKI fingerprints in
some older TLS pinning configs); production code should default to
SHA-256 or higher.
- Failure to serialise the SPKI bytes (e.g. negative `n`, malformed
`bitLength`) surfaces as `FINGERPRINT_FAILED` with the underlying
error preserved as `cause` — callers needing the original failure
class can introspect `error.cause`.

View File

@@ -0,0 +1,93 @@
# handle/registry
## Purpose
Process-wide registry for RSA key-handle providers. Every HSM, KMS, OS
keychain, or PKCS#11 bridge registers a provider once at startup; the
registry resolves opaque `RsaKeyHandle` values to the matching provider
at call time and dispatches `resolve` / `sign` / `decrypt` operations
through it. Decouples consumer code from any specific hardware
backend.
## Features
- Single in-process registry keyed by provider name
- Idempotent re-registration of the same provider value
- Conflicting registration of a different provider under the same name
rejects with `INVALID_KEY`
- `signWithHandle` / `decryptWithHandle` mirror the `RsaCryptoEngine`
surface so handle consumers and engine consumers share the same
scheme-options shape
- `unregisterKeyHandleProvider` exposed for test cleanup
## Usage: Basic Example
```ts
import {
registerKeyHandleProvider,
signWithHandle,
} from "@suaveplan/crypto-rsa/handle";
registerKeyHandleProvider(myHsmProvider);
const sig = await signWithHandle(
{ provider: "my-hsm", id: "key-1" },
message,
{ scheme: "pkcs1v15", hash: "SHA-256" }
);
```
## Usage: Advanced Example
For test setups that need a clean registry per test:
```ts
import {
registerKeyHandleProvider,
unregisterKeyHandleProvider,
SoftwareRsaKeyHandleProvider,
} from "@suaveplan/crypto-rsa/handle";
beforeEach(() => {
registerKeyHandleProvider(new SoftwareRsaKeyHandleProvider());
});
afterEach(() => {
unregisterKeyHandleProvider("software");
});
```
## API Reference
### `registerKeyHandleProvider(provider)`
- `provider: RsaKeyHandleProvider`
- **throws** `CryptoRsaError(CRYPTO_RSA.INVALID_KEY)` on conflicting
registration of a different provider under the same name
### `unregisterKeyHandleProvider(name)`
- `name: string`
### `resolveKeyHandle(handle): Promise<RsaPublicKey | RsaPrivateKey>`
- **throws** `CryptoRsaError(CRYPTO_RSA.INVALID_KEY)` when no provider
is registered under the handle's `provider` name
### `signWithHandle(handle, message, options): Promise<Uint8Array>`
### `decryptWithHandle(handle, ciphertext, options): Promise<Uint8Array>`
## Implementation Notes
- The registry is a module-level `Map<string, RsaKeyHandleProvider>`.
The chosen scope is process-lifetime; if cross-process or per-vault
isolation is required, callers must wrap their own dispatch layer.
- Idempotent registration of the same provider value (object identity
comparison) is a no-op so that import-once-then-register patterns
work without per-import side effects.
- Conflicting registration (two different provider objects with the
same name) raises a typed error instead of silently winning — this
is to detect copy-paste mistakes between adapters during integration.
- Handle resolution failures throw `INVALID_KEY` because from the
caller's perspective the supplied handle was not valid (the
underlying cause — provider unregistered, id unknown — is captured
in the error context).

View File

@@ -0,0 +1,78 @@
# handle/software
## Purpose
Reference `RsaKeyHandleProvider` wrapping in-memory `RsaPrivateKey`
values. Implements the same interface as a real HSM bridge but with
zero hardware dependency, so consumers can develop and test
handle-based code paths without an HSM. Production workloads typically
replace this with their own provider (PKCS#11, AWS KMS, Apple Keychain
via `@suaveplan/macos-keychain`, etc.).
## Features
- Implements every `RsaKeyHandleProvider` method
- Stores keys in a process-local `Map<string, RsaPrivateKey>` keyed by
handle id
- Sign / decrypt route through `nobleEngine` so semantics match the
rest of the package exactly
- `register(id, key)` returns the matching `RsaKeyHandle` for ergonomic
test setups
## Usage: Basic Example
```ts
import {
registerKeyHandleProvider,
signWithHandle,
} from "@suaveplan/crypto-rsa/handle";
import { SoftwareRsaKeyHandleProvider } from "@suaveplan/crypto-rsa/handle";
const provider = new SoftwareRsaKeyHandleProvider();
registerKeyHandleProvider(provider);
const handle = provider.register("test-key", privateKey);
const sig = await signWithHandle(handle, message, {
scheme: "pkcs1v15",
hash: "SHA-256",
});
```
## Usage: Advanced Example
Use the software provider to drive integration tests that exercise
the handle-based code path of a downstream consumer (e.g. a JWS signer
that accepts `RsaKeyHandle` instead of an in-memory key):
```ts
import { SoftwareRsaKeyHandleProvider } from "@suaveplan/crypto-rsa/handle";
import { registerKeyHandleProvider } from "@suaveplan/crypto-rsa/handle";
const provider = new SoftwareRsaKeyHandleProvider();
registerKeyHandleProvider(provider);
const handle = provider.register("k1", testKey);
// Downstream consumer uses the handle exactly as it would an HSM key.
const jws = await jwsSigner.sign(payload, handle);
```
## API Reference
### `class SoftwareRsaKeyHandleProvider`
- `name: "software"` (readonly)
- `register(id: string, key: RsaPrivateKey): RsaKeyHandle`
- `resolve(handle): Promise<RsaPrivateKey>`
- `sign(handle, message, options): Promise<Uint8Array>`
- `decrypt(handle, ciphertext, options): Promise<Uint8Array>`
## Implementation Notes
- The provider is class-based (vs the module-singleton style of
`nobleEngine`) so consumers can spin up multiple instances for
parallel-test isolation. Each instance has its own internal Map.
- Sign/decrypt delegate to `nobleEngine` directly — this provider is
a pure JS reference; HSM-backed providers replace these methods with
the appropriate `crypto.subtle` / PKCS#11 / KMS API calls.
- The "handle id missing" failure throws a plain `Error` (NOT a
`CryptoRsaError`) because the caller is the registry layer; the
registry maps it to a typed error before reaching application code.

View File

@@ -0,0 +1,59 @@
# jwk/thumbprint-uri
## Purpose
Compute the JWK Thumbprint URI per RFC 9278 §3 — the canonical
`urn:ietf:params:oauth:jwk-thumbprint:<hash-alg>:<base64url(thumbprint)>`
form used by OAuth 2.0 Demonstrating Proof-of-Possession (DPoP),
draft-ietf-oauth-dpop, and other OAuth/OIDC binding protocols. Wraps
`jwkThumbprint` with the hash-algorithm label and URN prefix.
## Features
- RFC 9278 §3 conformant URN format
- Default SHA-256 hash; SHA-1 / SHA-384 / SHA-512 supported
- Hash labels match RFC 9278 §3.1 registry: `sha-1`, `sha-256`, `sha-384`,
`sha-512`
- Returns a deterministic `string` ready to embed in OAuth tokens, DPoP
proofs, or `iss`/`sub` claims
## Usage: Basic Example
```ts
import { jwkThumbprintUri } from "@suaveplan/crypto-rsa/jwk";
const uri = jwkThumbprintUri(publicKey);
// "urn:ietf:params:oauth:jwk-thumbprint:sha-256:NzbLsXh8u..."
```
## Usage: Advanced Example
For protocols that pin a non-default hash (e.g. SHA-512 for
high-assurance bindings), pass the `hash` option:
```ts
import { jwkThumbprintUri } from "@suaveplan/crypto-rsa/jwk";
const sha512Uri = jwkThumbprintUri(publicKey, { hash: "SHA-512" });
// "urn:ietf:params:oauth:jwk-thumbprint:sha-512:..."
```
## API Reference
### `jwkThumbprintUri(key, options?)`
- `key: RsaPublicKey | RsaJwk` — public key or JWK
- `options.hash?: RsaHashAlgorithm` — defaults to `"SHA-256"`
- **returns** `string` — full URN
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` /
`CRYPTO_RSA.INVALID_KEY` (propagated from `jwkThumbprint`)
## Implementation Notes
- The hash-label mapping is a frozen lookup table mirroring the
IANA-registered names in RFC 9278 §3.1. Adding new RSA hash
algorithms (e.g. SHA-3) requires updating both this table and the
`RsaHashAlgorithm` union in `keys.types.ts`.
- This helper is a thin wrapper over `jwkThumbprint` + `toBase64Url`;
it adds no cryptographic complexity, only the URN framing. Tests
cover the framing for every supported hash.

View File

@@ -0,0 +1,84 @@
# jwk/thumbprint
## Purpose
Compute the JWK Thumbprint of an RSA public key per RFC 7638 §3.2. The
thumbprint is a deterministic hash over the canonical JSON form of the
public-key members (`e`, `kty`, `n` in alphabetical order with no
whitespace), giving every RSA key a stable identifier independent of
its wire format. Used downstream for `kid` derivation, key rotation
diffing, and key-pinning protocols.
## Features
- RFC 7638 §3.2 conformant — RSA member ordering `e`, `kty`, `n`
- Default SHA-256 hash; SHA-1 / SHA-384 / SHA-512 supported via `hash` option
- Accepts both canonical `RsaPublicKey` and `RsaJwk` shapes
- Hand-built canonical JSON byte string (does NOT use `JSON.stringify` —
member order is implementation-defined for that API)
- Worked-example matched against the RFC 7638 §3.1 reference value
`"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"`
- `jwkThumbprintBase64Url` convenience returns the URL-safe base64 encoding directly
- All thrown errors are `CryptoRsaError` with `INVALID_KEY` /
`UNSUPPORTED_HASH`
## Usage: Basic Example
```ts
import { jwkThumbprint } from "@suaveplan/crypto-rsa/jwk";
const fingerprint = jwkThumbprint(publicKey);
// 32 bytes (SHA-256 default)
```
## Usage: Advanced Example
The `kid` JWK member is conventionally set to the base64url-encoded
SHA-256 thumbprint. Combine `jwkThumbprintBase64Url` with
`serializeRsaPublicKeyJwk` to populate it on emit:
```ts
import {
jwkThumbprintBase64Url,
serializeRsaPublicKeyJwk,
} from "@suaveplan/crypto-rsa";
const baseJwk = serializeRsaPublicKeyJwk(publicKey);
const jwkWithKid = {
...baseJwk,
kid: jwkThumbprintBase64Url(publicKey),
};
```
## API Reference
### `jwkThumbprint(key, options?)`
- `key: RsaPublicKey | RsaJwk` — public key or JWK
- `options.hash?: RsaHashAlgorithm` — defaults to `"SHA-256"`
- **returns** `Uint8Array` — digest bytes
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` on bad hash;
`CRYPTO_RSA.INVALID_KEY` on missing `e` or `n`
### `jwkThumbprintBase64Url(key, options?)`
Convenience: returns `toBase64Url(jwkThumbprint(key, options))`.
### `JwkThumbprintOptions`
- `hash?: RsaHashAlgorithm`
## Implementation Notes
- The canonical JSON byte string is `'{"e":"<b64u(e)>","kty":"RSA","n":"<b64u(n)>"}'`.
Members are alphabetically ordered per RFC 7638 §3.2; only the three
required members participate in the digest.
- `JSON.stringify` is NOT used because member-order in object iteration
is implementation-defined for non-integer keys; constructing the byte
string by hand is the only correctness-stable approach.
- When the input is a `RsaPublicKey` (canonical bigint form), the
implementation routes through `serializeRsaPublicKeyJwk` to obtain
the base64url members, then concatenates the canonical JSON shape.
- Hash algorithm validation runs BEFORE member-validity checks so
consumers see `UNSUPPORTED_HASH` on bad hashes regardless of key
validity — useful for fail-fast configuration validators.

View File

@@ -0,0 +1,68 @@
# `keygen/exponent`
## Purpose
Public-exponent validation for RSA key generation. The `e`-only
constraints (odd, `> 1`, `< 2^256`) live here so they can be
asserted at the API boundary before the caller pays for prime
sampling. Coprimality with `λ(n)` happens later in
`generate.ts` once the primes are known.
## Features
- Single boundary check that maps every violation to
`CRYPTO_RSA.INVALID_KEY` with an `e` context for diagnosis.
- Auxiliary `gcd` helper exported so the higher-level keygen
loop can verify `gcd(e, λ(n)) === 1` without duplicating the
Euclidean algorithm.
- Conforms to NIST FIPS 186-5 §5.4 (`2^16 < e < 2^256`); this
module enforces the upper bound and `e > 1`. The lower bound
`e ≥ 2^16 + 1` is profile-specific (FIPS-186-5 mode) and lives
in the policy / profile layer.
## Usage: Basic Example
```ts
import { validatePublicExponent } from "@suaveplan/crypto-rsa/keygen/exponent";
validatePublicExponent(65537n); // ok
validatePublicExponent(3n); // ok — permissive profile
validatePublicExponent(4n); // throws CRYPTO_RSA.INVALID_KEY
```
## Usage: Advanced Example
Combine with `gcd` to cull candidate primes whose `(p-1)` shares
a factor with `e`:
```ts
import { gcd, validatePublicExponent } from "@suaveplan/crypto-rsa/keygen/exponent";
validatePublicExponent(65537n);
const candidate = 0x...n; // some probable prime
if (gcd(candidate - 1n, 65537n) !== 1n) {
// discard, sample again
}
```
## API Reference
### `validatePublicExponent(e: bigint): void`
Throws `CRYPTO_RSA.INVALID_KEY` on `e ≤ 1`, even `e`, or
`e ≥ 2^256`. Returns `void` on success.
### `gcd(a: bigint, b: bigint): bigint`
Iterative Euclidean algorithm; works on any bigint pair, signs
ignored.
## Implementation Notes
- The `gcd` implementation is the standard iterative Euclidean
loop rather than a recursive one to avoid V8 stack-frame
overhead on very large bigint pairs (`λ(n)` for a 4096-bit
modulus is roughly 4096 bits).
- The `2^256` upper bound is hard-coded as a literal because it
is a bright-line spec value; any future relaxation must
modify this module deliberately, not via a config flag.

View File

@@ -0,0 +1,112 @@
# `keygen/generate`
## Purpose
RSA key-pair generation per NIST FIPS 186-5 Appendix A.1 and
SP 800-56B Rev. 2 §6.3. Two-prime by default; multi-prime
(`primes ∈ {3, 4, 5}`) when callers explicitly opt in.
The algorithm samples each prime as a random odd `bits`-wide
integer with the top two bits forced to `1` (so the product
always reaches the target modulus length), checks coprimality
with `e`, runs the Miller-Rabin probable-prime test from the
sibling module, and assembles a canonical `RsaPrivateKey` with
all CRT fields populated.
## Features
- `generateRsaKeyPair(options)` returns a fully-formed
`RsaPrivateKey` ready for any encrypt / decrypt / sign / verify
surface in the package.
- `AbortSignal` plumbed through the prime-search loop — yields
via `await Promise.resolve()` every 64 candidates so an
aborted signal scheduled via `queueMicrotask` lands within
≤ 100 ms.
- Deterministic mode — identical seeded `Rng` produces
byte-identical keys. Used by tests and by reproducible-build
pipelines.
- Miller-Rabin exhaustion guard — caps each prime search at
100 000 candidates and surfaces `CRYPTO_RSA.KEYGEN_FAILED` on
hitting the cap (vanishingly rare in practice).
- Multi-prime delegation — `primes !== 2` routes to the sibling
`generateMultiPrimeKey` for the RFC 8017 §3 multi-prime path.
- `crypto_rsa.keygen_duration_ms{modulus_bits,primes}` histogram.
## Usage: Basic Example
```ts
import { generateRsaKeyPair } from "@suaveplan/crypto-rsa/keygen/generate";
const key = await generateRsaKeyPair({ modulusBits: 2048 });
// key has n, e, d, p, q, dp, dq, qInv all populated
```
## Usage: Advanced Example
Cancel a slow generation on a timeout via `AbortSignal`:
```ts
import { generateRsaKeyPair } from "@suaveplan/crypto-rsa/keygen/generate";
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 5000);
try {
const key = await generateRsaKeyPair({
modulusBits: 4096,
signal: ctrl.signal,
});
} catch (err) {
if (err.code === "CRYPTO_RSA.ABORTED") {
// surfaced from the next yield point in the prime search
}
}
```
Deterministic seeded generation for tests:
```ts
const seedRng = makeSeededRng(0xdeadbeef);
const k1 = await generateRsaKeyPair({
modulusBits: 1024,
rng: seedRng(),
});
const k2 = await generateRsaKeyPair({
modulusBits: 1024,
rng: seedRng(), // fresh from same seed
});
// k1 and k2 byte-identical
```
## API Reference
### `generateRsaKeyPair(options?): Promise<RsaPrivateKey>`
- `options.modulusBits?: number` — defaults to 3072.
- `options.publicExponent?: bigint` — defaults to `65537n`.
- `options.primes?: number` — defaults to 2; accepts 3–5.
- `options.rng?: Rng` — defaults to `defaultRng()`.
- `options.signal?: AbortSignal` — optional cancellation.
### `samplePrime(bits, e, rng, signal): Promise<bigint>`
Building block exposed for tests and for the multi-prime path.
Returns a probable prime with the top two bits set and coprime
to `e`. Throws `CRYPTO_RSA.KEYGEN_FAILED` after 100 000
candidates without success.
## Implementation Notes
- The top-two-bits-set rule guarantees that any two `bits`-wide
primes `p, q` produce a `p * q` of exactly `2 * bits` bits.
The post-multiplication length check is a paranoia guard that
fails loudly on a pathological RNG draw.
- The `bits` split on odd `modulusBits` is `(modulusBits + 1)/2`
for `p` and `modulusBits/2` for `q` so the top prime carries
the extra bit.
- `λ(n) = lcm(p-1, q-1)` per RFC 8017's modern derivation; this
produces a strictly smaller `d` than `φ(n) = (p-1)(q-1)`
while remaining cryptographically equivalent.
- `await Promise.resolve()` between candidate batches is the
cheapest yield point in V8 — it lets the microtask queue drain
(so a `queueMicrotask`-scheduled `signal.abort()` lands)
without paying for `setTimeout(0)`.

View File

@@ -0,0 +1,75 @@
# `keygen/miller-rabin`
## Purpose
Probable-prime test per NIST FIPS 186-5 Appendix B.3.1, used by
`generateRsaKeyPair` to qualify candidate primes during RSA key
generation. The default 64 rounds achieve ≥ 128-bit security
against composites for randomly-sampled candidates — the
prevailing recommendation for RSA key generation in 2026.
## Features
- Trial division by the first 53 odd primes (≤ 251) before any
modular exponentiation; eliminates ~80% of uniform random
candidates on average.
- Configurable round count — default 64. Lowering to 5 (e.g. for
fast unit tests) trades probabilistic certainty for speed.
- Uniform witness sampling via the injected `Rng` — works with
both CSPRNG-backed `defaultRng()` and seeded deterministic
test RNGs.
- `uniformBigint` exported as a reusable building block for any
caller that needs unbiased bigint sampling.
## Usage: Basic Example
```ts
import { isProbablePrime } from "@suaveplan/crypto-rsa/keygen/miller-rabin";
import { defaultRng } from "@suaveplan/crypto-rsa/encrypt/default-rng";
console.log(isProbablePrime(2147483647n, defaultRng())); // true (Mersenne)
console.log(isProbablePrime(561n, defaultRng())); // false (Carmichael)
```
## Usage: Advanced Example
Lower the round count for fast tests at the cost of negligible
false-positive risk on Carmichael-like inputs:
```ts
import { isProbablePrime } from "@suaveplan/crypto-rsa/keygen/miller-rabin";
const fast = (n: bigint, rng) => isProbablePrime(n, rng, 8);
```
## API Reference
### `isProbablePrime(n, rng, rounds = 64): boolean`
- `n: bigint` — candidate to test.
- `rng: Rng` — uniform witness source.
- `rounds: number` — default 64. Each round picks a fresh witness.
- Returns `true` if probably prime, `false` if definitely composite.
### `uniformBigint(min, maxInclusive, rng): bigint`
Returns a uniform bigint in `[min, maxInclusive]` via byte-wise
rejection sampling.
### `SMALL_PRIMES: ReadonlyArray<bigint>`
The first 53 odd primes through 251.
## Implementation Notes
- The Miller-Rabin witness loop short-circuits on the first
observed composite signature (non-trivial square root of 1)
rather than running through every round.
- The fallback modular-reduction path in `uniformBigint` only
triggers after 1024 rejection attempts — for any reasonable
range with the top-bit mask applied this never fires in
practice. Documented for completeness; biased by at most one
candidate's worth in the worst case.
- `SMALL_PRIMES` is a literal array rather than a `Set` because
the ordered scan benefits from monotonic comparison and the
set is small enough to iterate twice in cache time.

View File

@@ -0,0 +1,95 @@
# `keygen/multi-prime`
## Purpose
Multi-prime RSA key generation per RFC 8017 §3 and PKCS #1
Appendix A.1.2 `OtherPrimeInfos` extension. Reachable via the
public `generateRsaKeyPair({ primes: 3 | 4 | 5 })` entry point;
this module is the algorithmic core.
Multi-prime RSA trades a small reduction in security per bit (the
modulus must be slightly larger to retain the same factoring
hardness) for a substantial speedup of the private operation —
each prime factor is smaller, and CRT-accelerated decryption
runs `primes` exponentiations of `bitLength / primes`-sized
operands instead of two exponentiations of `bitLength / 2`.
## Features
- 3-, 4-, and 5-prime modes (PKCS #1 declares no upper bound but
this module caps at 5 because beyond that the security margin
per bit becomes uneconomical).
- Reuses the two-prime `samplePrime` helper for each factor — same
Miller-Rabin gate, same coprimality check, same `AbortSignal`
threading.
- Outputs a canonical `RsaPrivateKey` with the largest two primes
in the standard `(p, q, dp, dq, qInv)` slots and the rest in
the optional `otherPrimeInfos` field.
- Round-trips through PKCS #1 (`serializeRsaPrivateKeyPkcs1` /
`parseRsaPrivateKeyPkcs1`) — the serializer emits version 1
with the full `OtherPrimeInfos` SEQUENCE OF when
`otherPrimeInfos` is present.
## Usage: Basic Example
```ts
import { generateRsaKeyPair } from "@suaveplan/crypto-rsa/keygen/generate";
const key3 = await generateRsaKeyPair({
modulusBits: 2048,
primes: 3,
});
console.log(key3.otherPrimeInfos?.length); // 1
```
## Usage: Advanced Example
Round-trip a 3-prime key through PKCS #1 DER:
```ts
import {
generateRsaKeyPair,
parseRsaPrivateKeyPkcs1,
serializeRsaPrivateKeyPkcs1,
} from "@suaveplan/crypto-rsa";
const key = await generateRsaKeyPair({ modulusBits: 1536, primes: 3 });
const der = serializeRsaPrivateKeyPkcs1(key);
const restored = parseRsaPrivateKeyPkcs1(der);
console.log(restored.otherPrimeInfos?.length); // 1
```
## API Reference
### `generateMultiPrimeKey(options): Promise<RsaPrivateKey>`
Internal entry — public callers should use `generateRsaKeyPair`.
- `options.modulusBits: number`
- `options.publicExponent: bigint`
- `options.primes: number` — must be 3, 4, or 5.
- `options.rng: Rng`
- `options.signal: AbortSignal | undefined`
Throws `CRYPTO_RSA.PRIME_INVALID` on bad `primes`,
`CRYPTO_RSA.KEYGEN_FAILED` on outer-loop exhaustion (32 attempts
without producing a coprime modulus).
## Implementation Notes
- The `(p, q)` selection picks the two largest primes so the
standard CRT path operates on the bigger half of the modulus
— this matches OpenSSL's multi-prime convention and mirrors
what most PKCS #1 consumers expect when reading version 1
keys.
- The `otherPrimeInfos[i].t` coefficient is
`(r_1 * r_2 * ... * r_(i-1))^-1 mod r_i` per RFC 8017 §3 — we
maintain `runningProduct` rather than recomputing each step.
- Outer loop bails after `MAX_OUTER_ATTEMPTS = 32` attempts;
in practice every well-formed `(modulusBits, primes)` pair
succeeds on the first attempt because the small-prime trial
division in `isProbablePrime` already culls most non-coprime
candidates upstream.
- `lcm(a, b) = (a / gcd(a, b)) * b` — the reverse `(a * b) / gcd`
formulation overflows V8's bigint scratch for `λ(n)` with
4096-bit moduli; this rearrangement avoids it.

View File

@@ -0,0 +1,93 @@
# keys/jwk-policy
## Purpose
Cross-check a JWK's `key_ops`, `use`, and `alg` members against the
requested operation per RFC 7517 §4.2 / §4.3 / §4.4 + RFC 7518 §6.3.
Prevents the common operational mistake of using a JWK marked
`use: "sig"` for encryption (or vice-versa), which leaks both the key
purpose and the operator intent into the wrong code path.
## Features
- Validates `key_ops` ⊃ requested operation (with the standard
encrypt → wrapKey, decrypt → unwrapKey aliases)
- Validates `use === "sig"` permits only sign/verify and `use === "enc"`
permits only encrypt/decrypt
- Validates `alg` is one of the RSA-compatible JWA registry values
(`RS{256,384,512}`, `PS{256,384,512}`, `RSA-OAEP{,−256,−384,−512}`,
`RSA1_5`) AND is appropriate for the requested operation class
- Each violation throws `CRYPTO_RSA.KEY_OP_NOT_PERMITTED` with a
`reason` attribute (`"key_ops"` / `"use"` / `"alg"`) plus context
identifying the offending member
- Skips checks when the JWK omits the corresponding member (RFC 7517
semantics: absence ≠ denial)
## Usage: Basic Example
```ts
import { enforceJwkPolicy } from "@suaveplan/crypto-rsa/keys/jwk-policy";
const jwk = {
kty: "RSA",
n: "...",
e: "AQAB",
use: "sig",
alg: "RS256",
key_ops: ["sign"],
};
enforceJwkPolicy(jwk, "sign"); // no-op
enforceJwkPolicy(jwk, "encrypt"); // throws KEY_OP_NOT_PERMITTED
```
## Usage: Advanced Example
Wire policy enforcement into a higher-level signer that accepts both
canonical `RsaPrivateKey` and JWK inputs; only run the policy check on
the JWK path because canonical keys carry no policy fields:
```ts
import { enforceJwkPolicy, parseRsaPrivateKey } from "@suaveplan/crypto-rsa";
function loadSigningKey(input: RsaJwk | Uint8Array): RsaPrivateKey {
if (typeof input === "object" && "kty" in input) {
enforceJwkPolicy(input, "sign");
return parseRsaPrivateKey(input, { format: "jwk" });
}
return parseRsaPrivateKey(input, { format: "pkcs8" });
}
```
## API Reference
### `enforceJwkPolicy(jwk, operation): void`
- `jwk: RsaJwk`
- `operation: "sign" | "verify" | "encrypt" | "decrypt"`
- **throws** `CryptoRsaError(CRYPTO_RSA.KEY_OP_NOT_PERMITTED)` on any
cross-policy violation; the error context carries `reason` (which
member triggered the rejection) plus the member value
### `JwkOperation`
`"sign" | "verify" | "encrypt" | "decrypt"`.
## Implementation Notes
- The canonical `RsaPublicKey` / `RsaPrivateKey` shapes have no
`key_ops` / `use` / `alg` fields, so direct callers using the
canonical types bypass policy enforcement entirely. Wrap the policy
check at the JWK ingestion boundary if you need it; the package's
built-in sign / encrypt surfaces accept canonical keys precisely so
they don't impose JWK-specific semantics.
- The encrypt → wrapKey / decrypt → unwrapKey aliases follow RFC 7517
§4.3 (`key_ops` value definitions); a JWK marked `key_ops: ["wrapKey"]`
is permitted to encrypt because the cryptographic operation is the
same (RSA public encryption).
- When `use` and `alg` both appear they MUST be consistent (RFC 7517
§4.4); this implementation does not cross-check them against each
other because the JWA registry ties algorithm IDs to a single use,
so the per-member checks already cover the cross-product.
- `key_ops` and `use` together are RFC-permitted but discouraged
(RFC 7517 §4.3 last paragraph); both checks fire independently when
both are present.

View File

@@ -0,0 +1,53 @@
# `keys/oid`
## Purpose
Centralised OID constants for the RSA wire formats handled by this package.
Currently exports `OID_RSA_ENCRYPTION` (`1.2.840.113549.1.1.1`), the algorithm
identifier RFC 8017 uses for `rsaEncryption` inside `SubjectPublicKeyInfo`
and `PrivateKeyInfo` `AlgorithmIdentifier` slots. Future stages may extend
this file with the signature-OID family (`sha256WithRSAEncryption` etc.) once
the sign/verify modules need them; this Stage-1 file covers the universal
key-format OID alone.
## Features
- One definition per OID — eliminates copy-paste drift between parsers.
- String-typed OIDs (`"1.2.840.113549.1.1.1"`) match the dotted-decimal form
produced by `DerReader.readObjectIdentifier`.
## Usage: Basic Example
```ts
import { OID_RSA_ENCRYPTION } from "@suaveplan/crypto-rsa";
if (oid === OID_RSA_ENCRYPTION) {
// dispatch to RSA path
}
```
## Usage: Advanced Example
```ts
import { OID_RSA_ENCRYPTION } from "@suaveplan/crypto-rsa";
import { encodeNull, encodeObjectIdentifier, encodeSequence } from "@suaveplan/crypto-rsa/asn1";
const algorithmIdentifier = encodeSequence(
encodeObjectIdentifier(OID_RSA_ENCRYPTION),
encodeNull()
);
```
## API Reference
### `OID_RSA_ENCRYPTION: "1.2.840.113549.1.1.1"`
The PKCS #1 `rsaEncryption` algorithm identifier per RFC 3279 §2.3.1, RFC 5280
Appendix A, and RFC 8017 Appendix A.1.
## Implementation Notes
The constant is exposed as a string rather than a typed brand because
`DerReader.readObjectIdentifier` returns plain strings, and direct equality
checks remain trivially constant-time at the byte level. A branded type would
add complexity without security benefit at this layer.

View File

@@ -0,0 +1,80 @@
# `keys/parse-jwk`
## Purpose
Parses RSA JWKs per RFC 7517 §6.3.1 (public) and §6.3.2 (private). Accepts
the JWK as a plain JS object literal, as a JSON string, or as a `Uint8Array`
of UTF-8 JSON bytes — all common ways the format is carried in the wild
(headers vs. bodies vs. binary protocol fields).
JWK is the JOSE / OIDC / WebAuthn canonical RSA representation and the
required input format for `@suaveplan/jws`'s RSA algorithms (`RS256`,
`PS256`, etc.).
## Features
- Triple-form input (`RsaJwk` object / JSON string / UTF-8 bytes).
- Explicit `kty: "RSA"` enforcement (`UNSUPPORTED_FORMAT` otherwise).
- Base64url decode per RFC 7515 §2 (URL-safe alphabet, no padding) via
`@suaveplan/crypto-utils`.
- Private-form requires every CRT field (`d`, `p`, `q`, `dp`, `dq`, `qi`) —
partial private JWKs reject with `INVALID_KEY`.
## Usage: Basic Example
```ts
import { parseRsaPublicKeyJwk } from "@suaveplan/crypto-rsa";
const key = parseRsaPublicKeyJwk({
kty: "RSA",
n: "0vx7agoeb...",
e: "AQAB",
});
```
## Usage: Advanced Example
```ts
import {
parseRsaPrivateKeyJwk,
parseRsaPublicKeyJwk,
} from "@suaveplan/crypto-rsa";
const fromJson = parseRsaPublicKeyJwk(jsonString);
const fromBytes = parseRsaPublicKeyJwk(utf8Bytes);
const privateKey = parseRsaPrivateKeyJwk({
kty: "RSA",
n, e, d, p, q, dp, dq, qi,
});
```
## API Reference
### `parseRsaPublicKeyJwk(input: RsaJwk | string | Uint8Array): RsaPublicKey`
Decodes the JWK, validates `kty === "RSA"`, and decodes `n` + `e`.
### `parseRsaPrivateKeyJwk(input: RsaJwk | string | Uint8Array): RsaPrivateKey`
Same as above plus the CRT parameters. Every field is mandatory.
Both throw `CRYPTO_RSA.UNSUPPORTED_FORMAT` for non-`RSA` `kty`, and
`CRYPTO_RSA.INVALID_KEY` for missing members, empty values, or invalid
base64url.
## Implementation Notes
The JWK input may be an `RsaJwk` literal (already-typed), a JSON string
(common in headers), or a UTF-8 `Uint8Array` (common in network framing).
Internally we normalise to a plain object before extracting members so
the validation logic stays one path.
Base64url decoding goes through `@suaveplan/crypto-utils`'s `fromBase64Url`
which accepts both padded and unpadded inputs; JWA mandates unpadded, but
some implementations leak padding in the wild and we accept that gracefully.
The JWK private-key `qi` member is RFC 7517's name for what RFC 8017 calls
`qInv` (the CRT coefficient `q⁻¹ mod p`). The canonical `RsaPrivateKey`
shape uses `qInv` for consistency with RFC 8017, so we translate `qi → qInv`
in the parser.

View File

@@ -0,0 +1,66 @@
# `keys/parse-pkcs1-private`
## Purpose
Parses a DER-encoded `RSAPrivateKey` per RFC 8017 Appendix A.1.2 into the
canonical `RsaPrivateKey` shape with mandatory CRT parameters
(`p`, `q`, `dp`, `dq`, `qInv`). Also exports `finalisePrivateKey` so the
PKCS#8 and JWK parsers share the same field-positivity validation.
Multi-prime RSA (`version == 1` with non-empty `otherPrimeInfos`) is recognised
but explicitly rejected with `CRYPTO_RSA.UNSUPPORTED_FORMAT` — the wider
package contract assumes two-prime RSA throughout.
## Features
- Strict-DER `RSAPrivateKey` parser via `DerReader`.
- Version validation (`0` two-prime, `1` multi-prime — both detected; only
`0` accepted).
- Multi-prime detection via either `version === 1` OR a trailing SEQUENCE
(covers conformant and non-conformant emitters).
- Per-field positivity gate via `finalisePrivateKey`.
- Bit-length derivation from `n.toString(2).length`.
## Usage: Basic Example
```ts
import { parseRsaPrivateKeyPkcs1 } from "@suaveplan/crypto-rsa";
const key = parseRsaPrivateKeyPkcs1(derBytes);
// key.n, key.e, key.d, key.p, key.q, key.dp, key.dq, key.qInv, key.bitLength
```
## Usage: Advanced Example
```ts
import { finalisePrivateKey } from "@suaveplan/crypto-rsa/keys";
const key = finalisePrivateKey({ n, e, d, p, q, dp, dq, qInv });
```
## API Reference
### `parseRsaPrivateKeyPkcs1(bytes: Uint8Array): RsaPrivateKey`
Throws `CryptoRsaError(CRYPTO_RSA.INVALID_KEY)` on structural problems and
on any non-positive field. Throws `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_FORMAT)`
on multi-prime input.
### `finalisePrivateKey(inputs): RsaPrivateKey`
Validates each of `n, e, d, p, q, dp, dq, qInv` is `> 0n` and computes
`bitLength` from `n`. Surfaces the same `INVALID_KEY` error class as the
DER parser.
## Implementation Notes
The parser uses both `version === 1n` and a trailing-SEQUENCE peek to detect
multi-prime keys. Some emitters set `version` to `0` even when they include
`otherPrimeInfos`; we reject either flag to avoid silently truncating the
prime set.
The fields are read in fixed order with `seq.readInteger()` calls; if any
field is missing the underlying `DerReader` raises `INVALID_KEY` with a
truncation context. The final `seq.assertEnd()` ensures no extra fields
slip through (other than the multi-prime trailing SEQUENCE that we explicitly
detected and rejected).

View File

@@ -0,0 +1,60 @@
# `keys/parse-pkcs1-public`
## Purpose
Parses a DER-encoded `RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }`
per RFC 8017 Appendix A.1.1 into the canonical `RsaPublicKey` shape used throughout
`@suaveplan/crypto-rsa`. Also exports `finalisePublicKey(n, e)` so the SPKI and JWK
parsers can share the same `(n, e)` validation and `bitLength` computation without
re-running DER parsing.
The parser rejects `e < 3` and even public exponents at this layer because every
downstream operation (verify, encrypt) assumes those constraints; rejecting at parse
time means the operations never have to defensively re-check.
## Features
- Strict-DER `RSAPublicKey` parser via `DerReader`.
- Public-exponent validation: rejects `e < 3` and even `e`.
- Bit-length derivation from `n.toString(2).length` (no off-by-one risk because
the leading `1` of any positive bigint is always present).
- `finalisePublicKey(n, e)` exported for sibling parsers.
## Usage: Basic Example
```ts
import { parseRsaPublicKeyPkcs1 } from "@suaveplan/crypto-rsa";
const key = parseRsaPublicKeyPkcs1(derBytes);
// key.n, key.e, key.bitLength
```
## Usage: Advanced Example
```ts
import { finalisePublicKey } from "@suaveplan/crypto-rsa/keys";
// SPKI / JWK parsers share the validation logic.
const key = finalisePublicKey(n, e);
```
## API Reference
### `parseRsaPublicKeyPkcs1(bytes: Uint8Array): RsaPublicKey`
Throws `CryptoRsaError(CRYPTO_RSA.INVALID_KEY)` on any structural problem
(malformed DER, missing fields, trailing data, even or sub-3 exponent,
non-positive modulus).
### `finalisePublicKey(n: bigint, e: bigint): RsaPublicKey`
Validates `(n, e)` and computes `bitLength`. Surfaces the same `INVALID_KEY`
errors as `parseRsaPublicKeyPkcs1`.
## Implementation Notes
`bitLength` derives from the binary representation of `n`. For a 2048-bit
key the modulus has bit length exactly 2048; the leading bit of `n` is
always `1` because the underlying primes are sampled with the high bit
fixed. Consumers that need strict round-number assertions should compare
against the canonical {2048, 3072, 4096} set in their own gate.

View File

@@ -0,0 +1,55 @@
# `keys/parse-pkcs8`
## Purpose
Parses a PKCS#8 `PrivateKeyInfo` per RFC 5208 §5 — also accepts the
RFC 5958 `OneAsymmetricKey` shape, which adds optional `[0] attributes`
and `[1] publicKey` trailing fields. The inner `privateKey OCTET STRING`
carries a PKCS#1 `RSAPrivateKey` which is delegated to the PKCS#1 parser.
PKCS#8 is the canonical wire format for any private key persisted to
disk (OpenSSL `PRIVATE KEY` PEM, PKCS#12 / PFX `EncryptedData` payloads
after decryption, JWE `kty: RSA` private-form decryption keys).
## Features
- DER outer SEQUENCE → version → AlgorithmIdentifier → OCTET STRING walk.
- Algorithm OID equality against `OID_RSA_ENCRYPTION`.
- RFC 5958 `[0] attributes` and `[1] publicKey` tolerant — both consumed
and discarded.
- Delegates to `parseRsaPrivateKeyPkcs1` for the inner RSAPrivateKey body.
## Usage: Basic Example
```ts
import { parseRsaPrivateKeyPkcs8 } from "@suaveplan/crypto-rsa";
const key = parseRsaPrivateKeyPkcs8(pkcs8DerBytes);
```
## Usage: Advanced Example
```ts
import { parseRsaPrivateKeyPkcs8 } from "@suaveplan/crypto-rsa";
// RFC 5958 OneAsymmetricKey form with optional trailing publicKey [1].
const key = parseRsaPrivateKeyPkcs8(oneAsymmetricKeyBytes);
```
## API Reference
### `parseRsaPrivateKeyPkcs8(bytes: Uint8Array): RsaPrivateKey`
Throws `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_FORMAT)` for non-RSA OIDs.
Throws `CryptoRsaError(CRYPTO_RSA.INVALID_KEY)` on structural problems
(unsupported `version`, missing fields, trailing non-context-specific bytes).
## Implementation Notes
PKCS#8 `version` is `0` for RFC 5208 and `1` for the RFC 5958 successor
(the version bumps when `publicKey` is present). Both are accepted; the
RFC 5958 trailing fields are walked through as opaque context-specific
TLVs without attempting to parse them, because Stage 1 has no need for
the public-key cross-check or the attribute set. Stage 4 may add a
verification that the `[1] publicKey` matches the inner PKCS#1
`(n, e)`.

View File

@@ -0,0 +1,61 @@
# `keys/parse-spki`
## Purpose
Parses an X.509 `SubjectPublicKeyInfo` per RFC 5280 §4.1 + Appendix A and
unwraps the inner PKCS#1 `RSAPublicKey`. SPKI is the canonical wire format
for RSA public keys in TLS certificates (`Certificate.tbsCertificate.subjectPublicKeyInfo`),
JWK headers (`x5c`/`x5t#S256` reference chains), and the WebAuthn TPM
attestation statement.
The parser enforces the `1.2.840.113549.1.1.1` `rsaEncryption` algorithm OID
and the canonical `NULL` algorithm parameters per RFC 3279 §2.3.1.
## Features
- DER outer-SEQUENCE → AlgorithmIdentifier → BIT STRING walk.
- Algorithm OID equality check against `OID_RSA_ENCRYPTION`.
- `NULL` algorithm-parameters enforcement (RFC 3279 §2.3.1).
- Delegates inner PKCS#1 parse to `parseRsaPublicKeyPkcs1`.
## Usage: Basic Example
```ts
import { parseRsaPublicKeySpki } from "@suaveplan/crypto-rsa";
const key = parseRsaPublicKeySpki(spkiDerBytes);
```
## Usage: Advanced Example
```ts
import { parseRsaPublicKeySpki } from "@suaveplan/crypto-rsa";
// SPKI carrying an EC OID rejects with UNSUPPORTED_FORMAT.
try {
parseRsaPublicKeySpki(ecSpkiBytes);
} catch (error) {
if (error instanceof CryptoRsaError && error.code === "CRYPTO_RSA.UNSUPPORTED_FORMAT") {
// dispatch to @suaveplan/crypto-curve
}
}
```
## API Reference
### `parseRsaPublicKeySpki(bytes: Uint8Array): RsaPublicKey`
Throws `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_FORMAT)` for non-RSA OIDs.
Throws `CryptoRsaError(CRYPTO_RSA.INVALID_KEY)` on structural problems
(missing fields, trailing data, unused-bits ≠ 0 in the `BIT STRING`).
## Implementation Notes
The `BIT STRING` payload is copied (defensive copy in `DerReader.readBitString`)
so the inner PKCS#1 reader never aliases the input buffer. This costs one
allocation per parse but eliminates a class of subtle bugs where downstream
mutation of the inner buffer would corrupt the SPKI input.
`alg.assertEnd()` and `outer.assertEnd()` together guarantee no extra fields
slipped through. RFC 3279 forbids extra parameters in the `rsaEncryption`
algorithm identifier, and RFC 5280 forbids extra fields in `SubjectPublicKeyInfo`.

View File

@@ -0,0 +1,62 @@
# `keys/parse`
## Purpose
Public dispatcher entry-point unifying the four key-format parsers
(PKCS#1, SPKI, PKCS#8, JWK) under one ergonomic call. Selects the
underlying parser via a `format` discriminator on the options object,
matching the round-trip pattern used elsewhere in the crypto
family.
## Features
- `parseRsaPublicKey(input, { format: "pkcs1" | "spki" | "jwk" })`.
- `parseRsaPrivateKey(input, { format: "pkcs1" | "pkcs8" | "jwk" })`.
- Binary formats accept `Uint8Array`; JWK accepts object / JSON string /
UTF-8 bytes.
- `default` branches surface `CRYPTO_RSA.UNSUPPORTED_FORMAT` so any future
format added to the type union without a switch arm fails loudly.
## Usage: Basic Example
```ts
import { parseRsaPublicKey } from "@suaveplan/crypto-rsa";
const key = parseRsaPublicKey(spkiBytes, { format: "spki" });
```
## Usage: Advanced Example
```ts
import {
parseRsaPrivateKey,
parseRsaPublicKey,
} from "@suaveplan/crypto-rsa";
const fromPkcs8 = parseRsaPrivateKey(pkcs8Bytes, { format: "pkcs8" });
const fromJwk = parseRsaPrivateKey(jwkObject, { format: "jwk" });
const fromJsonJwk = parseRsaPrivateKey(jwkJsonString, { format: "jwk" });
```
## API Reference
### `parseRsaPublicKey(input, options): RsaPublicKey`
Throws `CRYPTO_RSA.INVALID_KEY` when a non-`Uint8Array` is passed for the
binary formats.
### `parseRsaPrivateKey(input, options): RsaPrivateKey`
Same input/error contract on the private side.
## Implementation Notes
The dispatcher is exhaustive over the literal union via `assertNeverFormat`,
which TypeScript verifies statically. Adding a new format requires both a
new type member and a new switch arm — the compiler enforces both. This is
the only safe way to evolve a discriminated dispatcher without silently
falling through to a default branch.
The `requireBytes` helper is shared between the PKCS#1/SPKI/PKCS#8 paths
because they all consume `Uint8Array`. JWK input is forwarded through the
JWK parser which handles its own normalisation.

View File

@@ -0,0 +1,54 @@
# `keys/serialize-jwk`
## Purpose
Inverse of `parseRsaPublicKeyJwk` and `parseRsaPrivateKeyJwk`. Emits an
`RsaJwk` with every numeric field encoded as base64url per RFC 7515 §2
(URL-safe alphabet, no padding) over the minimum-length big-endian
representation of each `bigint` per RFC 7518 §6.3.
## Features
- Per-field base64url encoding via `@suaveplan/crypto-utils`'s `toBase64Url`.
- Minimum-length big-endian byte derivation — no leading-zero padding.
- Public-form emits `{ kty, n, e }` only; private-form adds the full CRT set.
- Maps the canonical `qInv` field name to JWA's `qi` per RFC 7517 §6.3.2.
## Usage: Basic Example
```ts
import { serializeRsaPublicKeyJwk } from "@suaveplan/crypto-rsa";
const jwk = serializeRsaPublicKeyJwk(key);
// { kty: "RSA", n: "...", e: "AQAB" }
```
## Usage: Advanced Example
```ts
import {
parseRsaPrivateKeyJwk,
serializeRsaPrivateKeyJwk,
} from "@suaveplan/crypto-rsa";
const jwk = serializeRsaPrivateKeyJwk(key);
const roundTripped = parseRsaPrivateKeyJwk(jwk);
```
## API Reference
### `serializeRsaPublicKeyJwk(key: RsaPublicKey): RsaJwk`
Returns `{ kty: "RSA", n, e }`.
### `serializeRsaPrivateKeyJwk(key: RsaPrivateKey): RsaJwk`
Returns `{ kty: "RSA", n, e, d, p, q, dp, dq, qi }`.
## Implementation Notes
The minimum-length encoding implies that `e === 65537n` (the universal
default RSA exponent) emits as the 3-byte sequence `0x010001`, which
base64url-encodes to `"AQAB"` — the same canonical string every JWK
ecosystem produces. Encoding via i2osp with the byte length derived from
`hex.length / 2` ensures we never accidentally emit a leading-zero byte.

View File

@@ -0,0 +1,52 @@
# `keys/serialize-pkcs1-private`
## Purpose
Inverse of `parseRsaPrivateKeyPkcs1`. Emits a DER `RSAPrivateKey` SEQUENCE
per RFC 8017 Appendix A.1.2 from a canonical `RsaPrivateKey`. Always emits
two-prime form with `version 0`. Multi-prime keys cannot be constructed
through this package's parsers (the parser rejects them) so the serialiser
also never has to emit the multi-prime branch.
## Features
- Strict-DER nine-INTEGER SEQUENCE emission in canonical field order.
- Always emits `version 0` (two-prime).
- Pure function — no validation overhead.
## Usage: Basic Example
```ts
import { serializeRsaPrivateKeyPkcs1 } from "@suaveplan/crypto-rsa";
const der = serializeRsaPrivateKeyPkcs1(key);
```
## Usage: Advanced Example
```ts
import {
parseRsaPrivateKeyPkcs1,
serializeRsaPrivateKeyPkcs1,
} from "@suaveplan/crypto-rsa";
const roundTripped = parseRsaPrivateKeyPkcs1(
serializeRsaPrivateKeyPkcs1(key)
);
```
## API Reference
### `serializeRsaPrivateKeyPkcs1(key: RsaPrivateKey): Uint8Array`
Returns the canonical DER encoding.
## Implementation Notes
The field order matches RFC 8017 Appendix A.1.2 exactly: `version`,
`modulus`, `publicExponent`, `privateExponent`, `prime1`, `prime2`,
`exponent1`, `exponent2`, `coefficient`. Any reorder would still parse
under a tolerant reader but would fail strict-DER validation.
`version` is encoded as `0n` rather than `0` (a `bigint`) because
`encodeInteger` is monomorphised over `bigint` for the two-prime case.

View File

@@ -0,0 +1,53 @@
# `keys/serialize-pkcs1-public`
## Purpose
Inverse of `parseRsaPublicKeyPkcs1`. Emits a DER `RSAPublicKey` SEQUENCE
per RFC 8017 Appendix A.1.1 from a canonical `RsaPublicKey`. The output
is byte-identical for any input that previously round-tripped through
the parser, and is the canonical body wrapped by SPKI public-key
emission and PEM `-----BEGIN RSA PUBLIC KEY-----` framing.
## Features
- Single-call DER emission via `encodeSequence(encodeInteger, encodeInteger)`.
- Strict-DER output (minimal length, minimal integer encoding).
- Pure function — no allocation beyond the byte buffers.
## Usage: Basic Example
```ts
import { serializeRsaPublicKeyPkcs1 } from "@suaveplan/crypto-rsa";
const der = serializeRsaPublicKeyPkcs1(key);
```
## Usage: Advanced Example
```ts
import {
parseRsaPublicKeyPkcs1,
serializeRsaPublicKeyPkcs1,
} from "@suaveplan/crypto-rsa";
// Round-trip invariant.
const roundTripped = parseRsaPublicKeyPkcs1(
serializeRsaPublicKeyPkcs1(key)
);
// roundTripped equals key.
```
## API Reference
### `serializeRsaPublicKeyPkcs1(key: RsaPublicKey): Uint8Array`
Returns the canonical DER encoding. Never throws on a structurally valid
`RsaPublicKey`.
## Implementation Notes
The serialiser does not re-validate `(n, e)`; that is the parser's job.
Callers that reach this path have either constructed the key locally
(in which case validation is the constructor's job) or routed through
the parser (already validated). Adding a redundant check here would
double the cost without adding any safety guarantee.

View File

@@ -0,0 +1,49 @@
# `keys/serialize-pkcs8`
## Purpose
Inverse of `parseRsaPrivateKeyPkcs8`. Wraps a serialised PKCS#1
`RSAPrivateKey` in a DER `PrivateKeyInfo` per RFC 5208 §5, emitting the
canonical `version 0` form (RFC 5958 `version 1` requires the optional
`publicKey [1]` field which Stage 1 does not emit).
## Features
- Standard PKCS#8 emission with `version 0`.
- Reuses `serializeRsaPrivateKeyPkcs1` so the inner OCTET STRING body
matches the PKCS#1 output bit-for-bit.
- RFC 3279 §2.3.1 conformant `NULL` algorithm parameters.
## Usage: Basic Example
```ts
import { serializeRsaPrivateKeyPkcs8 } from "@suaveplan/crypto-rsa";
const pkcs8 = serializeRsaPrivateKeyPkcs8(key);
```
## Usage: Advanced Example
```ts
import {
parseRsaPrivateKeyPkcs8,
serializeRsaPrivateKeyPkcs8,
} from "@suaveplan/crypto-rsa";
const roundTripped = parseRsaPrivateKeyPkcs8(
serializeRsaPrivateKeyPkcs8(key)
);
```
## API Reference
### `serializeRsaPrivateKeyPkcs8(key: RsaPrivateKey): Uint8Array`
Returns the DER-encoded `PrivateKeyInfo`.
## Implementation Notes
We emit `version 0` (RFC 5208) rather than `version 1` (RFC 5958) because
Stage 1 does not have a public-key derivation pipeline yet. Stage 4 may
add a `version 1` emission path that includes the `[1] publicKey` field
for ecosystems (cosign, sigstore) that prefer the OneAsymmetricKey form.

View File

@@ -0,0 +1,47 @@
# `keys/serialize-spki`
## Purpose
Inverse of `parseRsaPublicKeySpki`. Wraps a serialised PKCS#1 `RSAPublicKey`
in a DER `SubjectPublicKeyInfo` per RFC 5280 §4.1, with the canonical
`AlgorithmIdentifier { algorithm = 1.2.840.113549.1.1.1, parameters = NULL }`
inner SEQUENCE.
## Features
- Standard X.509 SPKI emission.
- RFC 3279 §2.3.1 conformant `NULL` algorithm parameters.
- Reuses `serializeRsaPublicKeyPkcs1` so the inner body matches the PKCS#1
output bit-for-bit.
## Usage: Basic Example
```ts
import { serializeRsaPublicKeySpki } from "@suaveplan/crypto-rsa";
const spki = serializeRsaPublicKeySpki(key);
```
## Usage: Advanced Example
```ts
import { serializeRsaPublicKeySpki } from "@suaveplan/crypto-rsa";
// Compute the SPKI fingerprint downstream consumers expect for cert pinning.
const spki = serializeRsaPublicKeySpki(key);
const fingerprint = await crypto.subtle.digest("SHA-256", spki);
```
## API Reference
### `serializeRsaPublicKeySpki(key: RsaPublicKey): Uint8Array`
Returns the DER-encoded `SubjectPublicKeyInfo`.
## Implementation Notes
The output is the canonical RFC 5280 SPKI; many PKI libraries accept variants
with implicit-NULL parameters (omitted entirely) but emit the explicit-NULL
form, so we follow the de-facto majority. The PKCS#1 body inside the
`BIT STRING` is wrapped with one leading `0x00` "unused bits" byte per
RFC 5280 §4.1 (handled by `encodeBitString`).

View File

@@ -0,0 +1,52 @@
# `keys/serialize`
## Purpose
Public dispatcher entry-point inverse of `parse`. Selects the per-format
serialiser via the same `format` discriminator. Returns `Uint8Array` for
binary formats (`pkcs1`, `spki`, `pkcs8`) and a structured `RsaJwk` object
for JWK so consumers can post-process the result without re-parsing JSON.
## Features
- `serializeRsaPublicKey(key, { format })` over the PKCS#1 / SPKI / JWK set.
- `serializeRsaPrivateKey(key, { format })` over the PKCS#1 / PKCS#8 / JWK set.
- `default` branch surfaces `CRYPTO_RSA.UNSUPPORTED_FORMAT` for safety.
## Usage: Basic Example
```ts
import { serializeRsaPublicKey } from "@suaveplan/crypto-rsa";
const der = serializeRsaPublicKey(key, { format: "spki" });
```
## Usage: Advanced Example
```ts
import {
serializeRsaPrivateKey,
serializeRsaPublicKey,
} from "@suaveplan/crypto-rsa";
const jwk = serializeRsaPublicKey(key, { format: "jwk" });
const pkcs8 = serializeRsaPrivateKey(key, { format: "pkcs8" });
```
## API Reference
### `serializeRsaPublicKey(key, options): Uint8Array | RsaJwk`
Returns bytes for `pkcs1` / `spki`, a structured `RsaJwk` for `jwk`.
### `serializeRsaPrivateKey(key, options): Uint8Array | RsaJwk`
Returns bytes for `pkcs1` / `pkcs8`, a structured `RsaJwk` for `jwk`.
## Implementation Notes
The mixed `Uint8Array | RsaJwk` return type intentionally pushes the
discrimination back to the caller — picking `format: "jwk"` is a deliberate
choice and the caller already knows the result will be an object. Type
narrowing on the `format` literal is left to consumer-side discriminated
union helpers because Stage 1 deliberately keeps the dispatcher minimal.

View File

@@ -0,0 +1,68 @@
# `math/crt`
## Purpose
RSA private-key operation via the Chinese Remainder Theorem (RFC 8017 §5.1.2,
second-form algorithm). Computes `m = c^d mod n` using the CRT parameters
`p, q, dp, dq, qInv` already stored on the canonical `RsaPrivateKey` shape, so
the working modulus during exponentiation is half the size of `n`.
Empirically the CRT path is 3-4× faster than the naive `c^d mod n` route on
2048-bit keys; the speed-up grows quadratically with modulus size. Stage 1
ships the algorithm; Stages 2-3 wire it into `rsaSign*` and `rsaDecrypt*`
once MGF1 + EMSA encoding modules land.
## Features
- Pure-function implementation — no allocation beyond `bigint` intermediates.
- Reuses `modPow` so the banned-pattern grep gate stays clean.
- Negative-h fix-up (`h += p` when negative) is constant-form in TypeScript so
the JIT does not branch-predict away the canonicalisation step.
## Usage: Basic Example
```ts
import { rsaPrivateOperationCrt } from "@suaveplan/crypto-rsa/math";
const m = rsaPrivateOperationCrt(c, privateKey);
```
## Usage: Advanced Example
```ts
import {
i2osp,
os2ip,
rsaPrivateOperationCrt,
} from "@suaveplan/crypto-rsa/math";
function decryptCiphertextBytes(
ciphertext: Uint8Array,
key: RsaPrivateKey
): Uint8Array {
const c = os2ip(ciphertext);
const m = rsaPrivateOperationCrt(c, key);
return i2osp(m, ciphertext.length);
}
```
## API Reference
### `rsaPrivateOperationCrt(c: bigint, key: RsaPrivateKey): bigint`
Returns the message representative `m ∈ [0, n)` such that `m^e mod n === c`.
Requires `c < n` (caller's responsibility — the CRT formulation does not
verify the bound).
## Implementation Notes
The second-form algorithm (RFC 8017 §5.1.2) is preferred over the first-form
because it never materialises any value larger than `p` or `q` during the hot
loop, keeping memory pressure halved. The final reconstruction `m = m2 + h*q`
returns a value in `[0, n)` provided `c < n`; with `c >= n` the result is
mathematically valid but semantically meaningless because RSA encrypted/signed
representatives are always reduced mod `n`.
Negative `h` arises when `m1 < m2`. The `if (h < 0n) h += p` step canonicalises
`h` into `[0, p)` before the multiplication so the final `m2 + h*q` is
unambiguously non-negative.

View File

@@ -0,0 +1,60 @@
# `math/i2osp`
## Purpose
Integer-to-Octet-String Primitive (RFC 8017 §4.1). Encodes a non-negative integer
as a fixed-length big-endian byte string. Required by every RSA signature and
encryption scheme to convert the modular-exponentiation output into a byte
string of the modulus's byte length.
This module is a thin re-export of the canonical `i2osp` in `@suaveplan/crypto-utils`
so the entire project shares a single, audited definition. The re-export gives
RSA consumers a path-local import that mirrors the spec section number.
## Features
- Fixed-output-length big-endian byte encoding.
- Throws on negative input or insufficient output length.
- Accepts both `number` and `bigint` for source-side ergonomics.
## Usage: Basic Example
```ts
import { i2osp } from "@suaveplan/crypto-rsa/math";
const bytes = i2osp(255n, 4); // Uint8Array([0, 0, 0, 255])
```
## Usage: Advanced Example
```ts
import { i2osp, modPow } from "@suaveplan/crypto-rsa/math";
function publicOpAsBytes(
c: bigint,
e: bigint,
n: bigint,
modulusByteLength: number
): Uint8Array {
const result = modPow(c, e, n);
return i2osp(result, modulusByteLength);
}
```
## API Reference
### `i2osp(n: number | bigint, xLen: number): Uint8Array`
Returns a `Uint8Array` of length `xLen`. Throws
`CryptoUtilsError(CRYPTO.UTILS.INTEGER.OUT_OF_RANGE)` when `n` is negative or
does not fit in `xLen` bytes; throws
`CryptoUtilsError(CRYPTO.UTILS.BYTES.INVALID_LENGTH)` when `xLen` is negative
or not an integer.
## Implementation Notes
The canonical implementation lives in `@suaveplan/crypto-utils/bytes/i2osp/i2osp.ts`.
This re-export is preserved instead of importing the utility directly so that
RSA consumers reading the source see a path that maps 1:1 to RFC 8017 §4.1, and
so the RSA package can swap the implementation later (for instance to a
constant-time native variant) without churn at every call site.

View File

@@ -0,0 +1,60 @@
# `math/modpow`
## Purpose
Single-source-of-truth modular exponentiation for the entire `@suaveplan/crypto-rsa`
package. Wraps `@noble/curves/abstract/modular.pow` so every signing, verifying,
encrypting, decrypting, and CRT path goes through one function — and so the banned
native pattern `BigInt ** BigInt % N` never appears in package source.
The native `**` + `%` pattern materialises the full exponentiation result before
modular reduction. For RSA-grade exponents (2048+ bits) this allocates digits in
the gigabytes and is also non-constant-time. `@noble/curves/abstract/modular.pow`
implements the standard square-and-multiply ladder over the modular ring without
materialising the full power.
## Features
- One-line wrapper — no extra allocation, no extra branching.
- Pinned to `@noble/curves`'s audited `pow` implementation (the same primitive
that backs every elliptic curve in `@suaveplan/crypto-curve`).
- Repo-grep gate ensures no other file in the package emits the banned pattern.
## Usage: Basic Example
```ts
import { modPow } from "@suaveplan/crypto-rsa/math";
const result = modPow(2n, 10n, 1000n); // 24n
```
## Usage: Advanced Example
```ts
import { modPow } from "@suaveplan/crypto-rsa/math";
import type { RsaPublicKey } from "@suaveplan/crypto-rsa";
function rsaPublicOp(c: bigint, key: RsaPublicKey): bigint {
return modPow(c, key.e, key.n);
}
```
## API Reference
### `modPow(base: bigint, exponent: bigint, modulus: bigint): bigint`
Returns `base^exponent mod modulus` in `[0, modulus)`. Delegates entirely to
`@noble/curves/abstract/modular.pow`. The noble implementation throws
`RangeError` on `modulus <= 0n` or `exponent < 0n`.
## Implementation Notes
The function exists purely to centralise the import. Inlining would not save any
runtime cost (the wrapper is a single tail call) but would bypass the
single-import grep gate that the package relies on for the `BigInt ** %` audit.
The dependency on `@noble/curves` is justified because the package already
depends on it transitively for prime-related utilities and because the
`@suaveplan/crypto-curve` precedent established the engine selection. No
alternative pure-JS implementation in the project currently provides
audited modular exponentiation over `bigint`.

View File

@@ -0,0 +1,52 @@
# `math/os2ip`
## Purpose
Octet-String-to-Integer Primitive (RFC 8017 §4.2). Decodes a big-endian byte string
into a non-negative `bigint`. Used by every RSA scheme to convert a modulus-sized
byte payload back into the integer the modular operations expect.
Thin re-export of `@suaveplan/crypto-utils`'s canonical implementation so the
entire project agrees on encoding semantics.
## Features
- Always returns `bigint`, never `number`, so precision is preserved for any
byte length.
- Pure decoder; never throws.
## Usage: Basic Example
```ts
import { os2ip } from "@suaveplan/crypto-rsa/math";
const value = os2ip(new Uint8Array([0, 0, 0, 255])); // 255n
```
## Usage: Advanced Example
```ts
import { i2osp, modPow, os2ip } from "@suaveplan/crypto-rsa/math";
function privateOpRoundTrip(
sigBytes: Uint8Array,
e: bigint,
n: bigint,
modulusByteLength: number
): Uint8Array {
const sigInt = os2ip(sigBytes);
const recovered = modPow(sigInt, e, n);
return i2osp(recovered, modulusByteLength);
}
```
## API Reference
### `os2ip(bytes: Uint8Array): bigint`
Returns the big-endian-decoded `bigint`. Empty input returns `0n`.
## Implementation Notes
The canonical implementation lives in `@suaveplan/crypto-utils/bytes/os2ip/os2ip.ts`.
This re-export gives RSA consumers a path-local import mirroring RFC 8017 §4.2.

View File

@@ -0,0 +1,85 @@
# `observability/metrics`
## Purpose
OpenTelemetry meter + instrument registry for `@suaveplan/crypto-rsa`. Stage 1
defines the full instrument set the proposal pins (so dashboards and SLOs can
be authored ahead of Stages 2-4) and wires up the parse/serialise duration
histograms and the typed-error counter — the only paths that have a hot
production call site at this stage.
The instruments are lazily constructed on first use to keep the module
import-side-effect-free; this matters because some downstream consumers
import the package without ever installing a `MeterProvider`, and constructing
real instruments against the no-op global meter is harmless but unnecessary.
## Features
- Lazy `meter()` and `instruments()` accessors — first access constructs,
subsequent accesses reuse cached instruments.
- `resetMetricsForTesting()` — drops the caches so a fresh `MeterProvider`
installed by a test takes effect.
- `recordParseDuration(format, ms)`, `recordSerializeDuration(format, ms)`,
`recordError(code)` — production helpers wrapping the instrument calls.
- `getInstrumentsForTesting()` — exposes the bag for test assertions.
- Full instrument set (8 histograms + 6 counters) declared up-front so the
metric names cannot drift between Stages.
## Usage: Basic Example
```ts
import {
recordError,
recordParseDuration,
} from "@suaveplan/crypto-rsa/observability";
const start = performance.now();
try {
parse();
recordParseDuration("pkcs1", performance.now() - start);
} catch (error) {
recordError(error.code);
throw error;
}
```
## Usage: Advanced Example
```ts
import {
getInstrumentsForTesting,
resetMetricsForTesting,
} from "@suaveplan/crypto-rsa/observability";
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
resetMetricsForTesting();
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
const bag = getInstrumentsForTesting();
// bag.errorsTotal, bag.outcomeTotal, …
```
## API Reference
- `recordParseDuration(format: string, durationMs: number): void`
- `recordSerializeDuration(format: string, durationMs: number): void`
- `recordError(code: string): void`
- `resetMetricsForTesting(): void`
- `getInstrumentsForTesting(): InstrumentBag`
The instrument bag carries (counters): `errorsTotal`, `outcomeTotal`,
`engineSelectedTotal`, `fingerprintTotal`, `profileViolationTotal`,
`blindingDisabledTotal`; (histograms): `verifyDurationMs`, `signDurationMs`,
`encryptDurationMs`, `decryptDurationMs`, `keygenDurationMs`,
`modpowDurationMs`, `parseDurationMs`, `serializeDurationMs`.
## Implementation Notes
The instruments are declared even though most are not yet wired into
production paths because the proposal pins them as the canonical observable
surface; declaring them lets dashboards reference the metric
names by build time, not by feature-arrival time, and prevents naming drift
when Stages 2-4 wire them up.
`resetMetricsForTesting` is the documented seam for swapping
`MeterProvider`s under test. Production code should never call it.

View File

@@ -0,0 +1,67 @@
# observability/timing
## Purpose
Centralised high-resolution clock source used by every metric histogram
in `@suaveplan/crypto-rsa`. Resolves to `performance.now()` when
available and falls back to `Date.now()` when it is not. The presence
check runs once at module-load time so each call site receives the same
resolved function reference; there is no per-call branch and no
per-call cost beyond the indirect call.
## Features
- Single function (`nowMs`) shared across every sign / verify / encrypt / decrypt / keygen path
- Module-load-time clock resolution — chosen once per process
- `performance.now()` preferred (sub-millisecond resolution in modern engines)
- `Date.now()` fallback for engines without `performance` (legacy embedded JS)
## Usage: Basic Example
```ts
import { nowMs } from "@suaveplan/crypto-rsa/observability";
const start = nowMs();
doSomething();
const elapsed = nowMs() - start;
```
## Usage: Advanced Example
The clock source is used internally by every `recordSignOutcome` /
`recordVerifyOutcome` / `recordEncryptDuration` call site. Consumers
generally do not need to call `nowMs` directly; the helper is exposed
for instrumentation parity in package extensions or alternative
engines.
```ts
import { nowMs, getInstrumentsForTesting } from "@suaveplan/crypto-rsa/observability";
// Custom histogram tagging — bypassing the recordX helpers.
const start = nowMs();
performExpensiveOperation();
getInstrumentsForTesting().signDurationMs.record(nowMs() - start, {
scheme: "custom",
hash: "SHA-256",
engine: "custom",
});
```
## API Reference
### `nowMs(): number`
- **returns** the current timestamp in milliseconds (with sub-millisecond resolution where supported)
## Implementation Notes
- The `globalThis.performance` lookup tolerates engines that omit the
`performance` global without causing a `ReferenceError`. The presence
of `.now` is also checked because some embedded engines expose
`performance` without the method.
- The chosen clock is captured in a module-scope closure (`NOW`) so the
hot path is one indirect call — no `typeof` guard, no ternary.
- This module deliberately ships a single export; mocking the clock for
determinism testing is done through OpenTelemetry's `MeterProvider`
in conjunction with `resetMetricsForTesting`, not by replacing
`nowMs`.

View File

@@ -0,0 +1,49 @@
# `pem/labels`
## Purpose
Centralised PEM textual labels per RFC 7468 §5/§10/§11 used by the public
and private PEM parsers and serialisers in this package. Keeping the labels
in one file means the parser whitelist and serialiser emission share a
single source of truth.
## Features
- `PEM_LABEL_PUBLIC_KEY` — `"PUBLIC KEY"` (SPKI body).
- `PEM_LABEL_RSA_PUBLIC_KEY` — `"RSA PUBLIC KEY"` (PKCS#1 public body).
- `PEM_LABEL_PRIVATE_KEY` — `"PRIVATE KEY"` (PKCS#8 body).
- `PEM_LABEL_RSA_PRIVATE_KEY` — `"RSA PRIVATE KEY"` (PKCS#1 private body).
- `PEM_LINE_LENGTH = 64` — RFC 7468 §3 maximum body line length.
## Usage: Basic Example
```ts
import { PEM_LABEL_PUBLIC_KEY } from "@suaveplan/crypto-rsa/pem";
const armor = `-----BEGIN ${PEM_LABEL_PUBLIC_KEY}-----`;
```
## Usage: Advanced Example
```ts
import {
PEM_LABEL_PRIVATE_KEY,
PEM_LABEL_RSA_PRIVATE_KEY,
} from "@suaveplan/crypto-rsa/pem";
function classifyPrivatePem(label: string): "pkcs8" | "pkcs1" | "unknown" {
if (label === PEM_LABEL_PRIVATE_KEY) return "pkcs8";
if (label === PEM_LABEL_RSA_PRIVATE_KEY) return "pkcs1";
return "unknown";
}
```
## API Reference
All exports are `string` constants.
## Implementation Notes
RFC 7468 §3 fixes the body line length at 64 base64 characters. Some legacy
emitters use 76 (the historic RFC 1421 PEM length); we accept both on parse
but always emit 64.

View File

@@ -0,0 +1,50 @@
# `pem/parse-private`
## Purpose
Parse an RSA private key from RFC 7468 PEM textual encoding. Accepts the
two canonical private-key armor labels — `"PRIVATE KEY"` (PKCS#8 per RFC
5208 / RFC 5958) and `"RSA PRIVATE KEY"` (PKCS#1 per RFC 8017 Appendix
A.1.2) — and dispatches to the matching DER parser.
## Features
- Dual-label support (`"PRIVATE KEY"` and `"RSA PRIVATE KEY"`).
- Delegates to existing DER parsers.
- Strict-grammar body validation via `decodePem`.
## Usage: Basic Example
```ts
import { parseRsaPrivateKeyPem } from "@suaveplan/crypto-rsa/pem";
const key = parseRsaPrivateKeyPem(`
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAAS...
-----END PRIVATE KEY-----
`);
```
## Usage: Advanced Example
```ts
import { parseRsaPrivateKeyPem } from "@suaveplan/crypto-rsa/pem";
const fromPkcs8 = parseRsaPrivateKeyPem(pkcs8Pem);
const fromPkcs1 = parseRsaPrivateKeyPem(pkcs1Pem);
```
## API Reference
### `parseRsaPrivateKeyPem(pem: string): RsaPrivateKey`
Throws `CryptoRsaError(CRYPTO_RSA.PEM_MALFORMED)` on framing errors,
`CRYPTO_RSA.INVALID_KEY` / `CRYPTO_RSA.UNSUPPORTED_FORMAT` on inner-DER errors.
## Implementation Notes
Same dispatcher pattern as `parse-public.ts` — `Record<label, parser>` with
the allowlist derived from the dispatcher keys. Encrypted PEM bodies
(legacy `Proc-Type: 4,ENCRYPTED` headers, OpenSSL `DEK-Info` lines) are
not supported ; consumers needing those should decrypt to a
plain PKCS#8 body before calling this function.

View File

@@ -0,0 +1,52 @@
# `pem/parse-public`
## Purpose
Parse an RSA public key from RFC 7468 PEM textual encoding. Accepts both
canonical armor labels — `"PUBLIC KEY"` (SPKI per RFC 5280) and
`"RSA PUBLIC KEY"` (PKCS#1 per RFC 8017) — and dispatches to the
appropriate DER parser. Mismatched BEGIN/END, unknown labels, or non-base64
body content surface as `CRYPTO_RSA.PEM_MALFORMED`.
## Features
- Dual-label support (`"PUBLIC KEY"` and `"RSA PUBLIC KEY"`).
- Delegates to existing DER parsers — no DER logic duplicated.
- Strict-grammar body validation via `decodePem`.
## Usage: Basic Example
```ts
import { parseRsaPublicKeyPem } from "@suaveplan/crypto-rsa/pem";
const key = parseRsaPublicKeyPem(`
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----
`);
```
## Usage: Advanced Example
```ts
import { parseRsaPublicKeyPem } from "@suaveplan/crypto-rsa/pem";
// Either label is accepted.
const fromSpki = parseRsaPublicKeyPem(spkiPem);
const fromPkcs1 = parseRsaPublicKeyPem(pkcs1Pem);
```
## API Reference
### `parseRsaPublicKeyPem(pem: string): RsaPublicKey`
Throws `CryptoRsaError(CRYPTO_RSA.PEM_MALFORMED)` on framing errors and
`CryptoRsaError(CRYPTO_RSA.INVALID_KEY | CRYPTO_RSA.UNSUPPORTED_FORMAT)`
on inner-DER errors per the underlying DER parsers.
## Implementation Notes
The dispatcher is a `Record<string, parser>` lookup keyed by label. Building
the allowed-label list from the dispatcher's keys ensures the codec's
allowlist and the dispatcher's coverage cannot drift — adding a new label
requires changing exactly one place.

View File

@@ -0,0 +1,61 @@
# `pem/pem-codec`
## Purpose
Underlying RFC 7468 textual-encoding codec used by the four PEM
parse/serialise entry points in this module. Splits the codec from the
public-key-specific dispatchers so the parser logic (label whitelist,
mismatched BEGIN/END detection, base64 strict-grammar enforcement) lives
in exactly one place.
## Features
- `decodePem(pem, allowedLabels)` — line-tolerant scan for matching
BEGIN/END armor, label whitelist enforcement, body base64 round-trip.
- `encodePem(label, body)` — canonical 64-char body line emission with
trailing newline.
- All failure modes raise `CryptoRsaError(CRYPTO_RSA.PEM_MALFORMED)`.
## Usage: Basic Example
```ts
import { decodePem, encodePem } from "@suaveplan/crypto-rsa/pem";
const decoded = decodePem(pemString, ["PUBLIC KEY"]);
const back = encodePem("PUBLIC KEY", decoded.body);
```
## Usage: Advanced Example
```ts
import { decodePem } from "@suaveplan/crypto-rsa/pem";
const { label, body } = decodePem(input, ["PUBLIC KEY", "RSA PUBLIC KEY"]);
// Dispatch on label to the appropriate DER parser.
```
## API Reference
### `decodePem(pem: string, allowedLabels: readonly string[]): DecodedPem`
Returns `{ label, body }`. Throws `PEM_MALFORMED` on missing BEGIN line,
unknown label, missing matching END line, empty body, non-base64 body
characters, or base64-decode failure.
### `encodePem(label: string, body: Uint8Array): string`
Returns the canonical PEM block.
### `interface DecodedPem { readonly label: string; readonly body: Uint8Array }`
## Implementation Notes
The body grammar is enforced before base64 decoding because some base64
implementations silently accept non-canonical characters (whitespace, `_`,
`-`) that violate strict RFC 7468. We require the strict alphabet
`[A-Za-z0-9+/=]` to ensure round-trip determinism.
The codec accepts both LF and CRLF line endings on parse but always emits
LF — the canonical form recommended by RFC 7468 §2 for systems with
flexible line-ending support. CRLF emission is left to the consumer (e.g.
HTTP frameworks) as a transport-layer concern.

View File

@@ -0,0 +1,43 @@
# `pem/serialize-private`
## Purpose
Inverse of `parseRsaPrivateKeyPem`. Emits PEM textual encoding for an RSA
private key in either PKCS#8 (`"PRIVATE KEY"`, the modern default) or
PKCS#1 (`"RSA PRIVATE KEY"`, the legacy OpenSSL form) per RFC 7468 §3.
## Features
- 64-char body line emission.
- Discriminated `format` option (`"pkcs8"` default | `"pkcs1"`).
- Reuses the underlying DER serialisers.
## Usage: Basic Example
```ts
import { serializeRsaPrivateKeyPem } from "@suaveplan/crypto-rsa/pem";
const pem = serializeRsaPrivateKeyPem(key);
// -----BEGIN PRIVATE KEY----- ...
```
## Usage: Advanced Example
```ts
import { serializeRsaPrivateKeyPem } from "@suaveplan/crypto-rsa/pem";
const legacy = serializeRsaPrivateKeyPem(key, { format: "pkcs1" });
// -----BEGIN RSA PRIVATE KEY----- ...
```
## API Reference
### `serializeRsaPrivateKeyPem(key, options?: { format?: "pkcs8" | "pkcs1" }): string`
Returns the PEM string with trailing newline.
## Implementation Notes
We default to PKCS#8 for the same reason `serialize-public` defaults to
SPKI — modern PKI tooling, KMS providers, and JOSE libraries all consume
PKCS#8. PKCS#1 remains for legacy OpenSSL and ssh-keygen interop.

View File

@@ -0,0 +1,44 @@
# `pem/serialize-public`
## Purpose
Inverse of `parseRsaPublicKeyPem`. Emits PEM textual encoding for an RSA
public key in either SPKI (`"PUBLIC KEY"`, the default modern form) or
PKCS#1 (`"RSA PUBLIC KEY"`, the legacy OpenSSL form) per RFC 7468 §3.
## Features
- 64-char body line emission (RFC 7468 §3 canonical).
- Discriminated `format` option (`"spki"` default | `"pkcs1"`).
- Reuses the underlying DER serialisers — no DER logic duplicated.
## Usage: Basic Example
```ts
import { serializeRsaPublicKeyPem } from "@suaveplan/crypto-rsa/pem";
const pem = serializeRsaPublicKeyPem(key);
// -----BEGIN PUBLIC KEY----- ... (SPKI form)
```
## Usage: Advanced Example
```ts
import { serializeRsaPublicKeyPem } from "@suaveplan/crypto-rsa/pem";
const legacyOpenSsl = serializeRsaPublicKeyPem(key, { format: "pkcs1" });
// -----BEGIN RSA PUBLIC KEY----- ... (PKCS#1 form)
```
## API Reference
### `serializeRsaPublicKeyPem(key, options?: { format?: "spki" | "pkcs1" }): string`
Returns the PEM string with a trailing newline.
## Implementation Notes
The default is `format: "spki"` because the modern OpenSSL toolchain and
all PKI standards (X.509 / TLS / OIDC / WebAuthn) emit and consume SPKI-
labeled PEM. The PKCS#1 variant remains for compatibility with legacy
OpenSSL `openssl rsa -RSAPublicKey_out` and ssh-keygen tooling.

View File

@@ -0,0 +1,73 @@
# `policy/modulus`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Modulus-length policy gate enforced at every public-API entry point in
`@suaveplan/crypto-rsa`. Combines a default-2048-bit floor (with optional
1024-bit legacy carve-out) and a default-16384-bit ceiling (with optional
oversized opt-in) so consumers cannot accidentally accept dangerously short
keys or get DoS'd by attacker-supplied oversized keys.
The 2048-bit default tracks NIST SP 800-131A Rev. 2's transition from
112-bit to 128-bit security strength; 1024 bits delivers 80-bit strength
which is broken offline and survives only for legacy WebAuthn TPM
endorsement keys (see proposal §Why and CLAUDE.md). The 16384-bit ceiling
matches the largest modulus a service is realistically expected to verify;
beyond that the per-verify modpow cost (cubic in bit length) becomes a
viable DoS vector.
## Features
- `enforceMinBitLength(key, { allowLegacy? })` — strict default 2048-bit floor.
- `enforceMaxBitLength(key, { allowOversize? })` — strict default 16384-bit ceiling.
- Hard sub-1024-bit reject regardless of `allowLegacy`.
- Both throw `CryptoRsaError` with structured `context` for telemetry.
## Usage: Basic Example
```ts
import { enforceMinBitLength } from "@suaveplan/crypto-rsa/policy";
enforceMinBitLength(publicKey); // throws if bitLength < 2048
```
## Usage: Advanced Example
```ts
import {
enforceMaxBitLength,
enforceMinBitLength,
} from "@suaveplan/crypto-rsa/policy";
// WebAuthn TPM legacy path: 1024 bits permitted.
enforceMinBitLength(tpmEndorsementKey, { allowLegacy: true });
// Offline tooling that processes 32 768-bit keys.
enforceMaxBitLength(researchKey, { allowOversize: true });
```
## API Reference
### `enforceMinBitLength(key, options?: { allowLegacy?: boolean }): void`
Throws `CryptoRsaError(CRYPTO_RSA.MODULUS_TOO_SHORT)` with `context: { bitLength, floor }`
when `key.bitLength < floor`. `floor` is `2048` by default, `1024` under
`allowLegacy: true`. Sub-1024-bit keys are always rejected.
### `enforceMaxBitLength(key, options?: { allowOversize?: boolean }): void`
Throws `CryptoRsaError(CRYPTO_RSA.MODULUS_TOO_LARGE)` with `context: { bitLength, ceiling }`
when `key.bitLength > 16384`. `allowOversize: true` short-circuits the check.
## Implementation Notes
The hard sub-1024-bit floor is enforced even when `allowLegacy: true` because
512-bit and 768-bit moduli have been factored on commodity hardware and there
is no realistic legacy use-case that still relies on them.
The ceiling check returns early when `allowOversize: true` rather than scaling
the comparison; this keeps the hot path branch-predictable and the policy
scope obvious to readers.

View File

@@ -0,0 +1,108 @@
# profile/profile
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`enforceProfile` applies a named profile's rule set to RSA operation
parameters. Two profiles ship: `permissive` (no-op pass-through, the
default) and `fips-186-5` (FIPS 186-5 §5 + Appendix B.3.1
constraints). The function is wired into every operation surface that
accepts a `profile` option so the rule set runs uniformly across
sign / verify / encrypt / decrypt / keygen.
## Features
- Two profiles: `permissive` (default) and `fips-186-5`
- FIPS profile enforces:
- `modulusBits ∈ {2048, 3072, 4096}`
- `publicExponent ∈ [2^16 + 1, 2^256 − 1]` AND odd
- PSS `saltLength === hLen(hash)`
- `hash !== "SHA-1"` for sign/verify (allowed for OAEP encrypt/decrypt
so legacy interop still works for opaque cipher payloads)
- Derives modulus bits from `key.bitLength` when `modulusBits` is omitted
- Each violation throws `CRYPTO_RSA.PROFILE_VIOLATION` with `rule`
attribute identifying which constraint failed
- Increments `crypto_rsa.profile_violation_total{rule}` on every
violation for dashboard aggregation
## Usage: Basic Example
```ts
import { enforceProfile } from "@suaveplan/crypto-rsa/profile";
enforceProfile(
{ modulusBits: 2048, publicExponent: 65537n },
{ profile: "fips-186-5" }
);
// throws if 2048 / 3072 / 4096 / 65537 don't match
```
## Usage: Advanced Example
Wire the profile into the keygen surface to gate FIPS-only deployments:
```ts
import {
enforceProfile,
generateRsaKeyPair,
} from "@suaveplan/crypto-rsa";
enforceProfile(
{ modulusBits: 3072, publicExponent: 65537n },
{ profile: "fips-186-5" }
);
const key = await generateRsaKeyPair({
modulusBits: 3072,
publicExponent: 65537n,
});
```
## API Reference
### `enforceProfile(input, options): void`
- `input.modulusBits?: number`
- `input.publicExponent?: bigint`
- `input.hash?: RsaHashAlgorithm`
- `input.scheme?: "pkcs1v15" | "pss" | "oaep"`
- `input.saltLength?: number`
- `input.key?: RsaPublicKey | RsaPrivateKey` — modulus bits derived
from `key.bitLength` when `modulusBits` is omitted
- `options.profile: "permissive" | "fips-186-5"`
- **throws** `CryptoRsaError(CRYPTO_RSA.PROFILE_VIOLATION)` with `rule`
attribute on any violation
### `RsaProfile`
`"permissive" | "fips-186-5"`.
### `ProfileInput`
Shape of the operation parameters subject to enforcement.
### `EnforceProfileOptions`
`{ profile: RsaProfile }`.
### `resetProfileCounterForTesting()`
Test-only helper.
## Implementation Notes
- The FIPS profile is intentionally strict — it rejects the popular
`e = 3` and `e = 5` exponents which, while mathematically sound, are
banned by FIPS 186-5 §A.1.1 due to historical interop issues with
small-exponent attacks against poor PKCS1v15 implementations.
- SHA-1 is allowed for OAEP because RFC 8017 §7.1.1 still references
it in the canonical OAEP test vectors and FIPS 186-5 only deprecates
SHA-1 for digital signatures (sign/verify), not for the MGF / hash
binding inside OAEP.
- The `rule` attribute on `PROFILE_VIOLATION` errors uses kebab-case
identifiers (`modulus-bits`, `public-exponent`, `hash-algorithm`,
`pss-salt-length`) matching the counter label values.
- The function is intentionally side-effect-free (besides the counter
increment); callers can call it during config validation, at
operation-call time, or both.

View File

@@ -0,0 +1,90 @@
# `schemas`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod boundary schemas covering every public input/output of `@suaveplan/crypto-rsa`.
ships the canonical key shapes (`RsaPublicKeySchema`, `RsaPrivateKeySchema`),
the parse/serialise format discriminators, and the option schemas for the sign,
verify, PSS, and OAEP entry points that Stages 2-3 will wire up.
The schemas serve two purposes: runtime validation at every public surface, and
single-source-of-truth typing for documentation and downstream consumer
ecosystems (e.g. JWK parsers in `@suaveplan/jws` once it lands).
## Features
- `HashAlgorithmSchema` — `SHA-1` | `SHA-256` | `SHA-384` | `SHA-512`.
- `RsaPublicKeySchema` and `RsaPrivateKeySchema` — every field positive `bigint`,
`bitLength` positive integer.
- `RsaPublicKeyFormatSchema` (`"pkcs1" | "spki" | "jwk"`).
- `RsaPrivateKeyFormatSchema` (`"pkcs1" | "pkcs8" | "jwk"`).
- `RsaParseOptions{Public,Private}Schema` and the union `RsaParseOptionsSchema`.
- `RsaSerializeOptions{Public,Private}Schema` and the union `RsaSerializeOptionsSchema`.
- `RsaSignOptionsSchema`, `RsaVerifyOptionsSchema`, `RsaPssOptionsSchema`,
`RsaOaepOptionsSchema`.
## Usage: Basic Example
```ts
import { RsaPublicKeySchema } from "@suaveplan/crypto-rsa/schemas";
const validated = RsaPublicKeySchema.parse({
n: 0xc0_de_n,
e: 65537n,
bitLength: 2048,
});
```
## Usage: Advanced Example
```ts
import {
RsaParseOptionsPublicSchema,
RsaPssOptionsSchema,
} from "@suaveplan/crypto-rsa/schemas";
function readKey(input: unknown) {
const opts = RsaParseOptionsPublicSchema.parse(input);
// opts.format is now "pkcs1" | "spki" | "jwk"
}
function signOptions(input: unknown) {
const opts = RsaPssOptionsSchema.parse(input);
// opts.hash, opts.saltLength?, opts.mgfHash?
}
```
## API Reference
Every export is a `z.ZodType<T>`. `infer` to recover the static type:
```ts
import type { z } from "zod";
import { RsaPublicKeySchema } from "@suaveplan/crypto-rsa/schemas";
type RsaPublicKey = z.infer<typeof RsaPublicKeySchema>;
```
Schemas validate strictly:
- `n`, `e`, `d`, `p`, `q`, `dp`, `dq`, `qInv` MUST be positive `bigint`.
- `bitLength` MUST be a positive integer.
- `format` MUST be one of the discriminated string literals.
- `hash` MUST be one of the four canonical IETF hash labels (`SHA-256` etc.,
hyphenated, uppercase — the JOSE / WebCrypto canonical form).
## Implementation Notes
`positiveBigInt` and `positiveBitLength` are inlined helpers rather than exported
schemas to keep the public surface focused on the named shapes the proposal pins.
The parse/serialise option unions are `z.union(public, private)` rather than
discriminated unions because the discriminant value sets overlap (`"pkcs1"` and
`"jwk"` appear in both). Consumers that need stricter narrowing should call the
specific public/private schemas directly.
`RsaSignOptionsSchema` carries an optional `blinding` flag for downstream RSA
private-key blinding (Stage 2) so callers can opt out of the side-channel
hardening for benchmarks; the default behaviour will be `blinding: true`.

View File

@@ -0,0 +1,82 @@
# sign/pkcs1v15
## Purpose
RSASSA-PKCS1-v1\_5 signature generation per RFC 8017 §8.2.1. Hashes
the message with the configured algorithm, applies the EMSA-PKCS1-v1\_5
encoding, and runs the RSA private operation through the CRT path. The
output is a signature of length exactly `byteLength(n)`.
## Features
- RFC 8017 §8.2.1 conformant — uses the CRT private-key operation
- Supports SHA-1, SHA-256, SHA-384, SHA-512
- Modulus-length policy gates run on every call (`MODULUS_TOO_SHORT` / `MODULUS_TOO_LARGE`)
- Re-uses the canonical `i2osp` / `os2ip` math primitives
- Records `crypto_rsa.sign_duration_ms{scheme,hash,engine}` and `crypto_rsa.outcome_total{operation,outcome}` on every call
- **RSA blinding (RFC 8017 §5.1.2 / Kocher 1996) wired by default** — opt-out via `{ blinding: false }` emits `crypto_rsa.blinding_disabled_total{operation="sign"}`
- All thrown errors are `CryptoRsaError` instances; non-typed exceptions wrap as `CRYPTO_RSA.INVALID_SIGNATURE`
## Usage: Basic Example
```ts
import { rsaSignPkcs1v15 } from "@suaveplan/crypto-rsa/sign/pkcs1v15";
const message = new TextEncoder().encode("hello, signed world");
const signature = rsaSignPkcs1v15(privateKey, message, { hash: "SHA-256" });
console.log(signature.length); // === byteLength(n), e.g. 256 for 2048-bit
```
## Usage: Advanced Example
The `allowLegacy` option lowers the modulus floor from 2048 to 1024 bits
for legacy interop scenarios (e.g. WebAuthn TPM 1.2 endorsement keys).
The `allowOversize` option lifts the ceiling beyond 16384 bits for
offline tooling. Both default to `false`.
```ts
import { rsaSignPkcs1v15 } from "@suaveplan/crypto-rsa/sign/pkcs1v15";
const sig = rsaSignPkcs1v15(legacy1024PrivateKey, message, {
hash: "SHA-1",
allowLegacy: true,
});
```
## API Reference
### `rsaSignPkcs1v15(privateKey, message, options): Uint8Array`
- `privateKey: RsaPrivateKey` — canonical private key with mandatory CRT parameters
- `message: Uint8Array` — bytes to be signed; will be hashed internally
- `options.hash: RsaHashAlgorithm` — one of `"SHA-1"`, `"SHA-256"`, `"SHA-384"`, `"SHA-512"`
- `options.allowLegacy?: boolean` — see policy doc
- `options.allowOversize?: boolean` — see policy doc
- `options.blinding?: boolean` — defaults to `true`; opting out emits `crypto_rsa.blinding_disabled_total{operation="sign"}`
- `options.rng?: Rng` — randomness source for the blinding factor; defaults to `defaultRng()`
- **returns** `Uint8Array` of length `byteLength(n)`
- **throws** `CryptoRsaError(CRYPTO_RSA.MODULUS_TOO_SHORT)` / `MODULUS_TOO_LARGE` / `UNSUPPORTED_HASH` / `INVALID_KEY`
## Implementation Notes
- The output equals `i2osp(rsaPrivateOperationCrt(os2ip(em), privateKey), emLen)`
where `em = emsaPkcs1v15Encode(message, emLen, hash)` and
`emLen = byteLength(n)`. This is bit-for-bit identical to the naive
`c = m^d mod n` reference, but uses the CRT decomposition for ~3-4×
the throughput on a 2048-bit modulus.
- Encoding is deterministic — every call with the same `(message, hash)`
pair produces the same signature. This is RFC 8017 §8.2.1 behaviour:
PKCS1-v1\_5 is a deterministic signature scheme, in contrast to PSS.
- The function never short-circuits on early failure — even when an
exception is raised, the duration histogram and outcome counter are
recorded. The `outcome` attribute distinguishes `"ok"` from `"error"`.
- The engine attribute is `"noble"` because the underlying modular
exponentiation is `@noble/curves/abstract/modular.pow`. The
`RsaCryptoEngine` adapter (`./engine`) selects `"subtle"` when
`globalThis.crypto.subtle` exposes the matching algorithm.
- **Blinding** — for every call `m' = m * r^e mod n` is computed with a
freshly sampled `r ∈ Z*_n`, the CRT private operation runs against
`m'`, and the result is unblinded via `s = s' * r^{-1} mod n`. This
prevents the Kocher 1996 timing-attack class against the secret
exponent. Output is bit-for-bit identical to the unblinded path; only
the timing varies. The < 5% overhead is bench-verified.

View File

@@ -0,0 +1,105 @@
# sign/pss
## Purpose
RSASSA-PSS signature generation per RFC 8017 §8.1.1. Hashes the
message, draws a random salt of the configured length, runs the
EMSA-PSS encoder, then converts the result through the CRT private-key
operation. The output is a signature of length exactly `byteLength(n)`.
PSS is a probabilistic scheme — distinct calls with the same inputs
produce different signatures unless the salt length is zero.
## Features
- RFC 8017 §8.1.1 conformant
- Configurable `hash`, `mgfHash`, `saltLength`
- `saltLength` defaults to `hashOutputLength(hash)`
- `mgfHash` defaults to `hash`
- Injectable `Rng` from `@suaveplan/types` for deterministic-replay test scenarios
- Default RNG: `randomBytes` from `@suaveplan/crypto-utils` (CSPRNG-backed)
- CRT-aware private operation (~3-4× faster than naive `m^d mod n` on 2048-bit keys)
- **RSA blinding (RFC 8017 §5.1.2 / Kocher 1996) wired by default** — opt-out via `{ blinding: false }` emits `crypto_rsa.blinding_disabled_total{operation="sign"}`
- Records `crypto_rsa.sign_duration_ms{scheme="pss",hash,engine}` and outcome counter on every call
## Usage: Basic Example
```ts
import { rsaSignPss } from "@suaveplan/crypto-rsa/sign/pss";
const message = new TextEncoder().encode("hello, signed world");
const signature = rsaSignPss(privateKey, message, { hash: "SHA-256" });
console.log(signature.length); // === byteLength(n)
```
## Usage: Advanced Example
The `rng` option accepts any `Rng` shape from `@suaveplan/types`. A
seeded RNG produces deterministic signatures, useful for golden-file
testing or reproducible audit trails. The `saltLength: 0` shortcut also
produces deterministic PSS without needing a custom RNG.
```ts
import { rsaSignPss } from "@suaveplan/crypto-rsa/sign/pss";
import type { Rng } from "@suaveplan/types";
// Deterministic PSS via empty salt.
const detSig = rsaSignPss(privateKey, message, {
hash: "SHA-256",
saltLength: 0,
});
// Deterministic PSS via seeded RNG (reproducible for fuzz cases).
const seeded: Rng = makeMyMulberry32Rng(0xdeadbeef);
const reproducible = rsaSignPss(privateKey, message, {
hash: "SHA-256",
rng: seeded,
});
// Cross-hash PSS — message hashed with SHA-256, MGF1 driven by SHA-1.
const xHash = rsaSignPss(privateKey, message, {
hash: "SHA-256",
mgfHash: "SHA-1",
});
```
## API Reference
### `rsaSignPss(privateKey, message, options): Uint8Array`
- `privateKey: RsaPrivateKey`
- `message: Uint8Array`
- `options.hash: RsaHashAlgorithm`
- `options.saltLength?: number` — defaults to `hashOutputLength(hash)`; must be non-negative integer
- `options.mgfHash?: RsaHashAlgorithm` — defaults to `options.hash`
- `options.rng?: Rng` — defaults to `randomBytes` from `@suaveplan/crypto-utils`
- `options.allowLegacy?: boolean`
- `options.allowOversize?: boolean`
- `options.blinding?: boolean` — defaults to `true`; opting out emits `crypto_rsa.blinding_disabled_total{operation="sign"}`
- **returns** `Uint8Array` of length `byteLength(n)`
- **throws** `CryptoRsaError(CRYPTO_RSA.MODULUS_TOO_SHORT)` / `MODULUS_TOO_LARGE` / `UNSUPPORTED_HASH` / `INVALID_KEY` / `INVALID_SIGNATURE` (on bad RNG output or bad salt length)
## Implementation Notes
- `emBits = modulusBits - 1` per RFC 8017 §8.1.1 step 2. The encoder
takes `emBits` (not `emLen` bytes) so the leftmost-bit clearance can
be enforced correctly.
- The signature byte length is always `byteLength(n) = ceil(modulusBits/8)`.
When `modulusBits` is a multiple of 8 (the common case), this equals
`emLen + 1` because `emBits = modulusBits - 1` rounds down by one
byte. The `i2osp(s, Math.ceil(modulusBits / 8))` call pads the
signature to the modulus byte length on the wire.
- The `rng.nextBytes` path is used when available (CSPRNGs typically
expose it directly). When only `nextUint32` is implemented, the salt
is drawn 4 bytes at a time. Both paths produce the same statistical
uniformity assuming the underlying RNG is uniform.
- Salt length of `0` short-circuits the RNG entirely — useful for
deterministic PSS validation.
- **Blinding** — when enabled (default) the encoded message integer is
multiplied by `r^e mod n` for a freshly sampled `r ∈ Z*_n`, the CRT
private operation runs against that blinded value, and the unblinding
step `s = s' * r^{-1} mod n` is applied before `i2osp`. The signature
output is bit-identical to the unblinded path. PSS verifiers do not
observe the difference because verification only depends on the public
key. Disable only for benchmarks; the < 5% overhead does not justify
the timing-side-channel exposure in production.

View File

@@ -0,0 +1,81 @@
# verify/pkcs1v15
## Purpose
RSASSA-PKCS1-v1\_5 signature verification per RFC 8017 §8.2.2. Recovers
the encoded message from `signature` via `s^e mod n`, reconstructs the
expected EMSA-PKCS1-v1\_5 encoding of `message` with the same hash, and
compares the two byte strings constant-time.
## Features
- RFC 8017 §8.2.2 conformant
- Constant-time encoded-message compare via `constantTimeEqual` from `@suaveplan/crypto-utils`
- Returns `false` on rejection (no exception); throws only on structural malformation (signature length mismatch)
- Accepts either an `RsaPublicKey` or an `RsaPrivateKey` (both carry `n` and `e`)
- Modulus-length policy gates run on every call
- Records `crypto_rsa.verify_duration_ms` and `crypto_rsa.outcome_total{operation,outcome}` on every call
## Usage: Basic Example
```ts
import { rsaVerifyPkcs1v15 } from "@suaveplan/crypto-rsa/verify/pkcs1v15";
import { rsaSignPkcs1v15 } from "@suaveplan/crypto-rsa/sign/pkcs1v15";
const message = new TextEncoder().encode("hello, signed world");
const signature = rsaSignPkcs1v15(privateKey, message, { hash: "SHA-256" });
const ok = rsaVerifyPkcs1v15(publicKey, message, signature, {
hash: "SHA-256",
});
console.log(ok); // true
```
## Usage: Advanced Example
A bit-flipped signature, a tampered message, or a hash mismatch all
return `false` without revealing which one tripped the gate.
```ts
import { rsaVerifyPkcs1v15 } from "@suaveplan/crypto-rsa/verify/pkcs1v15";
const tampered = new Uint8Array(signature);
tampered[0] = tampered[0] ^ 0x01;
console.log(
rsaVerifyPkcs1v15(publicKey, message, tampered, { hash: "SHA-256" })
); // false
console.log(
rsaVerifyPkcs1v15(publicKey, message, signature, { hash: "SHA-512" })
); // false (hash mismatch)
```
## API Reference
### `rsaVerifyPkcs1v15(key, message, signature, options): boolean`
- `key: RsaPublicKey | RsaPrivateKey`
- `message: Uint8Array` — the bytes that were signed
- `signature: Uint8Array` — must be exactly `byteLength(n)` long
- `options.hash: RsaHashAlgorithm` — must match the hash used at sign time
- `options.allowLegacy?: boolean`
- `options.allowOversize?: boolean`
- **returns** `true` when the signature is valid, `false` on any verify-level rejection
- **throws** `CryptoRsaError(CRYPTO_RSA.INVALID_SIGNATURE)` on signature length mismatch
- **throws** `CryptoRsaError(CRYPTO_RSA.MODULUS_TOO_SHORT)` / `MODULUS_TOO_LARGE` / `UNSUPPORTED_HASH`
## Implementation Notes
- The signature integer `s = os2ip(signature)` is checked against `s < n`
before the modular exponentiation; signatures with `s >= n` are
rejected with `false` rather than allowed to wrap around.
- Comparison uses `constantTimeEqual(recovered, expected)` — never
`===` and never byte-by-byte short-circuit. This is the verify-side
defence against the timing-oracle attacks described in Bleichenbacher
2006 (the SHA-1 padding-oracle exploit) and follow-ups.
- The encoded message is reconstructed every call rather than carried
alongside the signature. This is the RFC 8017 §8.2.2 design — the
verifier MUST regenerate `EM` from the public inputs to detect
signature substitution.
- The `verify_duration_ms` histogram is recorded on every code path
(success, rejection, structural error) so service-side dashboards see
the full latency distribution including the failure modes.

View File

@@ -0,0 +1,87 @@
# verify/pss
## Purpose
RSASSA-PSS signature verification per RFC 8017 §8.1.2. Recovers the
encoded message via `s^e mod n`, then runs the EMSA-PSS verifier against
the digest of the message under the given `(hash, mgfHash, saltLength)`
triple.
## Features
- RFC 8017 §8.1.2 conformant
- Uses `emsaPssVerify` for the structural check (which uses `constantTimeEqual` for the salt+hash compare)
- Returns `false` on rejection; throws only on signature length mismatch
- `saltLength` defaults to `hashOutputLength(hash)`
- `mgfHash` defaults to `hash`
- Modulus-length policy gates run on every call
- Records `crypto_rsa.verify_duration_ms{scheme="pss",hash,engine}` and outcome counter on every call
## Usage: Basic Example
```ts
import { rsaVerifyPss } from "@suaveplan/crypto-rsa/verify/pss";
import { rsaSignPss } from "@suaveplan/crypto-rsa/sign/pss";
const message = new TextEncoder().encode("hello");
const sig = rsaSignPss(privateKey, message, { hash: "SHA-256" });
console.log(rsaVerifyPss(publicKey, message, sig, { hash: "SHA-256" })); // true
```
## Usage: Advanced Example
```ts
import { rsaVerifyPss } from "@suaveplan/crypto-rsa/verify/pss";
import { rsaSignPss } from "@suaveplan/crypto-rsa/sign/pss";
// Custom saltLength: must match the value used by the signer.
const sig = rsaSignPss(privateKey, message, { hash: "SHA-256", saltLength: 16 });
console.log(rsaVerifyPss(publicKey, message, sig, { hash: "SHA-256", saltLength: 16 })); // true
console.log(rsaVerifyPss(publicKey, message, sig, { hash: "SHA-256", saltLength: 32 })); // false
// mgfHash distinct from hash.
const xSig = rsaSignPss(privateKey, message, {
hash: "SHA-256",
mgfHash: "SHA-1",
});
console.log(
rsaVerifyPss(publicKey, message, xSig, {
hash: "SHA-256",
mgfHash: "SHA-1",
})
); // true
```
## API Reference
### `rsaVerifyPss(key, message, signature, options): boolean`
- `key: RsaPublicKey | RsaPrivateKey`
- `message: Uint8Array`
- `signature: Uint8Array` — must be exactly `byteLength(n)` long
- `options.hash: RsaHashAlgorithm`
- `options.saltLength?: number` — defaults to `hashOutputLength(hash)`
- `options.mgfHash?: RsaHashAlgorithm` — defaults to `options.hash`
- `options.allowLegacy?: boolean`
- `options.allowOversize?: boolean`
- **returns** `true` on valid, `false` on verify-level rejection
- **throws** `CryptoRsaError(CRYPTO_RSA.INVALID_SIGNATURE)` on signature length mismatch
- **throws** `CryptoRsaError(CRYPTO_RSA.MODULUS_TOO_SHORT)` / `MODULUS_TOO_LARGE` / `UNSUPPORTED_HASH`
## Implementation Notes
- `s = os2ip(signature)` is checked against `s < n` before the modular
exponentiation; signatures with `s >= n` are rejected with `false`.
- The encoded message produced by `i2osp(s^e mod n, emLen)` has length
`emLen = ceil((modulusBits - 1) / 8)`. This is one byte shorter than
the signature itself when `modulusBits` is a multiple of 8 — the
high-order zero pad is stripped here and the leftmost-bit clearance
rule in `emsaPssVerify` enforces that the original encoding had the
expected zero in that position.
- The constant-time guarantee for the final salt+hash compare is
delivered by `emsaPssVerify`'s use of `constantTimeEqual`. The
earlier structural checks (length mismatch, `s >= n`) are guarded by
fast-fail paths that do NOT leak the secret-derived signal — they
test only public inputs.
- The `verify_duration_ms` histogram is recorded on every code path
(success, rejection, structural error).