generators • 12 мин чтения • Обновлено: 2026-09-28

Криптографические генераторы CSPRNG: почему Math.random() небезопасен в JavaScript и как устроен Web Crypto API

Фундаментальное руководство по криптографической случайности в веб-разработке. Разбираем математические различия между PRNG и CSPRNG, уязвимость алгоритма xorshift128+ в движке V8, устройство энтропийных пулов операционной системы, синтаксис crypto.getRandomValues() и безопасную генерацию паролей и токенов без смещения распределения.

1. Фундаментальная разница между PRNG и CSPRNG: теория вероятностей и энтропия

В программировании понятие «случайное число» часто скрывает под собой два принципиально разных механизма: псевдослучайные генераторы общего назначения (PRNG — Pseudo-Random Number Generator) и криптографически стойкие генераторы псевдослучайных чисел (CSPRNG — Cryptographically Secure Pseudo-Random Number Generator). Разница между ними определяет грань между устойчивой информационной системой и сервисом, взламываемым за несколько секунд.

### Детерминированные автоматы и проблема начального состояния (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()

Для решения задач криптографии W3C стандартизировал интерфейс Web Crypto API, доступный во всех современных браузерах через глобальный объект 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:

`javascript
try {
    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);
}
// Иначе отбрасываем и повторяем попытку (вероятность повтора ничтожно мала)
}
}
`

### Генерация криптостойких паролей и секретных токенов

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

`javascript
function 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 архитектура: генерация чувствительных секретов происходит на стороне клиента в браузере пользователя без передачи по сети.

О том, как длина алфавита и энтропия влияют на время взлома пароля методом брутфорса, читайте в нашей статье руководство по безопасности паролей.

Часто задаваемые вопросы (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 года.

Скопировано в буфер обмена!