Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

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,39 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { defaultRng } from "./default-rng.js";
describe("defaultRng", () => {
it("returns a fresh Rng instance per call", { timeout: 5000 }, () => {
const a = defaultRng();
const b = defaultRng();
expect(a).not.toBe(b);
});
it("nextUint32 returns an integer in [0, 2^32)", {
timeout: 5000,
}, () => {
const rng = defaultRng();
for (let i = 0; i < 16; i++) {
const v = rng.nextUint32();
expect(Number.isInteger(v)).toBe(true);
expect(v >= 0 && v < 0x1_0000_0000).toBe(true);
}
});
it("nextFloat returns a value in [0, 1)", {
timeout: 5000,
}, () => {
const rng = defaultRng();
for (let i = 0; i < 16; i++) {
const v = rng.nextFloat();
expect(v >= 0 && v < 1).toBe(true);
}
});
it("nextBytes returns a buffer of the requested length", {
timeout: 5000,
}, () => {
const rng = defaultRng();
const out = rng.nextBytes?.(13);
expect(out?.length).toBe(13);
});
});

View File

@@ -0,0 +1,24 @@
import { defaultByteSource } from "@suaveplan/crypto-utils";
import type { Rng } from "@suaveplan/types";
/**
* Default `Rng` backed by `crypto.getRandomValues` via the
* `@suaveplan/crypto-utils` `defaultByteSource` indirection. Used by every
* encrypt / sign / keygen surface when the caller does not inject one.
*/
export function defaultRng(): Rng {
return {
nextUint32: () => {
const buf = defaultByteSource(4);
return new DataView(buf.buffer, buf.byteOffset, 4).getUint32(0);
},
nextFloat: () => {
const buf = defaultByteSource(4);
return (
new DataView(buf.buffer, buf.byteOffset, 4).getUint32(0) /
0x1_0000_0000
);
},
nextBytes: (n: number) => defaultByteSource(n),
};
}

View File

@@ -0,0 +1,8 @@
export {
type RsaEncryptOaepOptions,
rsaEncryptOaep,
} from "./oaep.js";
export {
type RsaEncryptPkcs1v15Options,
rsaEncryptPkcs1v15,
} from "./pkcs1v15.js";

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,94 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
LEGACY_1024_PRIVATE_KEY,
LEGACY_1024_PUBLIC_KEY,
} from "../__fixtures__/test-keys.js";
import { rsaDecryptOaep } from "../decrypt/oaep.js";
import { CryptoRsaError } from "../errors/errors.js";
import { rsaEncryptOaep } from "./oaep.js";
describe("rsaEncryptOaep", () => {
// SHA-512 OAEP requires modulus ≥ 1040 bits (k - 2*64 - 2 ≥ 1).
// The 1024-bit fixture leaves no room — covered by the
// "rejects oversized plaintext" case below instead.
it.each([
["SHA-256" as const, 32],
["SHA-384" as const, 48],
["SHA-1" as const, 20],
])("round-trips with hash=%s (hLen=%i)", { timeout: 15000 }, (hash) => {
const pt = new TextEncoder().encode("oaep round-trip");
const ct = rsaEncryptOaep(LEGACY_1024_PUBLIC_KEY, pt, {
hash,
allowLegacy: true,
});
expect(ct.length).toBe(128);
const recovered = rsaDecryptOaep(LEGACY_1024_PRIVATE_KEY, ct, {
hash,
allowLegacy: true,
});
expect(Array.from(recovered)).toEqual(Array.from(pt));
});
it("rejects oversized plaintext", { timeout: 5000 }, () => {
const k = 128;
const hLen = 32;
const tooBig = new Uint8Array(k - 2 * hLen - 1);
expect(() =>
rsaEncryptOaep(LEGACY_1024_PUBLIC_KEY, tooBig, {
hash: "SHA-256",
allowLegacy: true,
})
).toThrow(CryptoRsaError);
});
it("SHA-512 against 1024-bit modulus is rejected (modulus too small)", {
timeout: 5000,
}, () => {
// For SHA-512 (hLen=64), k - 2*hLen - 2 = 128 - 130 = -2,
// so even an empty plaintext fails the size gate.
expect(() =>
rsaEncryptOaep(LEGACY_1024_PUBLIC_KEY, new Uint8Array(0), {
hash: "SHA-512",
allowLegacy: true,
})
).toThrow(CryptoRsaError);
});
it("non-empty label round-trips", { timeout: 10000 }, () => {
const pt = new Uint8Array([7, 7, 7]);
const label = new TextEncoder().encode("ctx");
const ct = rsaEncryptOaep(LEGACY_1024_PUBLIC_KEY, pt, {
hash: "SHA-256",
label,
allowLegacy: true,
});
const recovered = rsaDecryptOaep(LEGACY_1024_PRIVATE_KEY, ct, {
hash: "SHA-256",
label,
allowLegacy: true,
});
expect(Array.from(recovered)).toEqual(Array.from(pt));
});
it("mgfHash overrides default to hash", { timeout: 10000 }, () => {
const pt = new Uint8Array([1]);
const ct = rsaEncryptOaep(LEGACY_1024_PUBLIC_KEY, pt, {
hash: "SHA-256",
mgfHash: "SHA-1",
allowLegacy: true,
});
const recovered = rsaDecryptOaep(LEGACY_1024_PRIVATE_KEY, ct, {
hash: "SHA-256",
mgfHash: "SHA-1",
allowLegacy: true,
});
expect(Array.from(recovered)).toEqual(Array.from(pt));
});
it("policy rejects sub-1024 keys", { timeout: 5000 }, () => {
const tinyKey = { n: 60491n, e: 65537n, bitLength: 16 };
expect(() => rsaEncryptOaep(tinyKey, new Uint8Array(1))).toThrow(
CryptoRsaError
);
});
});

