Skip to content

Commit

Permalink
Add browser support (#43)
Browse files Browse the repository at this point in the history
  • Loading branch information
Richienb committed Apr 18, 2022
1 parent a93cea0 commit 62e78ab
Show file tree
Hide file tree
Showing 5 changed files with 197 additions and 144 deletions.
42 changes: 42 additions & 0 deletions browser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/* 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));

// `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;

function getRandomValues(byteLength) {
const generatedBytes = [];

while (byteLength > 0) {
const bytesToGenerate = Math.min(byteLength, maxEntropy);
generatedBytes.push(crypto.getRandomValues(new Uint8Array({length: bytesToGenerate})));
byteLength -= bytesToGenerate;
}

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;
}

function specialRandomBytes(byteLength, type, length) {
const generatedBytes = getRandomValues(byteLength);
const convert = type === 'hex' ? toHex : toBase64;

return convert(generatedBytes).slice(0, length);
}

const cryptoRandomString = createStringGenerator(specialRandomBytes, getRandomValues);
cryptoRandomString.async = createAsyncStringGenerator(specialRandomBytes, getRandomValues);

export default cryptoRandomString;
138 changes: 138 additions & 0 deletions core.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
const urlSafeCharacters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'.split('');
const numericCharacters = '0123456789'.split('');
const distinguishableCharacters = 'CDEHKMPRTUWXY012458'.split('');
const asciiPrintableCharacters = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~'.split('');
const alphanumericCharacters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'.split('');

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 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;

while (stringLength < length) { // In case we had many bad values, which may happen for character sets of size above 0x8000 but close to it
const entropy = randomBytes(entropyLength);
let entropyPosition = 0;

while (entropyPosition < entropyLength && stringLength < length) {
const entropyValue = entropy.readUInt16LE(entropyPosition);
entropyPosition += 2;
if (entropyValue > maxValidSelector) { // Skip values which will ruin distribution when using modular division
continue;
}

string += characters[entropyValue % characterCount];
stringLength++;
}
}

return string;
};

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 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;

while (stringLength < length) { // In case we had many bad values, which may happen for character sets of size above 0x8000 but close to it
const entropy = await randomBytesAsync(entropyLength); // eslint-disable-line no-await-in-loop
let entropyPosition = 0;

while (entropyPosition < entropyLength && stringLength < length) {
const entropyValue = entropy.readUInt16LE(entropyPosition);
entropyPosition += 2;
if (entropyValue > maxValidSelector) { // Skip values which will ruin distribution when using modular division
continue;
}

string += characters[entropyValue % characterCount];
stringLength++;
}
}

return string;
};

const allowedTypes = new Set([
undefined,
'hex',
'base64',
'url-safe',
'numeric',
'distinguishable',
'ascii-printable',
'alphanumeric'
]);

const createGenerator = (generateForCustomCharacters, specialRandomBytes, randomBytes) => ({length, type, characters}) => {
if (!(length >= 0 && Number.isFinite(length))) {
throw new TypeError('Expected a `length` to be a non-negative finite number');
}

if (type !== undefined && characters !== undefined) {
throw new TypeError('Expected either `type` or `characters`');
}

if (characters !== undefined && typeof characters !== 'string') {
throw new TypeError('Expected `characters` to be string');
}

if (!allowedTypes.has(type)) {
throw new TypeError(`Unknown type: ${type}`);
}

if (type === undefined && characters === undefined) {
type = 'hex';
}

if (type === 'hex' || (type === undefined && characters === undefined)) {
return specialRandomBytes(Math.ceil(length * 0.5), 'hex', length); // Needs 0.5 bytes of entropy per character
}

if (type === 'base64') {
return specialRandomBytes(Math.ceil(length * 0.75), 'base64', length); // Needs 0.75 bytes of entropy per character
}

if (type === 'url-safe') {
return generateForCustomCharacters(length, urlSafeCharacters, randomBytes);
}

if (type === 'numeric') {
return generateForCustomCharacters(length, numericCharacters, randomBytes);
}

if (type === 'distinguishable') {
return generateForCustomCharacters(length, distinguishableCharacters, randomBytes);
}

if (type === 'ascii-printable') {
return generateForCustomCharacters(length, asciiPrintableCharacters, randomBytes);
}

if (type === 'alphanumeric') {
return generateForCustomCharacters(length, alphanumericCharacters, randomBytes);
}

if (characters.length === 0) {
throw new TypeError('Expected `characters` string length to be greater than or equal to 1');
}

if (characters.length > 0x10000) {
throw new TypeError('Expected `characters` string length to be less or equal to 65536');
}

return generateForCustomCharacters(length, characters.split(''), randomBytes);
};

export function createStringGenerator(specialRandomBytes, randomBytes) {
return createGenerator(generateForCustomCharacters, specialRandomBytes, randomBytes);
}

export function createAsyncStringGenerator(specialRandomBytesAsync, randomBytesAsync) {
return createGenerator(generateForCustomCharactersAsync, specialRandomBytesAsync, randomBytesAsync);
}
147 changes: 6 additions & 141 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,148 +1,13 @@
import {promisify} from 'util';
import crypto from 'crypto';
import {promisify} from 'node:util';
import crypto from 'node:crypto';
import {createStringGenerator, createAsyncStringGenerator} from './core.js';

const randomBytesAsync = promisify(crypto.randomBytes);

const urlSafeCharacters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'.split('');
const numericCharacters = '0123456789'.split('');
const distinguishableCharacters = 'CDEHKMPRTUWXY012458'.split('');
const asciiPrintableCharacters = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~'.split('');
const alphanumericCharacters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'.split('');

const generateForCustomCharacters = (length, characters) => {
// 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 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;

while (stringLength < length) { // In case we had many bad values, which may happen for character sets of size above 0x8000 but close to it
const entropy = crypto.randomBytes(entropyLength);
let entropyPosition = 0;

while (entropyPosition < entropyLength && stringLength < length) {
const entropyValue = entropy.readUInt16LE(entropyPosition);
entropyPosition += 2;
if (entropyValue > maxValidSelector) { // Skip values which will ruin distribution when using modular division
continue;
}

string += characters[entropyValue % characterCount];
stringLength++;
}
}

return string;
};

const generateForCustomCharactersAsync = async (length, characters) => {
// 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 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;

while (stringLength < length) { // In case we had many bad values, which may happen for character sets of size above 0x8000 but close to it
const entropy = await randomBytesAsync(entropyLength); // eslint-disable-line no-await-in-loop
let entropyPosition = 0;

while (entropyPosition < entropyLength && stringLength < length) {
const entropyValue = entropy.readUInt16LE(entropyPosition);
entropyPosition += 2;
if (entropyValue > maxValidSelector) { // Skip values which will ruin distribution when using modular division
continue;
}

string += characters[entropyValue % characterCount];
stringLength++;
}
}

return string;
};

const generateRandomBytes = (byteLength, type, length) => crypto.randomBytes(byteLength).toString(type).slice(0, length);

const generateRandomBytesAsync = async (byteLength, type, length) => {
const cryptoRandomString = createStringGenerator((byteLength, type, length) => crypto.randomBytes(byteLength).toString(type).slice(0, length), crypto.randomBytes);
cryptoRandomString.async = createAsyncStringGenerator(async (byteLength, type, length) => {
const buffer = await randomBytesAsync(byteLength);
return buffer.toString(type).slice(0, length);
};

const allowedTypes = new Set([
undefined,
'hex',
'base64',
'url-safe',
'numeric',
'distinguishable',
'ascii-printable',
'alphanumeric'
]);

const createGenerator = (generateForCustomCharacters, generateRandomBytes) => ({length, type, characters}) => {
if (!(length >= 0 && Number.isFinite(length))) {
throw new TypeError('Expected a `length` to be a non-negative finite number');
}

if (type !== undefined && characters !== undefined) {
throw new TypeError('Expected either `type` or `characters`');
}

if (characters !== undefined && typeof characters !== 'string') {
throw new TypeError('Expected `characters` to be string');
}

if (!allowedTypes.has(type)) {
throw new TypeError(`Unknown type: ${type}`);
}

if (type === undefined && characters === undefined) {
type = 'hex';
}

if (type === 'hex' || (type === undefined && characters === undefined)) {
return generateRandomBytes(Math.ceil(length * 0.5), 'hex', length); // Need 0.5 byte entropy per character
}

if (type === 'base64') {
return generateRandomBytes(Math.ceil(length * 0.75), 'base64', length); // Need 0.75 byte of entropy per character
}

if (type === 'url-safe') {
return generateForCustomCharacters(length, urlSafeCharacters);
}

if (type === 'numeric') {
return generateForCustomCharacters(length, numericCharacters);
}

if (type === 'distinguishable') {
return generateForCustomCharacters(length, distinguishableCharacters);
}

if (type === 'ascii-printable') {
return generateForCustomCharacters(length, asciiPrintableCharacters);
}

if (type === 'alphanumeric') {
return generateForCustomCharacters(length, alphanumericCharacters);
}

if (characters.length === 0) {
throw new TypeError('Expected `characters` string length to be greater than or equal to 1');
}

if (characters.length > 0x10000) {
throw new TypeError('Expected `characters` string length to be less or equal to 65536');
}

return generateForCustomCharacters(length, characters.split(''));
};

const cryptoRandomString = createGenerator(generateForCustomCharacters, generateRandomBytes);

cryptoRandomString.async = createGenerator(generateForCustomCharactersAsync, generateRandomBytesAsync);
}, randomBytesAsync);

export default cryptoRandomString;
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@
"url": "https://sindresorhus.com"
},
"type": "module",
"exports": "./index.js",
"exports": {
"types": "./index.d.ts",
"node": "./index.js",
"browser": "./browser.js"
},
"engines": {
"node": ">=12"
},
Expand All @@ -20,6 +24,8 @@
},
"files": [
"index.js",
"browser.js",
"core.js",
"index.d.ts"
],
"keywords": [
Expand Down
6 changes: 4 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
Can be useful for creating an identifier, slug, salt, PIN code, fixture, etc.

Works in Node.js and browsers.

## Install

```
$ npm install crypto-random-string
```sh
npm install crypto-random-string
```

## Usage
Expand Down

0 comments on commit 62e78ab

Please sign in to comment.