`). * * @var array */ public const CHARACTER_SETS = [ 'uppercase' => 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'lowercase' => 'abcdefghijklmnopqrstuvwxyz', 'numbers' => '0123456789', 'symbols' => '!#$%&()+,-./:;=?@[]{}', ]; /** Characters that read alike in many typefaces. */ public const AMBIGUOUS_CHARACTERS = '0O1lI'; /** @var array */ public const SEPARATORS = [ 'hyphen' => '-', 'dot' => '.', 'underscore' => '_', 'space' => ' ', ]; public const MIN_LENGTH = 12; public const MAX_LENGTH = 64; public const MIN_WORDS = 4; public const MAX_WORDS = 10; /** * @var array{mode: string, type: string, length: int, characterSets: list, avoidAmbiguous: bool, words: int, separator: string} */ public const DEFAULTS = [ 'mode' => 'button', 'type' => 'characters', 'length' => 20, 'characterSets' => ['uppercase', 'lowercase', 'numbers'], 'avoidAmbiguous' => true, 'words' => 6, 'separator' => 'hyphen', ]; /** @var list|null */ private ?array $wordList = null; /** * How the upload page offers generated passwords. */ public function mode(): string { $mode = Setting::get('password_generator_mode'); return in_array($mode, self::MODES, true) ? $mode : self::DEFAULTS['mode']; } /** * The saved generator settings, with the default for anything missing or no longer allowed. * * @return array{mode: string, type: string, length: int, characterSets: list, avoidAmbiguous: bool, words: int, separator: string} */ public function options(): array { $type = Setting::get('password_generator_type'); $length = (int) Setting::get('password_generator_length', self::DEFAULTS['length']); $words = (int) Setting::get('password_generator_words', self::DEFAULTS['words']); $separator = Setting::get('password_generator_separator'); $characterSets = array_values(array_intersect( array_keys(self::CHARACTER_SETS), explode(',', (string) Setting::get('password_generator_character_sets')), )); return [ 'mode' => $this->mode(), 'type' => in_array($type, self::TYPES, true) ? $type : self::DEFAULTS['type'], 'length' => $length >= self::MIN_LENGTH && $length <= self::MAX_LENGTH ? $length : self::DEFAULTS['length'], 'characterSets' => $characterSets ?: self::DEFAULTS['characterSets'], 'avoidAmbiguous' => (bool) Setting::get('password_generator_avoid_ambiguous', self::DEFAULTS['avoidAmbiguous'] ? '1' : '0'), 'words' => $words >= self::MIN_WORDS && $words <= self::MAX_WORDS ? $words : self::DEFAULTS['words'], 'separator' => is_string($separator) && array_key_exists($separator, self::SEPARATORS) ? $separator : self::DEFAULTS['separator'], ]; } /** * Generate a password from the given options, or from the saved settings. * * @param array{type: string, length: int, characterSets: list, avoidAmbiguous: bool, words: int, separator: string}|null $options */ public function generate(?array $options = null): string { $options ??= $this->options(); return $options['type'] === 'passphrase' ? $this->passphrase($options['words'], self::SEPARATORS[$options['separator']]) : $this->characters($options['length'], $options['characterSets'], $options['avoidAmbiguous']); } /** * Draw characters uniformly from the chosen sets, drawing again until every set shows up at * least once. Redrawing keeps each valid password equally likely, where placing one character * of each set first would not. * * @param list $characterSets */ public function characters(int $length, array $characterSets, bool $avoidAmbiguous): string { $alphabets = $this->alphabets($characterSets, $avoidAmbiguous); if ($alphabets === [] || $length < count($alphabets)) { throw new InvalidArgumentException('A password needs at least one character set and room for each of them.'); } $randomizer = new Randomizer; do { $password = $randomizer->getBytesFromString(implode('', $alphabets), $length); } while (array_filter($alphabets, fn (string $alphabet): bool => strpbrk($password, $alphabet) === false) !== []); return $password; } /** * Draw words from the word list, each independently of the others. */ public function passphrase(int $words, string $separator): string { $wordList = $this->wordList(); $randomizer = new Randomizer; return implode($separator, array_map( fn (): string => $wordList[$randomizer->getInt(0, count($wordList) - 1)], range(1, max(1, $words)), )); } /** * Roughly how many bits of entropy a password from these options carries. * * @param array{type: string, length: int, characterSets: list, avoidAmbiguous: bool, words: int} $options */ public function entropyBits(array $options): int { if ($options['type'] === 'passphrase') { return (int) floor($options['words'] * log(count($this->wordList()), 2)); } $alphabetSize = strlen(implode('', $this->alphabets($options['characterSets'], $options['avoidAmbiguous']))); return $alphabetSize > 0 ? (int) floor($options['length'] * log($alphabetSize, 2)) : 0; } /** * @return list */ public function wordList(): array { return $this->wordList ??= file(resource_path('wordlists/eff-large-wordlist.txt'), FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); } /** * The characters of each chosen set, without the look-alikes when asked. * * @param list $characterSets * @return array */ private function alphabets(array $characterSets, bool $avoidAmbiguous): array { return collect(self::CHARACTER_SETS) ->only($characterSets) ->map(fn (string $alphabet): string => $avoidAmbiguous ? str_replace(str_split(self::AMBIGUOUS_CHARACTERS), '', $alphabet) : $alphabet) ->all(); } }