Initial Commit
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user