View File

@@ -0,0 +1,103 @@
import { xor } from "@suaveplan/crypto-utils";
import type { Rng } from "@suaveplan/types";
import { nextBytesViaRng } from "../blinding/blinding.js";
import { digestWith, hashOutputLength, mgf1 } from "../encoding/mgf1.js";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaHashAlgorithm, RsaPublicKey } from "../keys/keys.types.js";
import { i2osp } from "../math/i2osp.js";
import { modPow } from "../math/modpow.js";
import { os2ip } from "../math/os2ip.js";
import {
recordEncryptDuration,
recordThrown,
} from "../observability/metrics.js";
import { enforceMaxBitLength, enforceMinBitLength } from "../policy/modulus.js";
import { defaultRng } from "./default-rng.js";
/**
* Optional input for `rsaEncryptOaep`. Defaults: `hash: "SHA-256"`,
* `mgfHash` defaults to `hash`, `label: empty`, `rng` defaults to the
* CSPRNG-backed `defaultRng()`.
*/
export interface RsaEncryptOaepOptions {
readonly hash?: RsaHashAlgorithm;
readonly mgfHash?: RsaHashAlgorithm;
readonly label?: Uint8Array;
readonly rng?: Rng;
readonly allowLegacy?: boolean;
}
const EMPTY = new Uint8Array(0);
/**
* RSAES-OAEP encryption per RFC 8017 §7.1.1.
*
* Builds the encoded message `EM = 0x00 || maskedSeed || maskedDB`
* where `DB = lHash || PS || 0x01 || M`, masks `DB` with
* `MGF(seed, k - hLen - 1)`, and masks `seed` with
* `MGF(maskedDB, hLen)`. Modular exponentiates `m = OS2IP(EM)` to the
* public exponent and emits `I2OSP(c, k)`.
*
* Throws `CRYPTO_RSA.INVALID_KEY` when `mLen > k - 2*hLen - 2`.
*/
export function rsaEncryptOaep(
key: RsaPublicKey,
plaintext: Uint8Array,
options: RsaEncryptOaepOptions = {}
): Uint8Array {
const start = nowMs();
const hash = options.hash ?? "SHA-256";
const mgfHash = options.mgfHash ?? hash;
try {
enforceMinBitLength(
key,
options.allowLegacy === undefined
? {}
: { allowLegacy: options.allowLegacy }
);
enforceMaxBitLength(key);
const k = byteLength(key);
const hLen = hashOutputLength(hash);
const label = options.label ?? EMPTY;
if (plaintext.length > k - 2 * hLen - 2) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.encrypt.plaintext-too-long",
{ context: { mLen: plaintext.length, max: k - 2 * hLen - 2 } }
);
}
const lHash = digestWith(hash, label);
const psLen = k - plaintext.length - 2 * hLen - 2;
const db = new Uint8Array(k - hLen - 1);
db.set(lHash, 0);
// PS is psLen zero bytes (already initialised).
db[hLen + psLen] = 0x01;
db.set(plaintext, hLen + psLen + 1);
const rng = options.rng ?? defaultRng();
const seed = nextBytesViaRng(rng, hLen);
const dbMask = mgf1(seed, k - hLen - 1, mgfHash);
const maskedDb = xor(db, dbMask);
const seedMask = mgf1(maskedDb, hLen, mgfHash);
const maskedSeed = xor(seed, seedMask);
const em = new Uint8Array(k);
em[0] = 0x00;
em.set(maskedSeed, 1);
em.set(maskedDb, 1 + hLen);
const m = os2ip(em);
const c = modPow(m, key.e, key.n);
const out = i2osp(c, k);
recordEncryptDuration("oaep", hash, nowMs() - start);
return out;
} catch (err) {
recordThrown(err);
throw err;
}
}
function byteLength(key: RsaPublicKey): number {
return Math.ceil(key.bitLength / 8);
}
function nowMs(): number {
return performance.now();
}

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,98 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
LEGACY_1024_PRIVATE_KEY,
LEGACY_1024_PUBLIC_KEY,
} from "../__fixtures__/test-keys.js";
import { rsaDecryptPkcs1v15 } from "../decrypt/pkcs1v15.js";
import { CryptoRsaError } from "../errors/errors.js";
import { rsaEncryptPkcs1v15 } from "./pkcs1v15.js";
describe("rsaEncryptPkcs1v15", () => {
it("round-trips through rsaDecryptPkcs1v15", { timeout: 10000 }, () => {
const plaintext = new TextEncoder().encode("hello, RSA!");
const ct = rsaEncryptPkcs1v15(LEGACY_1024_PUBLIC_KEY, plaintext, {
allowLegacy: true,
});
expect(ct.length).toBe(128); // 1024 / 8
const pt = rsaDecryptPkcs1v15(LEGACY_1024_PRIVATE_KEY, ct, {
allowLegacy: true,
});
expect(Array.from(pt)).toEqual(Array.from(plaintext));
});
it("rejects oversized plaintext", { timeout: 5000 }, () => {
const k = 128;
const tooBig = new Uint8Array(k - 10); // mLen > k - 11
expect(() =>
rsaEncryptPkcs1v15(LEGACY_1024_PUBLIC_KEY, tooBig, {
allowLegacy: true,
})
).toThrow(CryptoRsaError);
});
it("ciphertext length always equals modulus byte length", {
timeout: 10000,
}, () => {
for (const len of [0, 1, 16, 64, 117]) {
const ct = rsaEncryptPkcs1v15(
LEGACY_1024_PUBLIC_KEY,
new Uint8Array(len),
{ allowLegacy: true }
);
expect(ct.length).toBe(128);
}
});
it("uses provided rng deterministically", { timeout: 10000 }, () => {
const seedRng = (seed: number) => {
let s = seed >>> 0;
return {
nextUint32: () =>
(s = (Math.imul(s, 1664525) + 1013904223) >>> 0),
nextFloat: () => 0,
nextBytes: (n: number) => {
const out = new Uint8Array(n);
for (let i = 0; i < n; i++) {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
out[i] = (s >>> 24) & 0xff;
}
return out;
},
};
};
const plaintext = new Uint8Array([1, 2, 3, 4]);
const a = rsaEncryptPkcs1v15(LEGACY_1024_PUBLIC_KEY, plaintext, {
rng: seedRng(123),
allowLegacy: true,
});
const b = rsaEncryptPkcs1v15(LEGACY_1024_PUBLIC_KEY, plaintext, {
rng: seedRng(123),
allowLegacy: true,
});
expect(Array.from(a)).toEqual(Array.from(b));
});
it("policy gate rejects sub-1024 keys", { timeout: 5000 }, () => {
const tinyKey = {
n: 60491n,
e: 65537n,
bitLength: 16,
};
expect(() => rsaEncryptPkcs1v15(tinyKey, new Uint8Array(1))).toThrow(
CryptoRsaError
);
});
it("non-CryptoRsa error surfaces unchanged via catch branch", {
timeout: 5000,
}, () => {
// n = 0n triggers a RangeError from noble's modPow — exercises
// the false branch of the catch's instanceof check.
const malformed = { n: 0n, e: 65537n, bitLength: 1024 };
expect(() =>
rsaEncryptPkcs1v15(malformed, new Uint8Array(1), {
allowLegacy: true,
})
).toThrow();
});
});

