UNPKG

secure-password-utilities

Version:

Secure, zero-dependency utilities for generating passwords, passphrases, pins, and more

308 lines (267 loc) 10.2 kB
import { getRandomValues, getRandomNumbersInRange, randomizeCharacters, } from 'secure-password-utilities/random'; import { DIGIT_CHARSET, LOWERCASE_CHARSET, UPPERCASE_CHARSET, SYMBOL_CHARSET, } from 'secure-password-utilities/constants'; export type PasswordOptionType = // `true` means include [character type], `false` means exclude [character type] | boolean // <number> means include exactly <number> [character type]s | number // { min: <number> } means include at least <number> [character type]s | { min: number }; export type PasswordOptionsType = { digits?: PasswordOptionType; symbols?: PasswordOptionType; lowercase?: PasswordOptionType; uppercase?: PasswordOptionType; charset?: { digits?: string; symbols?: string; lowercase?: string; uppercase?: string; }; }; /** * Generate a random password. * * Examples: * * generatePassword(12); // l[Nz8UfU.o4g * generatePassword(8, { symbols: false, digits: 2 }); // k9WTkaP6 * generatePassword(8, { digits: {min: 2} }); // 0(c67+.f * * @param length The length of the resulting password. * @param options * @param options.digits Include or exclude digits. * @param options.symbols Include or exclude symbols. * @param options.lowercase Include or exclude lowercase. * @param options.uppercase Include or exclude uppercase. * @param options.charset * @param options.charset.digits Override the character set for digits. * @param options.charset.symbols Override the character set for symbols. * @param options.charset.lowercase Override the character set for lowercase. * @param options.charset.uppercase Override the character set for uppercase. * @returns A random password. */ export function generatePassword(length: number, options?: PasswordOptionsType): string { options = options || {}; return createPassword( length, { digits: options.digits ?? true, symbols: options.symbols ?? true, lowercase: options.lowercase ?? true, uppercase: options.uppercase ?? true, }, { digits: options.charset?.digits ?? DIGIT_CHARSET, symbols: options.charset?.symbols ?? SYMBOL_CHARSET, lowercase: options.charset?.lowercase ?? LOWERCASE_CHARSET, uppercase: options.charset?.uppercase ?? UPPERCASE_CHARSET, } ); } type PasswordOptionsTypeRequired = { digits: PasswordOptionType; symbols: PasswordOptionType; lowercase: PasswordOptionType; uppercase: PasswordOptionType; }; type CharsetType = { digits: string; symbols: string; lowercase: string; uppercase: string; }; function createPassword( passwordLength: number, options: PasswordOptionsTypeRequired, charset: CharsetType ) { validatePasswordOptions(passwordLength, options); validateCharsetOptions(charset); const [initDigitLength, moreDigits] = getInitialLengthForOption(options.digits); const [initSymbolLength, moreSymbols] = getInitialLengthForOption(options.symbols); const [initLowercaseLength, moreLowercase] = getInitialLengthForOption(options.lowercase); const [initUppercaseLength, moreUppercase] = getInitialLengthForOption(options.uppercase); // Construct the initial response based on the exact or minimum characters // specified for digits, symbols, lowercase and uppercase character sets. let result = generateCharacters(initDigitLength, charset.digits) + generateCharacters(initSymbolLength, charset.symbols) + generateCharacters(initLowercaseLength, charset.lowercase) + generateCharacters(initUppercaseLength, charset.uppercase); let remainingCharset = ''; if (moreDigits) { remainingCharset += charset.digits; } if (moreSymbols) { remainingCharset += charset.symbols; } if (moreLowercase) { remainingCharset += charset.lowercase; } if (moreUppercase) { remainingCharset += charset.uppercase; } result += generateCharacters(passwordLength - result.length, remainingCharset); return randomizeCharacters(result); } function validatePasswordOptions(length: number, options: PasswordOptionsTypeRequired) { if (typeof length !== 'number' || length < 1) { throw new Error('Invalid option: length option must be a number greater than or equal to 1'); } validatePasswordOption('digits', options.digits); validatePasswordOption('symbols', options.symbols); validatePasswordOption('lowercase', options.lowercase); validatePasswordOption('uppercase', options.uppercase); const [initDigitLength, moreDigits] = getInitialLengthForOption(options.digits); const [initSymbolLength, moreSymbols] = getInitialLengthForOption(options.symbols); const [initLowercaseLength, moreLowercase] = getInitialLengthForOption(options.lowercase); const [initUppercaseLength, moreUppercase] = getInitialLengthForOption(options.uppercase); const sum = initDigitLength + initSymbolLength + initLowercaseLength + initUppercaseLength; const allExact = !moreDigits && !moreSymbols && !moreLowercase && !moreUppercase; if (sum > length) { throw new Error('Invalid option: Requested characters exceeds expected length'); } if (allExact && sum !== length) { throw new Error('Invalid option: Requested less characters than expected length'); } } // This assumes that any missing options were filled in with a default, i.e., no `undefined` options. function validatePasswordOption(name: string, option: PasswordOptionType) { if (typeof option === 'boolean') { return; } if (typeof option === 'number') { if (option < 0) { throw new Error(`Invalid option: ${name} option cannot be a negative number`); } return; } if (option !== null && typeof option === 'object') { if (typeof option.min !== 'number' || option.min < 0) { throw new Error( `Invalid option: ${name} option 'min' property must be a non-negative integer` ); } return; } throw new Error(`Invalid option: ${name} option must be a boolean, number, or object`); } // Assumes option has already been validated, populated with defaults, and is thus well-formed. function getInitialLengthForOption(option: PasswordOptionType): [number, boolean] { switch (typeof option) { case 'boolean': return [0, option]; case 'number': return [option, false]; default: return [option.min, true]; } } function validateCharsetOptions(charsets: CharsetType) { validateCharsetOption('digits', charsets.digits); validateCharsetOption('symbols', charsets.symbols); validateCharsetOption('lowercase', charsets.lowercase); validateCharsetOption('uppercase', charsets.uppercase); } function validateCharsetOption(name: string, charset: string) { if (typeof charset !== 'string') { throw new Error(`Invalid charset option: ${name} charset must be a string`); } if (charset.length !== new Set(charset).size) { throw new Error(`Invalid charset option: ${name} charset contains duplicate characters`); } } /** * Generate a random digit pin. * * Examples: * * generatePin(6); // 036919 * generatePin(8); // 45958396 * * @param length The length of the resulting pin. * @returns A random digit pin. */ export function generatePin(length: number) { if (typeof length !== 'number' || length < 1) { throw new Error( 'Invalid argument: length argument must be a number greater than or equal to 1' ); } return generateCharacters(length, DIGIT_CHARSET); } /** * Generate a string of `length` characters chosen randomly from the given `charset`. * * Examples: * * generateCharacters(4, '$%^&'); // &$&^ * generateCharacters(6, '0123456789'); // 947682 * generateCharacters(6, 'abcdefghijklmnopqrstuvwxyz'); // ihdrnn * * @param length The number of random characters to generate. * @param charset The set of characters to randomly sample from. * @returns A random string of `length` characters from `charset`. */ export function generateCharacters(length: number, charset: string) { if (typeof length !== 'number' || length < 0) { throw new Error( 'Invalid argument: length argument must be a number greater than or equal to 0' ); } if (typeof charset !== 'string' || charset.length < 2) { throw new Error( 'Invalid argument: charset argument must be a string with length greater than or equal to 2' ); } return getRandomValues(length, charset.length).reduce((characters, i) => { return characters + charset[i]; }, ''); } /** * Generate a memorable passphrase comprised of words chosen randomly from the given `wordlist`. * * There are wordlists available in the wordlists module, or you can provide your own. * * The word separator defaults to a dash (`-`), but you can customize this behavior using the third argument. "-" * * Examples: * * generatePassphrase(6, DEFAULT_WORDLIST); // canopener-uncanny-hatchet-murky-agony-traitor * generatePassphrase(6, DEFAULT_WORDLIST); // backpack-craftwork-sweat-postcard-imaging-litter * generatePassphrase(6, DEFAULT_WORDLIST, '_'); // goldfish_scorpion_antiviral_pursuit_demanding_motto * * @param length The number of words selected at random. * @param wordlist The list of words to sample from. * @param sep The separator to use when joining the words in the passphrase. Defaults to '-'. * @returns A memorable passphrase. */ export function generatePassphrase(length: number, wordlist: readonly string[], sep = '-') { if (typeof length !== 'number' || length < 1) { throw new Error( 'Invalid argument: length argument must be a number greater than or equal to 1' ); } if (!Array.isArray(wordlist) || wordlist.length < 2) { throw new Error( 'Invalid argument: wordlist argument must be an array with length greater than or equal to 2' ); } if (typeof sep !== 'string') { throw new Error('Invalid argument: sep argument must be a string'); } return getRandomNumbersInRange(length, 0, wordlist.length).reduce((passphrase, value, i) => { const word = wordlist[value]; return passphrase + (i === 0 ? word : sep + word); }, ''); }