Подробно: Знакомство с Random

Источник: «In Depth: Introducing Random»
Random генерирует криптографически защищённые случайные значения в различных форматах с помощью простого PHP пакета.

Оглавление

Во время аудита безопасности (особенно в старых кодовых базах) я часто сталкиваюсь с небезопасной случайностью, обычно в тех местах, где требуется безопасность. Обычно это использование rand() в какой-либо форме, зачастую внедрённое внутрь md5() для генерации случайного хэша, в сочетании с str_shuffle() для генерации новых паролей или используемое для создания одноразового пароля (OTP) с помощью rand(100_000, 999_999).

Проблема в том, что функция rand() не является криптографически безопасной, как и mt_rand(), mt_srand(), str_shuffle(), array_rand() или другие небезопасные функции, доступные в PHP. Я уже много раз говорил на эту тему, так что, думаю, вы уже знаете, что эти методы небезопасны для использования. Однако мы не можем просто объявить эти методы небезопасными, бросить микрофон и уйти. Вместо этого необходимо предоставить безопасные альтернативы — так что вместо того, чтобы просто сказать не используйте rand(), таким образом, мы можем сказать вот безопасный метод, который вы можете использовать вместо этого!

Именно поэтому я создал Random.

Что такое Random

Random — это новый пакет Composer, который я создал, чтобы обеспечить безопасную и простую в использовании реализацию общих функций случайности. Он полностью независим от фреймворков и работает на PHP 7.1 и более поздних версиях, а единственной зависимостью является отличный php-random-polyfill от Антона Смирнова.

Вы можете найти его на:

Его можно установить, как обычно, с помощью Composer:

composer require valorin/random

Я хотел, чтобы его было удобно использовать как набор инструментов, поэтому все методы доступны через статические вызовы методов класса \Valorin\Random\Random, что придаёт ему простой и читабельный синтаксис:

$number = Random::number(1, 100);
$password = Random::password();

Смысл в том, что любой, кто хочет избавиться от небезопасной реализации, может установить пакет и заменить её одной строкой:

// Исходная небезопасная версия
$otp = rand(100_000, 999_999);

// Безопасная версия
$otp = Random::otp();

// Исходная небезопасная версия
function generatePassword($length = 10) {
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$charactersLength = strlen($characters);
$password = '';
for ($i = 0; $i < $length; $i++) {
$password .= $characters[rand(0, $charactersLength - 1)];
}
return str_shuffle($password);
}

// Безопасная версия
function generatePassword($length = 16) {
return Random::password($length);
}

Одной из дополнительных целей была поддержка старых версий PHP, поскольку даже если старые версии больше не поддерживаются (И я всегда отмечаю их как проблему в своих аудитах!), командам всё равно требуется время для обновления. Наличие инструментария, который можно использовать уже сейчас для быстрого исправления небезопасных случайностей, является огромным преимуществом и избавляет людей от необходимости пытаться самостоятельно реализовать безопасную версию или спешить с обновлением. Я выбрал PHP 7.1, так как это самая ранняя версия, поддерживаемая полифиллом, а в PHP 7.0 оказалось слишком проблематично заставить всё работать.

Достаточно вступления, давайте рассмотрим каждый из методов!

Случайные целые числа

Начнём с основ, мы можем генерировать случайные числа с помощью:

$number = Random::number(int $min, int $max): int;

> $number = Random::number(1, 1000);
= 384

Поскольку у нас уже есть фантастическая функция random_int(), единственная практическая причина, по которой вы захотите использовать Random::number() (Возможно, вы просто найдёте, что Random::number() выглядит лучше в вашем коде! 😉), — это использование кастомных движков, например, для получения случайных чисел, и мы рассмотрим их далее.

Мы уже рассказывали о random_int() в статье Cryptographically Secure Randomness.

Случайные одноразовые пароли (числовые OTP фиксированной длины)

Одноразовые пароли (OTP), passcode, nonce…. Существует множество названий для числовой строки фиксированной длины, отправляемой пользователям для проверки.

Мы можем сгенерировать их с помощью:

$otp = Random::otp(int $length): string;

> $otp = Random::otp(6);
= "001421"

Часто можно встретить OTP, сгенерированные с помощью чего-то вроде rand(100_000, 999_999), однако такой подход вдвойне небезопасен, поскольку использует небезопасную случайность и теряет ~10% энтропии (диапазон 000 000 - 099 999).