View File

@@ -0,0 +1,111 @@
import type { Rng } from "@suaveplan/types";
import { blindMessage, nextBytesViaRng } from "../blinding/blinding.js";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaPublicKey } from "../keys/keys.types.js";
import { i2osp } from "../math/i2osp.js";
import { modPow } from "../math/modpow.js";
import { os2ip } from "../math/os2ip.js";
import {
recordEncryptDuration,
recordThrown,
} from "../observability/metrics.js";
import { enforceMaxBitLength, enforceMinBitLength } from "../policy/modulus.js";
import { defaultRng } from "./default-rng.js";
/**
* Optional input for `rsaEncryptPkcs1v15`. The `rng` slot lets callers
* inject deterministic randomness for tests; production should leave it
* undefined so the CSPRNG-backed default kicks in.
*/
export interface RsaEncryptPkcs1v15Options {
readonly rng?: Rng;
/** Permit 1024-bit moduli (legacy WebAuthn TPM). */
readonly allowLegacy?: boolean;
}
/**
* RSAES-PKCS1-v1_5 encryption per RFC 8017 §7.2.1.
*
* Pads the plaintext as `0x00 || 0x02 || PS || 0x00 || M` where `PS` is
* `k - mLen - 3` non-zero random bytes and `k` is the modulus byte
* length. Throws `CRYPTO_RSA.INVALID_KEY` when the plaintext exceeds
* `k - 11` bytes.
*
* Modern callers should prefer `rsaEncryptOaep` — PKCS1-v1_5 encryption
* is retained for legacy interop only (TLS pre-handshake, JWE `RSA1_5`
* legacy mode). Decryption applies the Bleichenbacher-safe combined
* error path; encryption is plain padding.
*/
export function rsaEncryptPkcs1v15(
key: RsaPublicKey,
plaintext: Uint8Array,
options: RsaEncryptPkcs1v15Options = {}
): Uint8Array {
const start = nowMs();
try {
enforceMinBitLength(
key,
options.allowLegacy === undefined
? {}
: { allowLegacy: options.allowLegacy }
);
enforceMaxBitLength(key);
const k = byteLength(key);
if (plaintext.length > k - 11) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.encrypt.plaintext-too-long",
{
context: { mLen: plaintext.length, max: k - 11 },
}
);
}
const rng = options.rng ?? defaultRng();
const psLength = k - plaintext.length - 3;
const ps = nonZeroPadding(rng, psLength);
const em = new Uint8Array(k);
em[0] = 0x00;
em[1] = 0x02;
em.set(ps, 2);
em[2 + psLength] = 0x00;
em.set(plaintext, 3 + psLength);
const m = os2ip(em);
const c = modPow(m, key.e, key.n);
const out = i2osp(c, k);
recordEncryptDuration("pkcs1v15", "n/a", nowMs() - start);
return out;
} catch (err) {
recordThrown(err);
throw err;
}
}
function byteLength(key: RsaPublicKey): number {
return Math.ceil(key.bitLength / 8);
}
function nonZeroPadding(rng: Rng, length: number): Uint8Array {
const out = new Uint8Array(length);
let filled = 0;
while (filled < length) {
// Draw 2x what we still need so non-zero rejections rarely loop twice.
const need = (length - filled) * 2;
const draw = nextBytesViaRng(rng, Math.max(8, need));
for (const b of draw) {
if (b !== 0) {
out[filled++] = b;
if (filled === length) break;
}
}
}
return out;
}
function nowMs(): number {
return performance.now();
}
// Re-export the wired blinding helper for downstream sign-path consumers
// (Stage 2). Encryption itself does not blind — only the private operation
// (decrypt / sign) leaks via timing.
export { blindMessage };