summaryrefslogtreecommitdiffstats
path: root/toolkit/components/passwordmgr/PasswordGenerator.sys.mjs
blob: 0f2a407cea94e7746a75c365fd6e1f57fbf4acdb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
/* 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;
  },
};