Мой вариант исправления, обсуждавшийся в Magic Emails, включает random_int() и left pad, но я хотел упростить его и сделать всю работу в одном хелпере, чтобы людям не нужно было реализовывать ничего из этого самостоятельно.

Метод добавляет нули (0) в качестве префикса и возвращает строку, предотвращающую выпадение нулей впереди.

Изначально я сделал нулевой префикс настраиваемым, но использовать отличный от нуля не имело смысла, учитывая назначение метода. Метод также поддерживает более длинные числа, чем ограничение размера integer — не уверен, зачем вам это нужно, но если вы хотите, оно есть.

Возникает вопрос об именовании этой функции — точно ли otp() отражает то, что она делает, не подразумевая дополнительного поведения? Мне было трудно придумать подходящее название. Напишите мне в комментариях, если у вас есть какие-то предложения!

Случайные строки

Теперь, когда мы разобрались с числами, нам также нужно сгенерировать случайные строки с различными комбинациями символов.

Мы можем генерировать случайные строки, используя основной метод string():

$string = Random::string(
int $length = 32,
bool $lower = true, // использование строчных букв
bool $upper = true, // использование прописных букв
bool $numbers = true, // использование чисел
bool $symbols = true, // использование специальных символов
bool $requireAll = false // требуется хотя бы один символ каждого типа
): string;

Существуют также обёртки для распространённых случаев использования:

// Только случайные буквы
$string = Random::letters(int $length = 32): string;

// Случайные буквы и цифры (т.е. случайный токен)
$string = Random::token(int $length = 32): string;

// Случайные буквы, цифры и символы (т.е. случайный пароль).
$string = Random::password(int $length = 32, bool $requireAll = false): string;

// Произвольная буквенно-цифровая строка токенов с фрагментами, разделёнными тире, что облегчает чтение и ввод.
$password = Random::dashed(int $length = 25, string $delimiter = '-', int $chunkLength = 5): string;

> $string = Random::string();
= "QS`#z&/kP4x/R*gc9MomOMD]Q"&Ry62Z"

> $letters = Random::letters();
= "
bDIZrdAOdMgxXnnLTrobaHVLMGaWeDgj"

> $token = Random::token();
= "
Jz5QSwuUW7cF7J5flYqyrhSQEfZrvWdV"

> $password = Random::password();
= "
gB#'JhYc$1YWMOlN"

> $password = Random::dashed();
= "91m3K-TttUb-tBwdV-C5Llm-IngAC"

Этот метод был одной из причин, побудивших меня создать данный пакет. Я постоянно встречал небезопасные функции генерации паролей, которые делали всевозможные неприятные вещи (как в примере, который я использовал выше).

Я постарался сделать его как можно более гибким — он позволяет включать и выключать каждый из типов символов, а также требовать по крайней мере один символ из каждого типа (с помощью $requireAll). Я также хотел поддерживать пользовательские наборы символов (подробнее об этом позже!). Все эти требования я видел в генераторе паролей Laravel, поэтому я хотел их поддержать.

Примечание: я специально избегаю символа пробела в наборе символов пароля. Я знаю, что некоторым людям нравится включать его, но считаю, что он добавляет ненужную сложность (не может быть первым или последним) и путаницу (неполное копирование-вставка, перенос слов и т. д.), не принося никакой реальной пользы (это просто ещё один символ).

Я добавил хелперы-обёртки, предназначенные для покрытия общих случаев использования, чтобы вам не нужно было запоминать все параметры основного метода string(), а также для того, чтобы код читался немного приятнее, когда они используются.

Пароли с разделителями

В случаях, когда вам нужно сгенерировать случайный пароль, который пользователь должен прочитать и ввести, полезно разбить длинную строку на более мелкие фрагменты с помощью разделителя. Именно это и делает хелпер dashed():

// Random::dashed(int $length = 25, string $delimiter = '-', int $chunkLength = 5): string;

> $password = Random::dashed();
= "91m3K-TttUb-tBwdV-C5Llm-IngAC"

Вы можете настроить длину и разделитель, а также длину фрагмента, для получения строки в нужном формате:

> $password = Random::dashed($length = 12, $delimiter = '.', $chunkLength 3);
= "6Jl.6sV.iFA.Hd3"

$requireAll

Если вам нужно хотя бы по одному символу из каждого типа, вы можете включить параметр $requireAll.

Например:

