145 lines
4.8 KiB
TypeScript
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;
|
|
}
|