Files
suaveplan/packages/security-stack/crypto/crypto-rsa/src/blinding/blinding.ts
2026-05-27 23:47:53 +01:00

145 lines
4.8 KiB
TypeScript

import { invert } from "@noble/curves/abstract/modular";
import type { Rng } from "@suaveplan/types";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaPublicKey } from "../keys/keys.types.js";
import { modPow } from "../math/modpow.js";
/**
* Output of `blindMessage`: the blinded message representative `m * r^e
* mod n` ready to feed into `rsaPrivateOperationCrt`, plus the modular
* inverse `r^-1 mod n` the caller must keep until the private operation
* returns.
*/
export interface BlindResult {
readonly mPrime: bigint;
readonly rInverse: bigint;
}
/**
* RSA blinding (RFC 8017 §5.1.2 informational note). Given a message
* representative `m`, sample a uniform `r ∈ [2, n-1]` coprime with `n`
* and return `m' = m * r^e mod n` together with `r^-1 mod n`.
*
* Wired into `rsaDecryptPkcs1v15` / `rsaDecryptOaep` (and the Stage 2
* sign paths) by default — the `{ blinding: false }` opt-out emits the
* `crypto_rsa.blinding_disabled_total{operation}` counter so production
* dashboards can flag any caller that disables the timing-side-channel
* defence.
*
* The rejection-sampling loop terminates after `MAX_REJECTIONS` attempts
* with `KEYGEN_FAILED` — for a well-formed public key with `n` coprime to
* almost every value, this never fires in practice.
*/
const MAX_REJECTIONS = 1024;
export function blindMessage(
m: bigint,
key: RsaPublicKey,
rng: Rng
): BlindResult {
const n = key.n;
if (m < 0n || m >= n) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.blinding.message-out-of-range"
);
}
let attempts = 0;
while (attempts < MAX_REJECTIONS) {
attempts++;
const r = sampleCoprimeUniform(n, rng);
if (r === undefined) {
continue;
}
// gcd(r, n) === 1 is enforced inside sampleCoprimeUniform, so
// invert always succeeds. The defence-in-depth try/catch was
// removed: no upstream input can drive `invert` to throw here
// and a dead catch block undermines coverage as deliberate
// dead code.
const rInverse = invert(r, n);
const rE = modPow(r, key.e, n);
const mPrime = (m * rE) % n;
return { mPrime, rInverse };
}
throw new CryptoRsaError(
CRYPTO_RSA_CODES.KEYGEN_FAILED,
"error.crypto-rsa.blinding.exhausted"
);
}
/**
* Reverse the blinding step: given the unblinded private-operation
* output `s = (m')^d mod n`, return the true `m^d mod n` by multiplying
* with `r^-1` modulo `n`.
*/
export function unblindResult(s: bigint, rInverse: bigint, n: bigint): bigint {
return (s * rInverse) % n;
}
/**
* Sample a uniform random `r ∈ [2, n-1]` coprime with `n`. Returns
* `undefined` when the candidate happened to share a factor with `n` so
* the caller can retry. Uses byte-wise rejection sampling (drop any
* candidate `≥ n` AND any candidate sharing a factor) for an unbiased
* distribution across `Z*_n`.
*/
function sampleCoprimeUniform(n: bigint, rng: Rng): bigint | undefined {
const bitLen = n.toString(2).length;
const byteLen = Math.ceil(bitLen / 8);
const topMask = (1 << (bitLen - (byteLen - 1) * 8)) - 1;
const bytes = nextBytesViaRng(rng, byteLen);
bytes[0] = (bytes[0] as number) & topMask;
let r = 0n;
for (const b of bytes) {
r = (r << 8n) | BigInt(b);
}
if (r < 2n || r >= n) {
return undefined;
}
if (gcd(r, n) !== 1n) {
return undefined;
}
return r;
}
function gcd(a: bigint, b: bigint): bigint {
// Internal helper — only called with non-negative inputs from
// sampleCoprimeUniform (where r ∈ [2, n-1] and n > 0).
let x = a;
let y = b;
while (y !== 0n) {
[x, y] = [y, x % y];
}
return x;
}
/**
* Pull `n` bytes from an `Rng`. Uses the optional `nextBytes` shortcut
* when present (true for CSPRNG-backed `Rng` values) and falls back to
* `nextUint32` byte-extraction otherwise (so deterministic seeded RNGs
* used in tests work without implementing `nextBytes`).
*/
export function nextBytesViaRng(rng: Rng, n: number): Uint8Array {
if (rng.nextBytes !== undefined) {
const out = rng.nextBytes(n);
if (out.length !== n) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.KEYGEN_FAILED,
"error.crypto-rsa.blinding.rng-short-read"
);
}
return out;
}
const out = new Uint8Array(n);
let i = 0;
while (i < n) {
const u32 = rng.nextUint32() >>> 0;
out[i] = (u32 >>> 24) & 0xff;
if (i + 1 < n) out[i + 1] = (u32 >>> 16) & 0xff;
if (i + 2 < n) out[i + 2] = (u32 >>> 8) & 0xff;
if (i + 3 < n) out[i + 3] = u32 & 0xff;
i += 4;
}
return out;
}