105 lines
4.1 KiB
JavaScript
105 lines
4.1 KiB
JavaScript
import {uint8ArrayToBase64, uint8ArrayToHex} from 'uint8array-extras';
|
|||
|
|
|
||
|
|
// `crypto.getRandomValues` throws an error if too much entropy is requested at once. (https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues#exceptions)
|
||
|
|
const maxBytesPerRequest = 65_536;
|
||
|
|
|
||
|
|
// Each character is picked with a single `Uint16` selector value, so a character set cannot be larger than the range of one.
|
||
|
|
const maxCharacterSetSize = 0x1_00_00;
|
||
|
|
|
||
|
|
function fillWithRandomValues(typedArray) {
|
||
|
|
const maxElementsPerRequest = maxBytesPerRequest / typedArray.BYTES_PER_ELEMENT;
|
||
|
|
|
||
|
|
for (let offset = 0; offset < typedArray.length; offset += maxElementsPerRequest) {
|
||
|
|
// `TypedArray#subarray` clamps the end index and shares the underlying buffer, so this fills the array in place without any copying.
|
||
|
|
crypto.getRandomValues(typedArray.subarray(offset, offset + maxElementsPerRequest));
|
||
|
|
}
|
||
|
|
|
||
|
|
return typedArray;
|
||
|
|
}
|
||
|
|
|
||
|
|
const randomBytes = byteLength => fillWithRandomValues(new Uint8Array(byteLength));
|
||
|
|
|
||
|
|
const generateForCustomCharacters = (length, characters) => {
|
||
|
|
// Generating entropy is faster than complex math operations, so we use the simplest way
|
||
|
|
const characterCount = characters.length;
|
||
|
|
const validSelectorCount = Math.floor(maxCharacterSetSize / characterCount) * characterCount; // Using values at or above this will ruin distribution when using modular division
|
||
|
|
// Generating a bit more than required, adjusted for how many values we expect to discard, so we usually only need one pass
|
||
|
|
const entropyLength = Math.ceil(1.1 * length * (maxCharacterSetSize / validSelectorCount));
|
||
|
|
let string = '';
|
||
|
|
let stringLength = 0;
|
||
|
|
|
||
|
|
while (stringLength < length) {
|
||
|
|
const entropy = fillWithRandomValues(new Uint16Array(entropyLength));
|
||
|
|
|
||
|
|
for (let index = 0; index < entropyLength; index++) {
|
||
|
|
const entropyValue = entropy[index];
|
||
|
|
|
||
|
|
if (entropyValue < validSelectorCount) { // Skip values which will ruin distribution when using modular division
|
||
|
|
string += characters[entropyValue % characterCount];
|
||
|
|
stringLength++;
|
||
|
|
|
||
|
|
if (stringLength === length) {
|
||
|
|
return string;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return string;
|
||
|
|
};
|
||
|
|
|
||
|
|
const characterSets = new Map([
|
||
|
|
['url-safe', [...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~']],
|
||
|
|
['numeric', [...'0123456789']],
|
||
|
|
['distinguishable', [...'CDEHKMPRTUWXY012458']],
|
||
|
|
['ascii-printable', [...'!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~']],
|
||
|
|
['alphanumeric', [...'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789']],
|
||
|
|
]);
|
||
|
|
|
||
|
|
const allowedTypes = new Set(['hex', 'base64', ...characterSets.keys()]);
|
||
|
|
|
||
|
|
export default function cryptoRandomString({length, type, characters}) {
|
||
|
|
if (!Number.isSafeInteger(length) || length < 0) {
|
||
|
|
throw new TypeError('Expected `length` to be a non-negative integer');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (type !== undefined && characters !== undefined) {
|
||
|
|
throw new TypeError('Expected either `type` or `characters`');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (characters !== undefined) {
|
||
|
|
if (typeof characters !== 'string') {
|
||
|
|
throw new TypeError('Expected `characters` to be a string');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Spread to keep characters outside the Basic Multilingual Plane, like emoji, intact.
|
||
|
|
const customCharacterSet = [...characters];
|
||
|
|
|
||
|
|
if (customCharacterSet.length === 0) {
|
||
|
|
throw new TypeError('Expected `characters` to contain at least 1 character');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (customCharacterSet.length > maxCharacterSetSize) {
|
||
|
|
throw new TypeError(`Expected \`characters\` to contain at most ${maxCharacterSetSize} characters, got ${customCharacterSet.length}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
return generateForCustomCharacters(length, customCharacterSet);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (type !== undefined && !allowedTypes.has(type)) {
|
||
|
|
throw new TypeError(`Unknown type: ${type}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const characterSet = characterSets.get(type);
|
||
|
|
|
||
|
|
if (characterSet !== undefined) {
|
||
|
|
return generateForCustomCharacters(length, characterSet);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (type === 'base64') {
|
||
|
|
return uint8ArrayToBase64(randomBytes(Math.ceil(length * 0.75))).slice(0, length); // Needs 0.75 bytes of entropy per character
|
||
|
|
}
|
||
|
|
|
||
|
|
return uint8ArrayToHex(randomBytes(Math.ceil(length * 0.5))).slice(0, length); // Needs 0.5 bytes of entropy per character
|
||
|
|
}
|