diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 41fe626..6a82b18 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,11 +10,11 @@ jobs: fail-fast: false matrix: node-version: - - 14 - - 12 + - 18 + - 16 steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v1 + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} - run: npm install diff --git a/browser.js b/browser.js index f621fb7..a42c0c8 100644 --- a/browser.js +++ b/browser.js @@ -1,32 +1,23 @@ /* eslint-env browser */ import {createStringGenerator, createAsyncStringGenerator} from './core.js'; -const toHex = uInt8Array => uInt8Array.map(byte => byte.toString(16).padStart(2, '0')).join(''); - -const decoder = new TextDecoder('utf8'); -const toBase64 = uInt8Array => btoa(decoder.decode(uInt8Array)); +const toHex = uInt8Array => [...uInt8Array].map(byte => byte.toString(16).padStart(2, '0')).join(''); +const toBase64 = uInt8Array => btoa(String.fromCodePoint(...uInt8Array)); // `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 maxEntropy = 65536; +const maxEntropy = 65_536; function getRandomValues(byteLength) { - const generatedBytes = []; + const generatedBytes = new Uint8Array(byteLength); - while (byteLength > 0) { - const bytesToGenerate = Math.min(byteLength, maxEntropy); - generatedBytes.push(crypto.getRandomValues(new Uint8Array({length: bytesToGenerate}))); - byteLength -= bytesToGenerate; + for (let totalGeneratedBytes = 0; totalGeneratedBytes < byteLength; totalGeneratedBytes += maxEntropy) { + generatedBytes.set( + crypto.getRandomValues(new Uint8Array(Math.min(maxEntropy, byteLength - totalGeneratedBytes))), + totalGeneratedBytes, + ); } - const result = new Uint8Array(generatedBytes.reduce((sum, {byteLength}) => sum + byteLength, 0)); // eslint-disable-line unicorn/no-array-reduce - let currentIndex = 0; - - for (const bytes of generatedBytes) { - result.set(bytes, currentIndex); - currentIndex += bytes.byteLength; - } - - return result; + return generatedBytes; } function specialRandomBytes(byteLength, type, length) { @@ -36,7 +27,5 @@ function specialRandomBytes(byteLength, type, length) { return convert(generatedBytes).slice(0, length); } -const cryptoRandomString = createStringGenerator(specialRandomBytes, getRandomValues); -cryptoRandomString.async = createAsyncStringGenerator(specialRandomBytes, getRandomValues); - -export default cryptoRandomString; +export default createStringGenerator(specialRandomBytes, getRandomValues); +export const cryptoRandomStringAsync = createAsyncStringGenerator(specialRandomBytes, getRandomValues); diff --git a/core.js b/core.js index 7098b42..94f8c75 100644 --- a/core.js +++ b/core.js @@ -1,13 +1,15 @@ -const urlSafeCharacters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'.split(''); -const numericCharacters = '0123456789'.split(''); -const distinguishableCharacters = 'CDEHKMPRTUWXY012458'.split(''); -const asciiPrintableCharacters = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~'.split(''); -const alphanumericCharacters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'.split(''); +const urlSafeCharacters = [...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~']; +const numericCharacters = [...'0123456789']; +const distinguishableCharacters = [...'CDEHKMPRTUWXY012458']; +const asciiPrintableCharacters = [...'!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~']; +const alphanumericCharacters = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789']; + +const readUInt16LE = (uInt8Array, offset) => uInt8Array[offset] + (uInt8Array[offset + 1] << 8); // eslint-disable-line no-bitwise const generateForCustomCharacters = (length, characters, randomBytes) => { // Generating entropy is faster than complex math operations, so we use the simplest way const characterCount = characters.length; - const maxValidSelector = (Math.floor(0x10000 / characterCount) * characterCount) - 1; // Using values above this will ruin distribution when using modular division + const maxValidSelector = (Math.floor(0x1_00_00 / characterCount) * characterCount) - 1; // Using values above this will ruin distribution when using modular division const entropyLength = 2 * Math.ceil(1.1 * length); // Generating a bit more than required so chances we need more than one pass will be really low let string = ''; let stringLength = 0; @@ -17,7 +19,7 @@ const generateForCustomCharacters = (length, characters, randomBytes) => { let entropyPosition = 0; while (entropyPosition < entropyLength && stringLength < length) { - const entropyValue = entropy.readUInt16LE(entropyPosition); + const entropyValue = readUInt16LE(entropy, entropyPosition); entropyPosition += 2; if (entropyValue > maxValidSelector) { // Skip values which will ruin distribution when using modular division continue; @@ -34,7 +36,7 @@ const generateForCustomCharacters = (length, characters, randomBytes) => { const generateForCustomCharactersAsync = async (length, characters, randomBytesAsync) => { // Generating entropy is faster than complex math operations, so we use the simplest way const characterCount = characters.length; - const maxValidSelector = (Math.floor(0x10000 / characterCount) * characterCount) - 1; // Using values above this will ruin distribution when using modular division + const maxValidSelector = (Math.floor(0x1_00_00 / characterCount) * characterCount) - 1; // Using values above this will ruin distribution when using modular division const entropyLength = 2 * Math.ceil(1.1 * length); // Generating a bit more than required so chances we need more than one pass will be really low let string = ''; let stringLength = 0; @@ -44,7 +46,7 @@ const generateForCustomCharactersAsync = async (length, characters, randomBytesA let entropyPosition = 0; while (entropyPosition < entropyLength && stringLength < length) { - const entropyValue = entropy.readUInt16LE(entropyPosition); + const entropyValue = readUInt16LE(entropy, entropyPosition); entropyPosition += 2; if (entropyValue > maxValidSelector) { // Skip values which will ruin distribution when using modular division continue; @@ -66,7 +68,7 @@ const allowedTypes = new Set([ 'numeric', 'distinguishable', 'ascii-printable', - 'alphanumeric' + 'alphanumeric', ]); const createGenerator = (generateForCustomCharacters, specialRandomBytes, randomBytes) => ({length, type, characters}) => { @@ -122,11 +124,11 @@ const createGenerator = (generateForCustomCharacters, specialRandomBytes, random throw new TypeError('Expected `characters` string length to be greater than or equal to 1'); } - if (characters.length > 0x10000) { + if (characters.length > 0x1_00_00) { throw new TypeError('Expected `characters` string length to be less or equal to 65536'); } - return generateForCustomCharacters(length, characters.split(''), randomBytes); + return generateForCustomCharacters(length, characters, randomBytes); }; export function createStringGenerator(specialRandomBytes, randomBytes) { diff --git a/index.d.ts b/index.d.ts index 6952c9b..a49d5d9 100644 --- a/index.d.ts +++ b/index.d.ts @@ -68,42 +68,38 @@ interface CharactersOption { export type Options = BaseOptions & MergeExclusive; -declare const cryptoRandomString: { - /** - Generate a [cryptographically strong](https://en.wikipedia.org/wiki/Strong_cryptography) random string. - - @returns A randomized string. +/** +Generate a [cryptographically strong](https://en.wikipedia.org/wiki/Strong_cryptography) random string. - @example - ``` - import cryptoRandomString from 'crypto-random-string'; +@returns A randomized string. - cryptoRandomString({length: 10}); - //=> '2cf05d94db' - ``` - */ - (options: Options): string; +@example +``` +import cryptoRandomString from 'crypto-random-string'; - /** - Asynchronously generate a [cryptographically strong](https://en.wikipedia.org/wiki/Strong_cryptography) random string. +cryptoRandomString({length: 10}); +//=> '2cf05d94db' +``` +*/ +export default function cryptoRandomString(options: Options): string; - For most use-cases, there's really no good reason to use this async version. From the Node.js docs: +/** +Asynchronously generate a [cryptographically strong](https://en.wikipedia.org/wiki/Strong_cryptography) random string. - > The `crypto.randomBytes()` method will not complete until there is sufficient entropy available. This should normally never take longer than a few milliseconds. The only time when generating the random bytes may conceivably block for a longer period of time is right after boot, when the whole system is still low on entropy. +For most use-cases, there's really no good reason to use this async version. From the Node.js docs: - In general, anything async comes with some overhead on it's own. +> The `crypto.randomBytes()` method will not complete until there is sufficient entropy available. This should normally never take longer than a few milliseconds. The only time when generating the random bytes may conceivably block for a longer period of time is right after boot, when the whole system is still low on entropy. - @returns A promise which resolves to a randomized string. +In general, anything async comes with some overhead on it's own. - @example - ``` - import cryptoRandomString from 'crypto-random-string'; +@returns A promise which resolves to a randomized string. - await cryptoRandomString.async({length: 10}); - //=> '2cf05d94db' - ``` - */ - async(options: Options): Promise; -}; +@example +``` +import {cryptoRandomStringAsync} from 'crypto-random-string'; -export default cryptoRandomString; +await cryptoRandomStringAsync({length: 10}); +//=> '2cf05d94db' +``` +*/ +export function cryptoRandomStringAsync(options: Options): Promise; diff --git a/index.js b/index.js index 1810aa9..266127a 100644 --- a/index.js +++ b/index.js @@ -1,13 +1,13 @@ +// TODO: When targeting Node.js 16, remove `cryptoRandomStringAsync` and use `crypto.webcrypto.getRandomValues` to interop with the browser code. +// TODO: Later, when targeting Node.js 18, only use the browser code import {promisify} from 'node:util'; -import crypto from 'node:crypto'; +import {randomBytes} from 'node:crypto'; import {createStringGenerator, createAsyncStringGenerator} from './core.js'; -const randomBytesAsync = promisify(crypto.randomBytes); +const randomBytesAsync = promisify(randomBytes); -const cryptoRandomString = createStringGenerator((byteLength, type, length) => crypto.randomBytes(byteLength).toString(type).slice(0, length), crypto.randomBytes); -cryptoRandomString.async = createAsyncStringGenerator(async (byteLength, type, length) => { +export default createStringGenerator((byteLength, type, length) => randomBytes(byteLength).toString(type).slice(0, length), size => new Uint8Array(randomBytes(size))); +export const cryptoRandomStringAsync = createAsyncStringGenerator(async (byteLength, type, length) => { const buffer = await randomBytesAsync(byteLength); return buffer.toString(type).slice(0, length); -}, randomBytesAsync); - -export default cryptoRandomString; +}, async size => new Uint8Array(await randomBytesAsync(size))); diff --git a/index.test-d.ts b/index.test-d.ts index 3c0dddf..cc2c416 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -1,11 +1,11 @@ import {expectType, expectError} from 'tsd'; -import cryptoRandomString from './index.js'; +import cryptoRandomString, {cryptoRandomStringAsync} from './index.js'; expectType(cryptoRandomString({length: 10})); expectType(cryptoRandomString({length: 10, type: 'url-safe'})); expectType(cryptoRandomString({length: 10, type: 'numeric'})); expectType(cryptoRandomString({length: 10, characters: '1234'})); -expectType>(cryptoRandomString.async({length: 10})); +expectType>(cryptoRandomStringAsync({length: 10})); expectError(cryptoRandomString({type: 'url-safe'})); expectError(cryptoRandomString({length: 10, type: 'url-safe', characters: '1234'})); diff --git a/package.json b/package.json index f903582..cba46c5 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "browser": "./browser.js" }, "engines": { - "node": ">=12" + "node": ">=14.16" }, "scripts": { "test": "xo && ava && tsd" @@ -45,11 +45,12 @@ "protect" ], "dependencies": { - "type-fest": "^1.0.1" + "type-fest": "^2.12.2" }, "devDependencies": { - "ava": "^3.15.0", - "tsd": "^0.14.0", - "xo": "^0.38.2" + "ava": "^4.2.0", + "dot-prop": "^7.2.0", + "tsd": "^0.20.0", + "xo": "^0.48.0" } } diff --git a/readme.md b/readme.md index 786c239..ee3ac15 100644 --- a/readme.md +++ b/readme.md @@ -48,7 +48,7 @@ cryptoRandomString({length: 10, characters: 'abc'}); Returns a randomized string. [Hex](https://en.wikipedia.org/wiki/Hexadecimal) by default. -### cryptoRandomString.async(options) +### cryptoRandomStringAsync(options) Returns a promise which resolves to a randomized string. [Hex](https://en.wikipedia.org/wiki/Hexadecimal) by default. @@ -58,6 +58,13 @@ For most use-cases, there's really no good reason to use this async version. Fro In general, anything async comes with some overhead on it's own. +```js +import {cryptoRandomStringAsync} from 'crypto-random-string'; + +await cryptoRandomStringAsync({length: 10}); +//=> '2cf05d94db' +``` + #### options Type: `object` diff --git a/test.js b/test.js index acdf632..7a904b1 100644 --- a/test.js +++ b/test.js @@ -1,99 +1,119 @@ +import {webcrypto} from 'node:crypto'; +import {hasProperty, setProperty} from 'dot-prop'; import test from 'ava'; -import cryptoRandomString from './index.js'; +import browserCryptoRandomString, {cryptoRandomStringAsync as browserCryptoRandomStringAsync} from './browser.js'; +import nodeCryptoRandomString, {cryptoRandomStringAsync as nodeCryptoRandomStringAsync} from './index.js'; + +if (!hasProperty(globalThis, 'crypto')) { + setProperty(globalThis, 'crypto', webcrypto); +} // Probabilistic, result is always less than or equal to actual set size, chance it is less is below 1e-256 for sizes up to 32656. -const generatedCharacterSetSize = (options, targetSize) => { +const generatedCharacterSetSize = (cryptoRandomString, options, targetSize) => { const set = new Set(); const length = targetSize * 640; const string = cryptoRandomString({...options, length}); - for (let i = 0; i < length; i++) { - set.add(string[i]); + for (let index = 0; index < length; index++) { + set.add(string[index]); } return set.size; }; -test('main', t => { +function runTest(title, macro) { + test(`Node.js: ${title}`, macro, { + cryptoRandomString: nodeCryptoRandomString, + cryptoRandomStringAsync: nodeCryptoRandomStringAsync, + }); + test(`Browser: ${title}`, macro, { + cryptoRandomString: browserCryptoRandomString, + cryptoRandomStringAsync: browserCryptoRandomStringAsync, + }); +} + +runTest('main', test.macro((t, {cryptoRandomString}) => { t.is(cryptoRandomString({length: 0}).length, 0); t.is(cryptoRandomString({length: 10}).length, 10); t.is(cryptoRandomString({length: 100}).length, 100); t.regex(cryptoRandomString({length: 100}), /^[a-f\d]*$/); // Sanity check, probabilistic - t.is(generatedCharacterSetSize({}, 16), 16); -}); - -test('async', async t => { - t.is((await cryptoRandomString.async({length: 0})).length, 0); - t.is((await cryptoRandomString.async({length: 10})).length, 10); - t.is((await cryptoRandomString.async({length: 100})).length, 100); - t.regex(await cryptoRandomString.async({length: 100}), /^[a-f\d]*$/); // Sanity check, probabilistic -}); - -test('hex', t => { + t.is(generatedCharacterSetSize(cryptoRandomString, {}, 16), 16); +})); + +runTest('async', test.macro(async (t, {cryptoRandomStringAsync}) => { + /* eslint-disable unicorn/no-await-expression-member */ + t.is((await cryptoRandomStringAsync({length: 0})).length, 0); + t.is((await cryptoRandomStringAsync({length: 10})).length, 10); + t.is((await cryptoRandomStringAsync({length: 100})).length, 100); + /* eslint-enable unicorn/no-await-expression-member */ + t.regex(await cryptoRandomStringAsync({length: 100}), /^[a-f\d]*$/); // Sanity check, probabilistic +})); + +runTest('hex', test.macro((t, {cryptoRandomString}) => { t.is(cryptoRandomString({length: 0, type: 'hex'}).length, 0); t.is(cryptoRandomString({length: 10, type: 'hex'}).length, 10); t.is(cryptoRandomString({length: 100, type: 'hex'}).length, 100); t.regex(cryptoRandomString({length: 100, type: 'hex'}), /^[a-f\d]*$/); // Sanity check, probabilistic - t.is(generatedCharacterSetSize({type: 'hex'}, 16), 16); -}); + t.is(generatedCharacterSetSize(cryptoRandomString, {type: 'hex'}, 16), 16); +})); -test('base64', t => { +runTest('base64', test.macro((t, {cryptoRandomString}) => { t.is(cryptoRandomString({length: 0, type: 'base64'}).length, 0); t.is(cryptoRandomString({length: 10, type: 'base64'}).length, 10); t.is(cryptoRandomString({length: 100, type: 'base64'}).length, 100); t.regex(cryptoRandomString({length: 100, type: 'base64'}), /^[a-zA-Z\d/+]*$/); // Sanity check, probabilistic - t.is(generatedCharacterSetSize({type: 'base64'}, 64), 64); -}); + t.is(generatedCharacterSetSize(cryptoRandomString, {type: 'base64'}, 64), 64); +})); -test('url-safe', t => { +runTest('url-safe', test.macro((t, {cryptoRandomString}) => { t.is(cryptoRandomString({length: 0, type: 'url-safe'}).length, 0); t.is(cryptoRandomString({length: 10, type: 'url-safe'}).length, 10); t.is(cryptoRandomString({length: 100, type: 'url-safe'}).length, 100); t.regex(cryptoRandomString({length: 100, type: 'url-safe'}), /^[\w.~-]*$/); // Sanity check, probabilistic - t.is(generatedCharacterSetSize({type: 'url-safe'}, 66), 66); -}); + t.is(generatedCharacterSetSize(cryptoRandomString, {type: 'url-safe'}, 66), 66); +})); -test('numeric', t => { +runTest('numeric', test.macro((t, {cryptoRandomString}) => { t.is(cryptoRandomString({length: 0, type: 'numeric'}).length, 0); t.is(cryptoRandomString({length: 10, type: 'numeric'}).length, 10); t.is(cryptoRandomString({length: 100, type: 'numeric'}).length, 100); t.regex(cryptoRandomString({length: 100, type: 'numeric'}), /^\d*$/); // Sanity check, probabilistic - t.is(generatedCharacterSetSize({type: 'numeric'}, 10), 10); -}); + t.is(generatedCharacterSetSize(cryptoRandomString, {type: 'numeric'}, 10), 10); +})); -test('distinguishable', t => { +runTest('distinguishable', test.macro((t, {cryptoRandomString}) => { t.is(cryptoRandomString({length: 0, type: 'distinguishable'}).length, 0); t.is(cryptoRandomString({length: 10, type: 'distinguishable'}).length, 10); t.is(cryptoRandomString({length: 100, type: 'distinguishable'}).length, 100); t.regex(cryptoRandomString({length: 100, type: 'distinguishable'}), /^[CDEHKMPRTUWXY012458]*$/); // Sanity check, probabilistic - t.is(generatedCharacterSetSize({type: 'distinguishable'}, 19), 19); -}); + t.is(generatedCharacterSetSize(cryptoRandomString, {type: 'distinguishable'}, 19), 19); +})); -test('ascii-printable', t => { +runTest('ascii-printable', test.macro((t, {cryptoRandomString}) => { t.is(cryptoRandomString({length: 0, type: 'ascii-printable'}).length, 0); t.is(cryptoRandomString({length: 10, type: 'ascii-printable'}).length, 10); t.is(cryptoRandomString({length: 100, type: 'ascii-printable'}).length, 100); t.regex(cryptoRandomString({length: 100, type: 'ascii-printable'}), /^[!"#$%&'()*+,-./\w:;<=>?@[\\\]^`{|}~]*$/); // Sanity check, probabilistic -}); +})); -test('alphanumeric', t => { +runTest('alphanumeric', test.macro((t, {cryptoRandomString}) => { t.is(cryptoRandomString({length: 0, type: 'alphanumeric'}).length, 0); t.is(cryptoRandomString({length: 10, type: 'alphanumeric'}).length, 10); t.is(cryptoRandomString({length: 100, type: 'alphanumeric'}).length, 100); t.regex(cryptoRandomString({length: 100, type: 'alphanumeric'}), /^[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789]*$/); // Sanity check, probabilistic - t.is(generatedCharacterSetSize({type: 'alphanumeric'}, 19), 62); -}); + t.is(generatedCharacterSetSize(cryptoRandomString, {type: 'alphanumeric'}, 19), 62); +})); -test('characters', t => { +runTest('characters', test.macro((t, {cryptoRandomString}) => { t.is(cryptoRandomString({length: 0, characters: '1234'}).length, 0); t.is(cryptoRandomString({length: 10, characters: '1234'}).length, 10); t.is(cryptoRandomString({length: 100, characters: '1234'}).length, 100); t.regex(cryptoRandomString({length: 100, characters: '1234'}), /^[1-4]*$/); // Sanity check, probabilistic - t.is(generatedCharacterSetSize({characters: '1234'}, 4), 4); - t.is(generatedCharacterSetSize({characters: '0123456789'}, 10), 10); -}); + t.is(generatedCharacterSetSize(cryptoRandomString, {characters: '1234'}, 4), 4); + t.is(generatedCharacterSetSize(cryptoRandomString, {characters: '0123456789'}, 10), 10); +})); -test('argument errors', t => { +runTest('argument errors', test.macro((t, {cryptoRandomString}) => { t.throws(() => { cryptoRandomString({length: Number.POSITIVE_INFINITY}); }); @@ -113,4 +133,4 @@ test('argument errors', t => { t.throws(() => { cryptoRandomString({length: 0, type: 'unknown'}); }); -}); +}));