Криптографические генераторы CSPRNG: почему Math.random() небезопасен в JavaScript и как устроен Web Crypto API
Фундаментальное руководство по криптографической случайности в веб-разработке. Разбираем математические различия между PRNG и CSPRNG, уязвимость алгоритма xorshift128+ в движке V8, устройство энтропийных пулов операционной системы, синтаксис crypto.getRandomValues() и безопасную генерацию паролей и токенов без смещения распределения.
1. Фундаментальная разница между PRNG и CSPRNG: теория вероятностей и энтропия
### Детерминированные автоматы и проблема начального состояния (Seed)
Любой классический генератор PRNG представляет собой детерминированный конечный автомат. Он начинает работу с некоторого начального числа — зерна (seed), а затем при каждом вызове применяет математическую функцию для вычисления следующего значения: $S_{n+1} = f(S_n)$.
Поскольку функция $f$ и размер внутреннего состояния конечны, последовательность чисел неизбежно зацикливается, образуя период повторения. В простых генераторах линейного конгруэнтного метода (LCG), таких как формула $X_{n+1} = (aX_n + c) \pmod m$, внутреннее состояние тривиально вычисляется по двум последовательным значениям.
Главная цель обычного PRNG — скорость и статистическая равномерность распределения. Такие алгоритмы разрабатываются для имитационного моделирования (метод Монте-Карло), трехмерной графики, процедурной генерации игровых ландшафтов и перемешивания визуальных элементов. В этих задачах абсолютно не важно, сможет ли наблюдатель угадать следующее число. Важно лишь, чтобы числа вычислялись за миллиардные доли секунды и в среднем покрывали интервал от 0 до 1 без явных кластеров.
### Критерий криптографической стойкости: тест следующего бита (Next-Bit Test)
Генератор признается криптографически стойким (CSPRNG) только в том случае, если он удовлетворяет строгим требованиям криптоанализа, впервые математически сформулированным Эндрю Яо в 1982 году:
1. Тест следующего бита (Next-Bit Test): зная алгоритм генерации и первые $k$ бит выходной последовательности, никакой полиномиальный по времени алгоритм не может предсказать $(k+1)$-й бит с вероятностью, превышающей $\\frac{1}{2} + \\epsilon$, где $\\epsilon$ пренебрежимо мало.
2. Устойчивость к компрометации состояния (State Compromise Extension): даже если злоумышленник сумеет перехватить текущее внутреннее состояние генератора в момент времени $t$, он не должен иметь возможности восстановить предыдущие случайные числа, сгенерированные до этого момента (прямая секретность / forward secrecy).
Обычные функции вроде стандартной
Math.random() в JavaScript с треском проваливают оба этих критерия. Использование их в контексте безопасности создает иллюзию защиты, за которой скрывается полная уязвимость. 2. Почему Math.random() категорически запрещен в безопасности: анатомия xorshift128+
Math.random() возвращает дробные числа с 16 знаками после запятой, то угадать их человеку невозможно. Однако атаку совершает не человек, а алгоритм.### Устройство алгоритма xorshift128+ в движке V8 (Google Chrome, Node.js)
С 2015 года в движке Google V8, на котором работают браузер Chrome, Microsoft Edge, Opera, а также серверные платформы Node.js и Deno, функция
Math.random() реализована на базе алгоритма xorshift128+. Алгоритм xorshift128+ оперирует внутренним состоянием всего в 128 бит, разделенным на два 64-битных беззнаковых регистра:
s0 и s1. На каждом шаге выполняются быстрые побитовые операции: сдвиги влево и вправо, операция исключающего ИЛИ (XOR), а на выходе возвращается сумма этих состояний:`javascript// Псевдокод внутреннего шага xorshift128+ в движке V8:
let s1 = state0;
const s0 = state1;state0 = s0;
s1 ^= s1 << 23n;
state1 = s1 ^ s0 ^ (s1 >> 17n) ^ (s0 >> 26n);
return (state1 + s0) & 0xFFFFFFFFFFFFFFFFn;
`Число с плавающей точкой
Math.random() получается делением полученного 64-битного целого числа на $2^{53}$ (что соответствует 53 битам мантиссы в стандарте IEEE 754).### Восстановление внутреннего состояния за 5 последовательных чисел
Поскольку xorshift128+ является линейным по модулю 2 преобразованием, знание всего лишь 5 последовательных значений, возвращенных
Math.random(), позволяет составить систему линейных уравнений над полем GF(2). С помощью современных решателей систем условий (SMT-солверов вроде Z3) или матричного метода исключения Гаусса внутреннее 128-битное состояние
[s0, s1] восстанавливается менее чем за 50 миллисекунд процессорного времени. Как только состояние вычислено, злоумышленник получает абсолютную возможность:- Точно предсказать все последующие значения
Math.random(), которые когда-либо сгенерирует данный процесс;- Восстановить все предыдущие значения, которые были сгенерированы до перехвата.
### Реальные векторы атак в веб-приложениях
Если веб-разработчик использует
Math.random() для чувствительных операций, последствия катастрофичны:1. Токены сброса паролей (Password Reset Tokens): генерируя токен восстановления доступа через
Math.random().toString(36), сервер отдает злоумышленнику ключ к захвату любой учетной записи. Атакующий запрашивает несколько сбросов для подконтрольного аккаунта, вычисляет внутреннее состояние генератора V8 на сервере и мгновенно предугадывает токен для аккаунта администратора.2. Сессионные идентификаторы и CSRF-токены: предсказание куки сессии позволяет авторизоваться под чужим именем без ввода пароля.
3. Генерация паролей на клиенте: если инструмент создания паролей полагается на
Math.random(), созданный пароль детерминирован и будет вскрыт целевым перебором за минуты. В сервисе Utilora для этого применяется строго криптостойкий генератор паролей.4. Одноразовые SMS-коды и промокоды (OTP): предугадывание 6-значных кодов подтверждения банковских и транзакционных операций.
3. Архитектура Web Crypto API: как устроен crypto.getRandomValues()
crypto (в Node.js — через модуль node:crypto или глобальный crypto начиная с Node 19).Основной низкоуровневой функцией генерации случайности является
crypto.getRandomValues(). В отличие от Math.random(), она не использует математические формулы на уровне JS-движка, а обращается напрямую к ядру операционной системы.### Источники энтропии операционной системы
Операционная система непрерывно собирает физическую непредсказуемость (энтропию) от аппаратных компонентов компьютера:
- Точные микросекундные задержки между прерываниями от клавиатуры и перемещениями курсора мыши;
- Флуктуации времени отклика жестких дисков и контроллеров твердотельных накопителей (SSD);
- Шум сетевых пакетов и контроллеров Ethernet/Wi-Fi;
- Специализированные аппаратные инструкции современных процессоров (инструкции Intel/AMD
RDRAND и RDSEED, использующие тепловой шум кремниевого кристалла).Собранная энтропия смешивается криптографическими хеш-функциями и направляется в системный CSPRNG ядра:
- В Linux / Android: системный вызов
getrandom(2) или виртуальное устройство /dev/urandom, использующее алгоритм ChaCha20;- В Windows: криптографический сервис Cryptography API: Next Generation (CNG) через вызов функции
BCryptGenRandom с флагом BCRYPT_USE_SYSTEM_PREFERRED_RNG;- В macOS / iOS: системный вызов
getentropy(2) и криптографические демоны ядра Darwin на базе шифра AES-CTR.### Работа с типизированными массивами (TypedArray)
Функция
crypto.getRandomValues() не возвращает примитивное число с плавающей точкой. Она принимает в качестве аргумента типизированный массив JavaScript и заполняет его сырыми случайными байтами на месте (in-place):`javascript// Создаем типизированный массив на 16 байт (128 бит)
const buffer = new Uint8Array(16);// Заполняем массив криптографически стойкими случайными байтами
window.crypto.getRandomValues(buffer);
console.log(buffer); // Uint8Array(16) [ 214, 45, 189, 7, ... ]`Поддерживаются любые целочисленные массивы:
Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, Uint16Array, Int32Array, Uint32Array, BigInt64Array и BigUint64Array. Массивы чисел с плавающей точкой (Float32Array, Float64Array) намеренно запрещены спецификацией, чтобы избежать ошибок округления мантиссы.### Квота на размер буфера: лимит 65 536 байт
Спецификация W3C Web Cryptography жестко ограничивает максимальный размер массива за один вызов: ровно 65 536 байт (64 КБ). Если попытаться передать массив большего размера, браузер выбросит исключение
QuotaExceededError:`javascripttry {
const hugeBuffer = new Uint8Array(65537);crypto.getRandomValues(hugeBuffer);
} catch (e) {
console.error(e.name); // "QuotaExceededError"
}
`Этот лимит введен намеренно: синхронное получение мегабайтов случайных данных из системного пула энтропии могло бы исчерпать энтропию ядра ОС и заблокировать основной поток выполнения (Event Loop) браузера. Если приложению требуется больше 64 КБ данных, заполнение буфера выполняется циклами.
4. Практические примеры безопасной реализации в JavaScript
Math.random() на crypto.getRandomValues() является проблема неравномерности распределения (Modulo Bias).### Проблема Modulo Bias и алгоритм отбрасывания (Rejection Sampling)
Предположим, вам нужно получить случайное целое число от 0 до 5 (например, бросок игрального кубика). Разработчик берет случайный байт от 0 до 255 и применяет операцию остатка от деления:
byte % 6. Это грубая ошибка! Диапазон байта содержит 256 значений. При делении 256 на 6 получается 42 с остатком 4. Это означает:
- Числа 0, 1, 2, 3 получаются из 43 различных байтов;
- Числа 4 и 5 получаются только из 42 различных байтов.
Вероятность выпадения 0, 1, 2, 3 составляет $43 / 256 \approx 16.797\%$, а чисел 4 и 5 — $42 / 256 \approx 16.406\%$. В криптографии такое смещение (bias) позволяет злоумышленникам проводить статистические атаки на пароли и ключи.
Правильный подход — Rejection Sampling (отбрасывание неполного диапазона):
`javascript/**
* Безопасная генерация целого числа в диапазоне [min, max] без Modulo Bias
*/
function secureRandomInt(min, max) {if (min > max) throw new Error("min must be <= max");
const range = max - min + 1;if (range === 1) return min;
// Вычисляем максимальное число, кратное range, умещающееся в 32 бита
const maxAllowed = Math.floor(0xFFFFFFFF / range) * range;
const buffer = new Uint32Array(1);while (true) {
window.crypto.getRandomValues(buffer);
const candidate = buffer[0];// Если кандидат попадает в равномерный отрезок, возвращаем результат
if (candidate < maxAllowed) {
return min + (candidate % range);
}
// Иначе отбрасываем и повторяем попытку (вероятность повтора ничтожно мала)
}
}
`### Генерация криптостойких паролей и секретных токенов
Используя функцию без смещения, можно построить абсолютно надежный генератор паролей, гарантирующий включение всех необходимых классов символов:
`javascriptfunction generateSecurePassword(length = 20) {
const charset = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*()_+-=";
const chars = [];for (let i = 0; i < length; i++) {
const index = secureRandomInt(0, charset.length - 1);chars.push(charset[index]);
}
return chars.join('');
}
`При работе с токенами идентификации стандартом де-факто является UUID версии 4 по спецификации RFC 4122. В современных браузерах для этого доступен нативный метод
crypto.randomUUID(), создающий 128-битный идентификатор с 122 битами чистой энтропии. Подробнее о структуре версий идентификаторов читайте в нашем специальном руководстве сравнение UUID v4 и UUID v7, а генерировать их онлайн можно через генератор UUID.Для генерации криптографических хешей токенов и солей используйте Web Crypto API
subtle.digest или онлайн-утилиту генератор хешей SHA-256.### Сравнительная таблица: Math.random() против crypto.getRandomValues()
| Характеристика | Math.random() | crypto.getRandomValues() | crypto.randomUUID() |
|---|:---:|:---:|:---:|
| Тип генератора | PRNG (xorshift128+) | CSPRNG (ChaCha20 / AES-CTR) | CSPRNG (RFC 4122 v4) |
| Источник энтропии | Псевдозерно в памяти движка | Системный пул энтропии ОС | Системный пул энтропии ОС |
| Криптостойкость | ❌ Нет (взлом за 5 вызовов) | ✅ Полная криптостойкость | ✅ Полная криптостойкость |
| Скорость выполнения | Экстремально высокая (~100 млн/сек) | Высокая (~5 млн/сек) | Высокая (~3 млн/сек) |
| Формат вывода | Float64 от 0.0 до 1.0 | TypedArray целых чисел | Строка формата UUID |
| Лимит за вызов | 1 число | 65 536 байт | 1 идентификатор |
| Сфера применения | Анимация, игры, канвас | Пароли, токены, криптография | Первичные ключи, сессии |
5. Чек-лист безопасности и типичные ошибки разработчиков
### Чек-лист аудита генерации случайных данных:
- [ ] Никакого
Math.random() в токенах: в кодовой базе выполнен поиск по регулярному выражению Math\.random\(\) и проверено, что вызовы не связаны с ключами, токенами доступа, солями и паролями.- [ ] Отсутствие псевдо-усиления случайности: исключены конструкции вида
Math.random() + Date.now() или Math.sin(Date.now()). Добавление текущего времени не усиливает энтропию, так как время запроса известно атакующему с точностью до миллисекунды.- [ ] Защита от Modulo Bias: при конвертации случайных байтов в произвольные интервалы используется алгоритм отбрасывания (Rejection Sampling).
- [ ] Контроль квоты буфера: запросы объемом более 64 КБ разбиваются на последовательные чанки для предотвращения исключения
QuotaExceededError.- [ ] Безопасная очистка памяти: чувствительные криптографические ключи и пароли в буферах
Uint8Array после использования обнуляются методом .fill(0).- [ ] Использование
crypto.randomUUID(): генерация UUID v4 переведена на нативный API браузера вместо самописных регулярных выражений.- [ ] Privacy-First архитектура: генерация чувствительных секретов происходит на стороне клиента в браузере пользователя без передачи по сети.
О том, как длина алфавита и энтропия влияют на время взлома пароля методом брутфорса, читайте в нашей статье руководство по безопасности паролей.
Попробуйте онлайн-инструменты по этой теме
Используйте бесплатные утилиты Utilora прямо в браузере без регистрации:
Генератор паролей
Создание криптографически стойких паролей с расчётом энтропии Шеннона и времени подбора.
Запустить ▸Генератор UUID
Создание криптографически стойких уникальных идентификаторов UUID v4 (GUID) по стандарту RFC 9562.
Запустить ▸Хэш-генератор
Мгновенное вычисление криптографических хэшей MD5 (RFC 1321), SHA-1, SHA-256 и SHA-512 в браузере с аппаратным ускорением Web Crypto API.
Запустить ▸Часто задаваемые вопросы (FAQ)
Да. Алгоритм xorshift128+, применяемый в Chrome и Node.js, восстанавливается по 5 последовательным значениям через SMT-солвер менее чем за 0.1 секунды. После этого атакующий точно знает все прошлые и будущие числа этого генератора.
Главная причина — производительность и сохранение ресурсов батареи. Math.random() выполняется за единицы наносекунд в пользовательском пространстве памяти без системных вызовов к ядру ОС, что критично для игр, анимаций и математического моделирования.
Да, абсолютно автономно. Источником случайности выступает ядро локальной операционной системы (аппаратные шумы процессора, таймеры прерываний). Никаких запросов к внешним серверам не отправляется.
Это защитный лимит спецификации W3C Web Cryptography (64 КБ). Он предотвращает исчерпание пула энтропии ядра ОС и защищает браузер от зависания потока выполнения при запросе слишком больших объемов данных.
Это иллюзия безопасности. Временная метка Date.now() известна злоумышленнику с точностью до диапазона сетевого ответа сервера (секунды или миллисекунды). Это сужает пространство перебора всего до нескольких тысяч комбинаций вместо сотен миллиардов.
Да. Начиная с Node.js 19 объект crypto доступен глобально так же, как в браузере. В более ранних версиях Node.js используется модуль node:crypto с методом crypto.randomBytes() или webcrypto.getRandomValues().
Проверьте условие: typeof window !== 'undefined' && window.crypto && typeof window.crypto.getRandomValues === 'function'. В безопасных контекстах (HTTPS и localhost) этот API поддерживается во всех актуальных браузерах с 2014 года.