// Случайно выбираются только заглавные буквы и символы
> $password = Random::string(length: 5, requireAll: false);
= ")OR`{"

// По крайней мере, по одному: строчные, прописные, числовые, символы
> $password = Random::string(length: 5, requireAll: true);
= "d4)T-"

Я включил эту опцию, несмотря на то, что современные рекомендации по паролям больше не требуют усложнения пароля за счёт типов символов, поскольку некоторые медленные корпоративные правила могут по-прежнему требовать этого при генерации паролей. По умолчанию она отключена, но если вам понадобится, то она есть.

Произвольные наборы символов

Если вам нужно переопределить конкретные наборы символов, используемые string(), вы можете сделать так:

// Переопределение только символов
$generator = Random::useSymbols(['!', '@', '#', '$', '%', '^', '&', '*', '(', ')'])->string();
= "UZWS2KYiK)(XECWLQbs9yYveH#@gwVpo"

// Переопределение всего
$generator = Random::useLower(range('a', 'f'))
->useUpper(range('G', 'L'))
->useNumbers(range(2, 6))
->useSymbols(['!', '@', '#', '$', '%', '^', '&', '*', '(', ')']);

$string = $generator->string(
$length = 32,
$lower = true,
$upper = true,
$numbers = true,
$symbols = true,
$requireAll = true
);
= "fG22aIG@%fad25b264)fe(b5G3JKe46("

Эти методы use*() возвращают новый экземпляр \Valorin\Random\Generator, содержащий все методы Random, но использующий заданный набор символов.

Это даёт полный контроль над тем, какие символы будут включены в генерируемые строки — что полезно, если ваша система поддерживает ограниченное количество символов, или, может быть, вы хотите сгенерировать шестнадцатеричную строку?

> $string = Random::useUpper(range('A', 'F'))->string(
$length = 32,
$lower = false,
$upper = true,
$numbers = true,
$symbols = false,
$requireAll = true
);
= "5C65DF598AD08CF94D129040F2668025"

Перемешивание Массива, Строки или Коллекции

Помимо генерации случайностей, вам также понадобится безопасно перемешивать массивы, строки и коллекции:

$shuffled = Random::shuffle(
array|string|\Illuminate\Support\Collection $values,
bool $preserveKeys = false
): array|string|\Illuminate\Support\Collection;

> $shuffled = Random::shuffle(['a', 'b', 'c', 'd', 'e']);
= [
"e",
"b",
"a",
"d",
"c",
]

> $shuffled = Random::shuffle(['a', 'b', 'c', 'd', 'e'], $preserveKeys = true);
= [
3 => "d",
2 => "c",
1 => "b",
4 => "e",
0 => "a",
]

> $string = Random::shuffle('abcde');
= "bdcae"

> $collection = new Collection(['a', 'b', 'c', 'd', 'e']);
> $shuffled = Random::shuffle($collection);
> $shuffled->toArray();
= [
"a",
"c",
"e",
"d",
"b",
]

Этот метод — ещё одна причина, по которой я захотел создать Random. Почти все реализации тасования, которые я видел во время аудита (и многие вне моего аудита), были в той или иной степени небезопасны. Большинство из них просто используют один из сырых PHP-методов shuffle() — но все они используют небезопасную случайность. Люди не знают, как безопасно перетасовывать значения, и я хотел это изменить.

Новые хелперы PHP 8.2 \Random\Randomizer::shuffleArray() и \Random\Randomizer::shuffleBytes() дают нам безопасное перемешивание, поэтому я обернул их внутри Random и добавил поддержку Collections.

Я включил поддержку Коллекций Laravel, потому что метод shuffle() для Коллекций небезопасен и не должен использоваться. Я постараюсь исправить это в v11, но старые версии всё ещё будут содержать небезопасный метод shuffle, поэтому полезно иметь набор инструментов, который может легко справиться с этим.

Выбрать X элементов или символов

Вслед за перемешиванием ещё одним распространённым вариантом использования является выбор одного или нескольких элементов, или символов из значения.

$picks = Random::pick(
array|string|\Illuminate\Support\Collection $values,
int $count
): array|string|\Illuminate\Support\Collection;

$pick = Random::pickOne(
array|string|\Illuminate\Support\Collection $values
): array|string|\Illuminate\Support\Collection;

// Выбрать из массива
> $picked = Random::pick(['a', 'b', 'c', 'd', 'e'], 1);
= "c"

