Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 20 additions & 11 deletions src/shared/utils/secureRandom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@
*
* secureRandomInt(n) === Math.floor(Math.random() * n) // integer in [0, n)
* secureRandomFloat() === Math.random() // float in [0, 1)
*
* The integer helper uses `crypto.randomInt` (unbiased rejection sampling) rather than
* `Math.floor(cryptoFloat() * n)` — dividing/rounding a crypto value introduces modulo bias
* (CodeQL `js/biased-cryptographic-random`).
*/
import { randomBytes } from "node:crypto";
import { randomBytes, randomInt } from "node:crypto";

/** Default source: uniform float in [0, 1) from 48 bits of crypto entropy. */
function cryptoRandomFloat(): number {
Expand All @@ -24,27 +28,32 @@ function cryptoRandomFloat(): number {
return value / 2 ** 48;
}

// Swappable source — production always uses the crypto source; tests inject a deterministic
// sequence via _setSecureRandomFloatSource (mirrors the `_resetAllDecks` test-only export in
// shuffleDeck.ts). secureRandomInt derives from the same float source, so a given injected
// value picks the exact same index that `Math.floor(Math.random() * n)` would have.
let floatSource: () => number = cryptoRandomFloat;
// Test-only deterministic float source — `null` in production (the crypto sources above are
// used directly). Tests inject a fixed sequence via _setSecureRandomFloatSource (mirrors the
// `_resetAllDecks` test-only export). When injected, secureRandomInt derives the index from
// the same float so the deterministic selection tests keep identical assertions; production
// never takes that path, so no crypto value is divided/rounded into a biased integer.
let testFloatSource: (() => number) | null = null;

/** Uniform float in [0, 1) — drop-in for `Math.random()`. */
export function secureRandomFloat(): number {
return floatSource();
return testFloatSource ? testFloatSource() : cryptoRandomFloat();
}

/**
* Uniform integer in [0, maxExclusive) — drop-in for `Math.floor(Math.random() * maxExclusive)`.
* Returns 0 for any `maxExclusive <= 1` (matching `Math.floor(Math.random() * {0,1})`), so
* single-element / empty selections behave exactly as before.
* single-element / empty selections behave exactly as before. Production uses the unbiased
* `crypto.randomInt`; only the test path (deterministic injected source) scales a float.
*/
export function secureRandomInt(maxExclusive: number): number {
if (!Number.isFinite(maxExclusive) || maxExclusive <= 1) return 0;
const max = Math.floor(maxExclusive);
// Scale [0, 1) → [0, max); clamp defends the probability-0 case of a source returning ~1.
return Math.min(max - 1, Math.floor(floatSource() * max));
if (testFloatSource) {
// Deterministic test path — clamp defends the probability-0 case of a source returning ~1.
return Math.min(max - 1, Math.floor(testFloatSource() * max));
}
return randomInt(max);
}

/**
Expand All @@ -53,5 +62,5 @@ export function secureRandomInt(maxExclusive: number): number {
* test-only export convention in shuffleDeck.ts.
*/
export function _setSecureRandomFloatSource(source: (() => number) | null): void {
floatSource = source ?? cryptoRandomFloat;
testFloatSource = source ?? null;
}
Loading