Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
type RsaEncryptOaepOptions,
|
||||
rsaEncryptOaep,
|
||||
} from "./oaep.js";
|
||||
export {
|
||||
type RsaEncryptPkcs1v15Options,
|
||||
rsaEncryptPkcs1v15,
|
||||
} from "./pkcs1v15.js";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
103
packages/security-stack/crypto/crypto-rsa/src/encrypt/oaep.ts
Normal file
103
packages/security-stack/crypto/crypto-rsa/src/encrypt/oaep.ts
Normal 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();
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user