> $picked = Random::pick(['a', 'b', 'c', 'd', 'e'], 3);
= [
"b",
"a",
"c",
]

> $picked = Random::pickOne(['a', 'b', 'c', 'd', 'e']);
= "d"

// Выбрать из строки
> $picked = Random::pick('abcde', 1);
= "a"

> $picked = Random::pick('abcde', 3);
= "dbc"

> $picked = Random::pickOne('abcde');
= "e"

// Выбрать из Коллекции
> $collection = new Collection(['a', 'b', 'c', 'd', 'e']);
> $picked = Random::pick($collection, 1);
= "d"

> $picked = Random::pick($collection, 3);
> $picked->toArray();
= [
"b",
"c",
"a",
]

> $picked = Random::pickOne($collection, 1);
= "a"

Выбирать элементы случайным образом довольно просто, если у вас есть инкрементные ключи, но я часто вижу, как это делается с помощью rand() или shuffle(). Эти функции делают безопасный выбор элементов тривиальной операцией.

Я решил возвращать единственное выбранное значение из массива и коллекции при $count = 1, так как это позволяет избежать необходимости извлекать единственное значение из массива. Также, если вы ещё не поняли, метод pickOne() — это алиас метода pick($values, $count = 1).

Поддержка Коллекций включена сюда по той же причине, что и shuffle() — существующие методы Laravel небезопасны.

Использование определённого \Random\Engine

Random использует внутренний PHP 8.2 Random\Randomizer для всех случайностей, а значит, вы можете указать собственный \Random\Engine для обеспечения случайности.

Random поддерживает это с помощью метода use(), создающего собственный Generator вокруг Randomizer Engine, позволяя использовать все методы генератора:

$generator = Random::use(\Random\Engine $engine): \Valorin\Random\Generator;

> $generatorOne = Random::use(new \Random\Engine\Mt19937($seed = 3791));
> $generatorTwo = Random::use(new \Random\Engine\Mt19937($seed = 3791));

> $number = $generatorOne->number(1, 1000);
= 65

> $number = $generatorTwo->number(1, 1000);
= 65

> $password = $generatorOne->password();
= "MOz:^U/Hc?PsZD[e"

> $password = $generatorTwo->password();
= "MOz:^U/Hc?PsZD[e"

Возвращаемый объект Generator будет использовать предоставленный Engine, независимо от любого другого генератора или первичных хелперов Random. Это позволяет вам делать такие вещи, как установка засеянной случайности в конкретном объекте, не затрагивая другие части вашего приложения.

Именно по этой причине моя попытка исправить небезопасную случайность в Laravel в феврале 2023 года провалилась. Многие люди использовали srand() в своих приложениях, и изменение реализаций случайности приводило к тому, что из предсказанных значений на выходе получались случайные, и всё ломалось…

Это было проблемой только потому, что я пытался добавить его после выхода v10, так что это было ломающее изменение в минорном релизе. Я планирую исправить это в v11 до её выхода, чтобы изменение было задокументировано, и люди, использующие кастомные сидеры, могли обновить свой код в рамках обновления.

Если вам не нужен один из пользовательских движков, или если не нужно заполнять случайными значениями, установка пользовательского движка — скорее всего, вам не понадобится. Но она есть, если вам это понадобится.

Будущее Random

Теперь, когда мы рассмотрели все текущие возможности, что ждёт Random дальше?

Не знаю… Мне кажется, что функционал завершён, но я думал так до добавления хелпера dashed(), который я добавил в середине написания этой статьи! Так что я думаю, что это будет делом совершенствования API и добавления помощников и поддержки специфических случаев использования случайности.

Я бы хотел, чтобы он стал пакетом-выручалочкой в любой момент, когда вам нужно что-то сделать со случайностью, помимо простой генерации случайных чисел.

Итоги

Надеюсь, вы узнали что-то новое из нашего погружения в Random, и это заставило задуматься о том, как вы используете случайность в своих приложениях. Если у вас есть что-то небезопасное, почему бы не добавить Random и посмотреть, будет ли он делать то, что вам нужно 🙂.

Пожалуйста, проверьте код на GitHub и следите за любыми слабыми местами или ошибками, которые я мог упустить. Кроме того, дайте знать, если у вас есть предложения по улучшению!

Дополнительные материалы

Предыдущая Статья

Как использовать Fetch API в Node.js, Deno и Bun

Следующая Статья

Наука о JavaScript движке: Как компьютеры читают ваш код