/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ /** * This file is a port of a subset of Chromium's implementation from * https://cs.chromium.org/chromium/src/components/password_manager/core/browser/generation/password_generator.cc?l=93&rcl=a896a3ac4ea731b5ab3d2ab5bd76a139885d5c4f * which is Copyright 2018 The Chromium Authors. All rights reserved. */ const DEFAULT_PASSWORD_LENGTH = 15; const MAX_UINT8 = Math.pow(2, 8) - 1; const MAX_UINT32 = Math.pow(2, 32) - 1; // Some characters are removed due to visual similarity: const LOWER_CASE_ALPHA = "abcdefghijkmnpqrstuvwxyz"; // no 'l' or 'o' const UPPER_CASE_ALPHA = "ABCDEFGHJKLMNPQRSTUVWXYZ"; // no 'I' or 'O' const DIGITS = "23456789"; // no '1' or '0' const SPECIAL_CHARACTERS = "-~!@#$%^&*_+=)}:;\"'>,.?]"; const REQUIRED_CHARACTER_CLASSES = [ LOWER_CASE_ALPHA, UPPER_CASE_ALPHA, DIGITS, SPECIAL_CHARACTERS, ]; // Consts for different password rules const REQUIRED = "required"; const MAX_LENGTH = "maxlength"; const MIN_LENGTH = "minlength"; const MAX_CONSECUTIVE = "max-consecutive"; const UPPER = "upper"; const LOWER = "lower"; const DIGIT = "digit"; const SPECIAL = "special"; // Default password rules const DEFAULT_RULES = new Map(); DEFAULT_RULES.set(MIN_LENGTH, REQUIRED_CHARACTER_CLASSES.length); DEFAULT_RULES.set(MAX_LENGTH, MAX_UINT8); DEFAULT_RULES.set(REQUIRED, [UPPER, LOWER, DIGIT, SPECIAL]); export const PasswordGenerator = { /** * @param {Object} options * @param {number} options.length - length of the generated password if there are no rules that override the length * @param {Map} options.rules - map of password rules * @returns {string} password that was generated * @throws Error if `length` is invalid * @copyright 2018 The Chromium Authors. All rights reserved. * @see https://cs.chromium.org/chromium/src/components/password_manager/core/browser/generation/password_generator.cc?l=93&rcl=a896a3ac4ea731b5ab3d2ab5bd76a139885d5c4f */ generatePassword({ length = DEFAULT_PASSWORD_LENGTH, rules = DEFAULT_RULES, inputMaxLength, }) { rules = new Map([...DEFAULT_RULES, ...rules]); if (rules.get(MIN_LENGTH) > length) { length = rules.get(MIN_LENGTH); } if (rules.get(MAX_LENGTH) < length) { length = rules.get(MAX_LENGTH); } if (inputMaxLength > 0 && inputMaxLength < length) { length = inputMaxLength; } let password = ""; let requiredClasses = []; let allRequiredCharacters = ""; // Generate one character of each required class and/or required character list from the rules this._addRequiredClassesAndCharacters(rules, requiredClasses); // Generate one of each required class for (const charClassString of requiredClasses) { password += charClassString[this._randomUInt8Index(charClassString.length)]; if (Array.isArray(charClassString)) { // Convert array into single string so that commas aren't // concatenated with each character in the arbitrary character array. allRequiredCharacters += charClassString.join(""); } else { allRequiredCharacters += charClassString; } } // Now fill the rest of the password with random characters. while (password.length < length) { password += allRequiredCharacters[ this._randomUInt8Index(allRequiredCharacters.length) ]; } // So far the password contains the minimally required characters at the // the beginning. Therefore, we create a random permutation. password = this._shuffleString(password); // Make sure the password passes the "max-consecutive" rule, if the rule exists if (rules.has(MAX_CONSECUTIVE)) { // Ensures that a password isn't shuffled an infinite number of times. const DEFAULT_NUMBER_OF_SHUFFLES = 15; let shuffleCount = 0; let consecutiveFlag = this._checkConsecutiveCharacters( password, rules.get(MAX_CONSECUTIVE) ); while (!consecutiveFlag) { password = this._shuffleString(password); consecutiveFlag = this._checkConsecutiveCharacters( password, rules.get(MAX_CONSECUTIVE) ); ++shuffleCount; if (shuffleCount === DEFAULT_NUMBER_OF_SHUFFLES) { consecutiveFlag = true; } } } return password; }, /** * Adds special characters and/or other required characters to the requiredCharacters array. * @param {Map} rules * @param {string[]} requiredClasses */ _addRequiredClassesAndCharacters(rules, requiredClasses) { for (const charClass of rules.get(REQUIRED)) { if (charClass === UPPER) { requiredClasses.push(UPPER_CASE_ALPHA); } else if (charClass === LOWER) { requiredClasses.push(LOWER_CASE_ALPHA); } else if (charClass === DIGIT) { requiredClasses.push(DIGITS); } else if (charClass === SPECIAL) { requiredClasses.push(SPECIAL_CHARACTERS); } else { requiredClasses.push(charClass); } } }, /** * @param range to generate the number in * @returns a random number in range [0, range). * @copyright 2018 The Chromium Authors. All rights reserved. * @see https://cs.chromium.org/chromium/src/base/rand_util.cc?l=58&rcl=648a59893e4ed5303b5c381b03ce0c75e4165617 */ _randomUInt8Index(range) { if (range > MAX_UINT8) { throw new Error("`range` cannot fit into uint8"); } // We must discard random results above this number, as they would // make the random generator non-uniform (consider e.g. if // MAX_UINT64 was 7 and |range| was 5, then a result of 1 would be twice // as likely as a result of 3 or 4). // See https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#Modulo_bias const MAX_ACCEPTABLE_VALUE = Math.floor(MAX_UINT8 / range) * range - 1; const randomValueArr = new Uint8Array(1); do { crypto.getRandomValues(randomValueArr); } while (randomValueArr[0] > MAX_ACCEPTABLE_VALUE); return randomValueArr[0] % range; }, /** * Shuffle the order of characters in a string. * @param {string} str to shuffle * @returns {string} shuffled string */ _shuffleString(str) { let arr = Array.from(str); // Generate all the random numbers that will be needed. const randomValues = new Uint32Array(arr.length - 1); crypto.getRandomValues(randomValues); // Fisher-Yates Shuffle // https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor((randomValues[i - 1] / MAX_UINT32) * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr.join(""); }, /** * Determine the number of consecutive characters in a string. * This is primarily used to validate the "max-consecutive" rule * of a generated password. * @param {string} generatedPassword * @param {number} value the number of consecutive characters allowed * @return {boolean} `true` if the generatePassword has less than the value argument number of characters, `false` otherwise */ _checkConsecutiveCharacters(generatedPassword, value) { let max = 0; for (let start = 0, end = 1; end < generatedPassword.length; ) { if (generatedPassword[end] === generatedPassword[start]) { if (max < end - start + 1) { max = end - start + 1; if (max > value) { return false; } } end++; } else { start = end++; } } return true; }, _getUpperCaseCharacters() { return UPPER_CASE_ALPHA; }, _getLowerCaseCharacters() { return LOWER_CASE_ALPHA; }, _getDigits() { return DIGITS; }, _getSpecialCharacters() { return SPECIAL_CHARACTERS; }, };