150 lines
5.1 KiB
TypeScript
150 lines
5.1 KiB
TypeScript
import {
|
|
asUint8Array,
|
|
assertBoundedBytes,
|
|
assertBoundedItems,
|
|
assertPositiveSafeInteger,
|
|
type BytesLike,
|
|
} from "./limits";
|
|
|
|
export type RandomSeed = string | number | bigint | BytesLike;
|
|
|
|
export interface SeededRandom {
|
|
nextUint32(): number;
|
|
nextFloat(): number;
|
|
integer(minimum: number, maximumExclusive: number): number;
|
|
bytes(length: number): Uint8Array<ArrayBuffer>;
|
|
shuffle<T>(values: readonly T[]): T[];
|
|
state(): readonly [number, number, number, number];
|
|
}
|
|
|
|
export function secureRandomBytes(
|
|
length: number,
|
|
cryptoProvider: Pick<Crypto, "getRandomValues"> = crypto,
|
|
maximumLength = 1024 * 1024,
|
|
): Uint8Array<ArrayBuffer> {
|
|
assertPositiveSafeInteger(maximumLength, "Maximum random byte length");
|
|
if (!Number.isSafeInteger(length) || length < 0)
|
|
throw new TypeError(
|
|
"Random byte length must be a non-negative safe integer",
|
|
);
|
|
if (length > maximumLength)
|
|
throw new RangeError(`Random byte length exceeds ${maximumLength}`);
|
|
const output = new Uint8Array(length);
|
|
for (let offset = 0; offset < output.length; offset += 65_536) {
|
|
cryptoProvider.getRandomValues(
|
|
output.subarray(offset, Math.min(output.length, offset + 65_536)),
|
|
);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
export function secureRandomInt(
|
|
minimum: number,
|
|
maximumExclusive: number,
|
|
cryptoProvider: Pick<Crypto, "getRandomValues"> = crypto,
|
|
): number {
|
|
validateIntegerRange(minimum, maximumExclusive);
|
|
const range = maximumExclusive - minimum;
|
|
if (range > 0x1_0000_0000)
|
|
throw new RangeError("Secure random integer range cannot exceed 2^32");
|
|
const limit = Math.floor(0x1_0000_0000 / range) * range;
|
|
const sample = new Uint32Array(1);
|
|
do cryptoProvider.getRandomValues(sample);
|
|
while ((sample[0] ?? 0) >= limit);
|
|
return minimum + ((sample[0] ?? 0) % range);
|
|
}
|
|
|
|
export function createSeededRandom(seed: RandomSeed): SeededRandom {
|
|
const state = seedState(seed);
|
|
const nextUint32 = () => {
|
|
const result = Math.imul(rotl(Math.imul(state[1] ?? 0, 5), 7), 9) >>> 0;
|
|
const temporary = ((state[1] ?? 0) << 9) >>> 0;
|
|
state[2] = ((state[2] ?? 0) ^ (state[0] ?? 0)) >>> 0;
|
|
state[3] = ((state[3] ?? 0) ^ (state[1] ?? 0)) >>> 0;
|
|
state[1] = ((state[1] ?? 0) ^ (state[2] ?? 0)) >>> 0;
|
|
state[0] = ((state[0] ?? 0) ^ (state[3] ?? 0)) >>> 0;
|
|
state[2] = ((state[2] ?? 0) ^ temporary) >>> 0;
|
|
state[3] = rotl(state[3] ?? 0, 11);
|
|
return result;
|
|
};
|
|
const integer = (minimum: number, maximumExclusive: number) => {
|
|
validateIntegerRange(minimum, maximumExclusive);
|
|
const range = maximumExclusive - minimum;
|
|
if (range > 0x1_0000_0000)
|
|
throw new RangeError("Seeded integer range cannot exceed 2^32");
|
|
const limit = Math.floor(0x1_0000_0000 / range) * range;
|
|
let sample: number;
|
|
do sample = nextUint32();
|
|
while (sample >= limit);
|
|
return minimum + (sample % range);
|
|
};
|
|
return {
|
|
nextUint32,
|
|
nextFloat: () => nextUint32() / 0x1_0000_0000,
|
|
integer,
|
|
bytes(length) {
|
|
assertBoundedItems(length, 1024 * 1024, "Random byte length");
|
|
const output = new Uint8Array(length);
|
|
let word = 0;
|
|
for (let index = 0; index < length; index += 1) {
|
|
if (index % 4 === 0) word = nextUint32();
|
|
output[index] = word & 0xff;
|
|
word >>>= 8;
|
|
}
|
|
return output;
|
|
},
|
|
shuffle<T>(values: readonly T[]): T[] {
|
|
assertBoundedItems(values.length);
|
|
const output = [...values];
|
|
for (let index = output.length - 1; index > 0; index -= 1) {
|
|
const swap = integer(0, index + 1);
|
|
[output[index], output[swap]] = [output[swap]!, output[index]!];
|
|
}
|
|
return output;
|
|
},
|
|
state: () =>
|
|
[state[0] ?? 0, state[1] ?? 0, state[2] ?? 0, state[3] ?? 0] as const,
|
|
};
|
|
}
|
|
|
|
export function shuffleSeeded<T>(values: readonly T[], seed: RandomSeed): T[] {
|
|
return createSeededRandom(seed).shuffle(values);
|
|
}
|
|
|
|
function seedState(seed: RandomSeed): Uint32Array<ArrayBuffer> {
|
|
let bytes: Uint8Array<ArrayBufferLike>;
|
|
if (typeof seed === "string") bytes = new TextEncoder().encode(seed);
|
|
else if (typeof seed === "number" || typeof seed === "bigint")
|
|
bytes = new TextEncoder().encode(String(seed));
|
|
else bytes = assertBoundedBytes(seed, 1024 * 1024, "Seed byte length");
|
|
if (bytes.byteLength === 0) bytes = asUint8Array(new Uint8Array([0]));
|
|
const state = new Uint32Array(4);
|
|
let hash = 0x811c9dc5;
|
|
for (let lane = 0; lane < state.length; lane += 1) {
|
|
for (const byte of bytes) {
|
|
hash ^= byte + lane;
|
|
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
hash ^= hash >>> 13;
|
|
}
|
|
hash = Math.imul(hash ^ (hash >>> 16), 0x85ebca6b) >>> 0;
|
|
state[lane] = hash || 0x9e3779b9 ^ lane;
|
|
}
|
|
return state;
|
|
}
|
|
|
|
function rotl(value: number, count: number): number {
|
|
return ((value << count) | (value >>> (32 - count))) >>> 0;
|
|
}
|
|
|
|
function validateIntegerRange(minimum: number, maximumExclusive: number): void {
|
|
if (
|
|
!Number.isSafeInteger(minimum) ||
|
|
!Number.isSafeInteger(maximumExclusive) ||
|
|
maximumExclusive <= minimum
|
|
) {
|
|
throw new RangeError(
|
|
"Random integer bounds must be safe integers with maximum greater than minimum",
|
|
);
|
|
}
|
|
}
|