feat: implement strong randomInt that works in browser without need for polyfill lib

This commit is contained in:
Peter Sanderson
2024-10-16 12:57:54 +02:00
committed by Peter Sanderson
parent 560dd755d0
commit 3f4312e834
4 changed files with 142 additions and 0 deletions

View File

@@ -38,5 +38,8 @@
},
"dependencies": {
"bignumber.js": "^9.1.2"
},
"browser": {
"crypto": false
}
}

View File

@@ -0,0 +1,58 @@
import { getRandomValues as cryptoGetRandomValues } from 'crypto';
/**
* Before changing anything here, see the Modulo Bias problem!
* @see https://research.kudelskisecurity.com/2020/07/28/the-definitive-guide-to-modulo-bias-and-how-to-avoid-it/
*
* @param min Inclusive
* @param max Exclusive (to match the crypto.randomInt() function API
*/
export const getRandomInt = (min: number, max: number) => {
if (!Number.isSafeInteger(min)) {
throw new RangeError(
`The "min" argument must be a safe integer. Received type ${typeof min} (${min})`,
);
}
if (!Number.isSafeInteger(max)) {
throw new RangeError(
`The "max" argument must be a safe integer. Received type ${typeof max} (${max})`,
);
}
if (min >= max) {
throw new RangeError(
`The value of "max" is out of range. It must be greater than the value of "min" (${min}). Received ${max}`,
);
}
const MAX_RANGE_32_BITS = 0xffffffff + 1;
const range = max - min;
if (range > MAX_RANGE_32_BITS) {
throw new RangeError(
`This function only provide 32 bits of entropy, therefore range cannot be more then 2^32.`,
);
}
const getRandomValues =
typeof window !== 'undefined'
? (array: Uint32Array) => window.crypto.getRandomValues(array)
: (array: Uint32Array) => cryptoGetRandomValues(array);
const array = new Uint32Array(1); // This provides 32 bits of entropy.
// It is crucial to avoid modulo bias.
// See: https://research.kudelskisecurity.com/2020/07/28/the-definitive-guide-to-modulo-bias-and-how-to-avoid-it/
// We calculate the maximum possible value that can be evenly distributed across the desired range.
const maxRange = MAX_RANGE_32_BITS - (MAX_RANGE_32_BITS % range);
let randomValue: number;
do {
getRandomValues(array);
randomValue = array[0];
} while (randomValue >= maxRange);
return min + (randomValue % range);
};

View File

@@ -21,6 +21,7 @@ export * from './getMutex';
export * from './getNumberFromPixelString';
export * from './getWeakRandomNumberInRange';
export * from './getSynchronize';
export * from './getRandomInt';
export * from './getWeakRandomId';
export * from './getWeakRandomInt';
export * from './hasUppercaseLetter';

View File

@@ -0,0 +1,80 @@
import { randomInt } from 'crypto';
import { getRandomInt } from '../src/getRandomInt';
describe(getRandomInt.name, () => {
it('raises same error as randomInt from crypto when max <= min', () => {
const EXPECTED_ERROR = new RangeError(
'The value of "max" is out of range. It must be greater than the value of "min" (0). Received -1',
);
expect(() => randomInt(0, -1)).toThrowError(EXPECTED_ERROR);
expect(() => getRandomInt(0, -1)).toThrowError(EXPECTED_ERROR);
});
it('raises error for range > 2^32', () => {
const EXPECTED_ERROR = new RangeError(
'This function only provide 32 bits of entropy, therefore range cannot be more then 2^32.',
);
expect(() => getRandomInt(0, Math.pow(2, 32) + 1)).toThrowError(EXPECTED_ERROR);
});
it('raises same error for unsafe integer', () => {
const UNSAFE_INTEGER = 2 ** 53;
const EXPECTED_ERROR_MIN = new RangeError(
'The "min" argument must be a safe integer. Received type number (9007199254740992)',
);
expect(() => randomInt(UNSAFE_INTEGER, UNSAFE_INTEGER + 1)).toThrowError(
EXPECTED_ERROR_MIN,
);
expect(() => getRandomInt(UNSAFE_INTEGER, UNSAFE_INTEGER + 1)).toThrowError(
EXPECTED_ERROR_MIN,
);
const EXPECTED_ERROR_MAX = new RangeError(
'The "max" argument must be a safe integer. Received type number (9007199254740992)',
);
const EXPECTED_ERROR_MAX_NEGATIVE = new RangeError(
'The "max" argument must be a safe integer. Received type number (-9007199254740992)',
);
expect(() => randomInt(0, UNSAFE_INTEGER)).toThrowError(EXPECTED_ERROR_MAX);
expect(() => getRandomInt(0, UNSAFE_INTEGER)).toThrowError(EXPECTED_ERROR_MAX);
expect(() => randomInt(0, -UNSAFE_INTEGER)).toThrowError(EXPECTED_ERROR_MAX_NEGATIVE);
expect(() => getRandomInt(0, -UNSAFE_INTEGER)).toThrowError(EXPECTED_ERROR_MAX_NEGATIVE);
});
// This test takes 100+seconds to run. It is very needed for development and debugging,
// but due to the time it takes, it is skipped in CI.
it.skip('return value in given range and in uniform distribution', () => {
const SAMPLES = 1000_000;
const RANGE = 100;
const MIN = 0;
const MAX = MIN + RANGE;
const TOLERANCE = 0.1;
const EXPECTED = SAMPLES / RANGE;
const LOWER_BOUND = (1 - TOLERANCE) * EXPECTED;
const UPPER_BOUND = (1 + TOLERANCE) * EXPECTED;
const distribution = new Map<number, number>();
for (let i = 0; i < SAMPLES; i++) {
const result = getRandomInt(MIN, MAX);
distribution.set(result, (distribution.get(result) ?? 0) + 1);
expect(Number.isInteger(result)).toBe(true);
expect(result).toBeGreaterThanOrEqual(MIN);
expect(result).toBeLessThan(MAX);
}
Array.from(distribution.keys()).forEach(key => {
expect(distribution.get(key)).toBeGreaterThanOrEqual(LOWER_BOUND);
expect(distribution.get(key)).toBeLessThanOrEqual(UPPER_BOUND);
});
});
});