diff --git a/yarn-project/foundation/src/crypto/random/index.test.ts b/yarn-project/foundation/src/crypto/random/index.test.ts index 7aa2c7403744..a7102621f0fc 100644 --- a/yarn-project/foundation/src/crypto/random/index.test.ts +++ b/yarn-project/foundation/src/crypto/random/index.test.ts @@ -1,4 +1,4 @@ -import { randomBytes } from './index.js'; +import { randomBigInt, randomBoolean, randomBytes, randomInt } from './index.js'; describe('random', () => { it('randomBytes returns a filled byte array', () => { @@ -10,4 +10,71 @@ describe('random', () => { } expect(identical).toEqual(false); }); + + describe('randomInt', () => { + it('stays within bounds', () => { + for (const max of [1, 2, 3, 100, 255, 256, 257, 1000, 2 ** 32]) { + for (let i = 0; i < 200; i++) { + const value = randomInt(max); + expect(Number.isInteger(value)).toBe(true); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(max); + } + } + }); + + it('covers the whole range for maxima above 2^48', () => { + const max = Number.MAX_SAFE_INTEGER; + const values = Array.from({ length: 500 }, () => randomInt(max)); + expect(Math.max(...values)).toBeGreaterThan(2 ** 48); + }); + + it('covers every value of a small range', () => { + const seen = new Set(Array.from({ length: 500 }, () => randomInt(3))); + expect([...seen].sort()).toEqual([0, 1, 2]); + }); + + it('returns zero for a max of one', () => { + expect(randomInt(1)).toEqual(0); + }); + + it('rejects a non-positive or unsafe max', () => { + expect(() => randomInt(0)).toThrow(RangeError); + expect(() => randomInt(-1)).toThrow(RangeError); + expect(() => randomInt(1.5)).toThrow(RangeError); + expect(() => randomInt(2 ** 53)).toThrow(RangeError); + }); + }); + + describe('randomBigInt', () => { + it('stays within bounds', () => { + for (const max of [1n, 2n, 3n, 100n, 256n, 1n << 64n]) { + for (let i = 0; i < 200; i++) { + const value = randomBigInt(max); + expect(value).toBeGreaterThanOrEqual(0n); + expect(value).toBeLessThan(max); + } + } + }); + + it('covers the whole range for maxima above 2^64', () => { + const max = 1n << 200n; + const values = Array.from({ length: 500 }, () => randomBigInt(max)); + expect(values.reduce((a, b) => (a > b ? a : b))).toBeGreaterThan(1n << 64n); + }); + + it('returns zero for a max of one', () => { + expect(randomBigInt(1n)).toEqual(0n); + }); + + it('rejects a non-positive max', () => { + expect(() => randomBigInt(0n)).toThrow(RangeError); + expect(() => randomBigInt(-1n)).toThrow(RangeError); + }); + }); + + it('randomBoolean returns both values', () => { + const seen = new Set(Array.from({ length: 200 }, () => randomBoolean())); + expect([...seen].sort()).toEqual([false, true]); + }); }); diff --git a/yarn-project/foundation/src/crypto/random/index.ts b/yarn-project/foundation/src/crypto/random/index.ts index 6924fd5d974a..48a5b3578558 100644 --- a/yarn-project/foundation/src/crypto/random/index.ts +++ b/yarn-project/foundation/src/crypto/random/index.ts @@ -1,47 +1,53 @@ import { randomBytes as bbRandomBytes } from '@aztec/bb.js'; -import { RandomnessSingleton } from './randomness_singleton.js'; - -export const randomBytes = (len: number) => { - const singleton = RandomnessSingleton.getInstance(); - - if (singleton.isDeterministic()) { - return singleton.getBytes(len); - } - return Buffer.from(bbRandomBytes(len)) as Buffer; -}; +import { toBigIntBE } from '../../bigint-buffer/index.js'; /** - * Generate a random integer less than max. - * @param max - The maximum value. - * @returns A random integer. - * - * TODO(#3949): This is insecure as it's modulo biased. Nuke or safeguard before mainnet. + * Generate a buffer of cryptographically secure random bytes. + * @param len - The number of bytes to generate. */ -export const randomInt = (max: number) => { - const randomBuffer = randomBytes(6); // Generate a buffer of 6 random bytes. - const randomInt = parseInt(randomBuffer.toString('hex'), 16); // Convert buffer to a large integer. - return randomInt % max; // Use modulo to ensure the result is less than max. -}; +export function randomBytes(len: number): Buffer { + return Buffer.from(bbRandomBytes(len)) as Buffer; +} /** - * Generate a random bigint less than max. - * @param max - The maximum value. - * @returns A random bigint. - * - * TODO(#3949): This is insecure as it's modulo biased. Nuke or safeguard before mainnet. + * Generate a uniformly distributed random bigint in the range [0, max). + * @param max - The exclusive upper bound, which must be positive. */ -export const randomBigInt = (max: bigint) => { - const randomBuffer = randomBytes(8); // Generate a buffer of 8 random bytes. - const randomBigInt = BigInt(`0x${randomBuffer.toString('hex')}`); // Convert buffer to a large integer. - return randomBigInt % max; // Use modulo to ensure the result is less than max. -}; +export function randomBigInt(max: bigint): bigint { + if (max <= 0n) { + throw new RangeError(`randomBigInt requires a positive max, got ${max}`); + } + if (max === 1n) { + return 0n; + } + const bits = BigInt((max - 1n).toString(2).length); + const mask = (1n << bits) - 1n; + const bytes = Number((bits + 7n) / 8n); + // Rejection sampling. Masking the draw down to ceil(log2(max)) bits keeps the acceptance + // probability above 1/2, so this loops fewer than 2 times on average. Sampling a fixed width and + // reducing modulo max would instead bias the low end of the range, and would silently cap the + // result at the sample width for maxima wider than it. + for (;;) { + const candidate = toBigIntBE(randomBytes(bytes)) & mask; + if (candidate < max) { + return candidate; + } + } +} /** - * Generate a random boolean value. - * @returns A random boolean value. + * Generate a uniformly distributed random integer in the range [0, max). + * @param max - The exclusive upper bound, which must be a positive safe integer. */ -export const randomBoolean = () => { - const randomByte = randomBytes(1)[0]; // Generate a single random byte. - return randomByte % 2 === 0; // Use modulo to determine if the byte is even or odd. -}; +export function randomInt(max: number): number { + if (!Number.isSafeInteger(max) || max <= 0) { + throw new RangeError(`randomInt requires a positive safe integer max, got ${max}`); + } + return Number(randomBigInt(BigInt(max))); +} + +/** Generate a random boolean value. */ +export function randomBoolean(): boolean { + return randomBytes(1)[0] % 2 === 0; +} diff --git a/yarn-project/foundation/src/crypto/random/randomness_singleton.ts b/yarn-project/foundation/src/crypto/random/randomness_singleton.ts deleted file mode 100644 index ed2b2358d751..000000000000 --- a/yarn-project/foundation/src/crypto/random/randomness_singleton.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { type Logger, type LoggerBindings, createLogger } from '../../log/pino-logger.js'; - -/** - * A number generator which is used as a source of randomness in the system. If the SEED env variable is set, the - * generator will be deterministic and will always produce the same sequence of numbers. Otherwise a true randomness - * sourced by crypto library will be used. - * @remarks This class was implemented so that tests can be run deterministically. - * - * TODO(#3949): This is not safe enough for production and should be made safer or removed before mainnet. - */ -export class RandomnessSingleton { - private static instance: RandomnessSingleton; - - private counter = 0; - private log: Logger; - - private constructor( - private readonly seed?: number, - bindings?: LoggerBindings, - ) { - this.log = createLogger('foundation:randomness_singleton', bindings); - if (seed !== undefined) { - this.log.debug(`Using pseudo-randomness with seed: ${seed}`); - this.counter = seed; - } else { - this.log.debug('Using true randomness'); - } - } - - public static getInstance(bindings?: LoggerBindings): RandomnessSingleton { - if (!RandomnessSingleton.instance) { - const seed = process.env.SEED ? Number(process.env.SEED) : undefined; - RandomnessSingleton.instance = new RandomnessSingleton(seed, bindings); - } - - return RandomnessSingleton.instance; - } - - /** - * Indicates whether the generator is deterministic (was seeded) or not. - * @returns Whether the generator is deterministic. - */ - public isDeterministic(): boolean { - return this.seed !== undefined; - } - - public getBytes(length: number): Buffer { - if (this.seed === undefined) { - // Note: It would be more natural to just have the contents of randomBytes(...) function from - // yarn-project/foundation/src/crypto/random/index.ts here but that would result in a larger - // refactor so I think prohibiting use of this func when the seed is undefined is and handling - // the singleton within randomBytes func is fine. - throw new Error('RandomnessSingleton is not implemented for non-deterministic mode'); - } - const result = Buffer.alloc(length); - for (let i = 0; i < length; i++) { - // Each byte of the buffer is set to a 1 byte of this.counter's value. 0xff is 255 in decimal and it's used as - // a mask to get the last 8 bits of the shifted counter. - result[i] = (this.counter >> (i * 8)) & 0xff; - } - this.counter++; - return result; - } -}