SecuredKit

Опубліковано: · Оновлено: · автор:

Що таке CSPRNG і чому Math.random() небезпечний (2026)

CSPRNG — джерело випадковості, яке не передбачити за його виходом. Я відновив стан Math.random() з 4 значень у Node 22: як це працює і що брати натомість.

CSPRNG простими словами: передбачуваний вихід Math.random() проти непередбачуваного потоку crypto.getRandomValues()

CSPRNG (криптографічно стійкий генератор псевдовипадкових чисел) — це джерело випадковості, побудоване так, що жоден обсяг його виходу не допоможе передбачити наступне значення чи відновити попередні. Math.random() до них не належить. У Node 22 мені вистачило чотирьох послідовних значень Math.random(), щоб відновити внутрішній стан генератора й точно передбачити наступні дев'ять; скрипт і вивід нижче. Для паролів, токенів, солі та ідентифікаторів сесій беріть crypto.getRandomValues() у браузері та crypto.randomBytes() у Node.

PRNG чи CSPRNG: що їх насправді відрізняє

Будь-який програмний генератор псевдовипадковий: це детермінований алгоритм, який перетворює зерно (seed) на потік чисел, що виглядає випадковим. Різниця в тому, що може зробити спостерігач, який бачив частину цього потоку.

ВластивістьPRNG (Math.random() у V8)CSPRNG (crypto.getRandomValues())
Алгоритмxorshift128+DRBG операційної системи або браузера (ChaCha20, AES-CTR DRBG, Hash_DRBG)
Стан128 біт256 біт і більше, з перезасіванням з ентропії ОС
Чи можна передбачити за виходом?Так, приблизно з чотирьох значеньНі
Що розкриває викрадений станУсі минулі й майбутні значенняНічого з того, що було до витоку; перезасівання обмежує те, що буде після
Для чого створенийШвидкість: ігри, джитер, вибіркиКлючі, токени, сіль, nonce

Формально CSPRNG має пройти тест наступного біта (знаючи перші k бітів, ви вгадуєте біт k+1 з імовірністю не кращою за 50%) і витримувати компрометацію стану (викрадений сьогодні стан не розкриває вчорашній вихід). xorshift128+ не проходить жодної з двох вимог. Це не вада V8: специфікація ніколи не вимагала від Math.random() безпечності, і щоб перемішати плейлист, це правильний інструмент.

Я передбачив Math.random() за чотирма значеннями

V8, рушій Chrome, Node і Deno, використовує xorshift128+ для Math.random() з 2015 року. Кожен крок — кілька зсувів і XOR над двома 64-бітними словами, а кожне повернене число double будується з верхніх 52 бітів одного з них. Отже, кожен вихід розкриває 52 зі 128 бітів стану, а сам алгоритм публічний. Для атаки важлива ще одна деталь: V8 генерує значення пачками по 64 у кеш і видає їх у зворотному порядку.

Я написав короткий скрипт на Python із SMT-розв'язувачем Z3 (z3-solver 5.3.1 з pip). Він вважає стан двома невідомими 64-бітними числами, символьно проганяє через них xorshift128+, вимагає, щоб верхні 52 біти кожного кроку збігалися зі спостереженими значеннями (у зворотному порядку, через кеш), і просить Z3 знайти стан, що підходить. Якщо прокрутити знайдений стан назад, отримуємо значення, які Math.random() поверне далі. Ядро скрипта:

a, b = BitVec('a', 64), BitVec('b', 64)
s0, s1 = a, b
for v in reversed(observed):
    s0, s1 = s1, xorshift(s0, s1)        # one xorshift128+ step, symbolic
    S.add(LShR(s0, 12) == mantissa(v))   # top 52 bits = the double's mantissa
S.check()                                # sat: the state is now known

Запуск на свіжому процесі Node, скорочено:

$ node -e 'console.log(JSON.stringify(Array.from({length: 13}, () => Math.random())))'
[0.48530045300870905,0.4535024111129262,0.22613694600298828,0.6116499683877723,0.27607344271000467, ...]

$ python3 predict.py local.json 4
Node v22.23.2: 4 outputs observed, state recovered in 0.02s
  predicted 0.27607344271000467  actual 0.27607344271000467  match
  predicted 0.71206622404905096  actual 0.71206622404905096  match
  predicted 0.26313849711769510  actual 0.26313849711769510  match
  ...
  predicted 0.03887366515100310  actual 0.03887366515100310  match
9/9 exact

Я повторив це на дев'яти свіжих процесах у Node 22.22 і 22.23. З чотирма спостереженими значеннями кожен запуск точно передбачив усі наступні значення в кеші, і розв'язувачу ніколи не знадобилося більше кількох сотих секунди. З трьома значеннями (156 спостережених бітів проти 128 невідомих) Z3 знаходив стан, який підходив до вхідних даних, але не вгадував жодного наступного значення, і так в усіх чотирьох спробах.

Межа цього тесту: я давав розв'язувачу «сирі» числа double. Реальний код зазвичай розкриває щось на кшталт Math.floor(Math.random() * 62), тобто близько 6 бітів за виклик замість 52. Атакувальнику тоді потрібно більше спостережень, і доведеться враховувати перезаповнення 64-значного кешу. Таку версію я не запускав. Вона змінює кількість значень, які треба побачити, але не те, чи можна відновити стан. Тому будь-який секрет, створений через Math.random(), я вважаю вже публічним.

Звідки CSPRNG бере непередбачуваність

CSPRNG — теж алгоритм, тож непередбачуваність має походити із зерна. Операційна система збирає її з фізичного шуму: таймінгів переривань, джитеру пристроїв і вводу-виводу, а на більшості сучасних процесорів ще й з апаратного джерела на кшталт Intel RDRAND. Ця ентропія засіває детермінований генератор (ChaCha20 у ядрі Linux починаючи з 4.8, AES-CTR DRBG за BCryptGenRandom у Windows), який розтягує 256-бітне зерно на будь-який потрібний обсяг виходу й з часом перезасівається.

Конвеєр ентропії CSPRNG: апаратні джерела шуму наповнюють пул ентропії ядра, який засіває ChaCha20 DRBG для crypto.getRandomValues()

crypto.getRandomValues() зрештою звертається саме до цього механізму ОС. Деякі браузери передають виклик напряму, а Firefox запускає власний DRBG у NSS, засіяний від ОС. В обох випадках немає «швидкого, але слабкого» режиму, який можна випадково ввімкнути, а саме це потрібно від API, яким користуються, не читаючи документацію.

Як перетворити випадкові байти на символи без зміщення

Усе в SecuredKit працює у вашому браузері, і нічого згенерованого не надсилається на сервер, тож генераторам потрібне було безпечне джерело випадковості, доступне локально. Це crypto.getRandomValues(). Проте отримати безпечні байти — простіша половина справи. Типова помилка ховається в наступному рядку, де байт або 32-бітне слово втискають у менший діапазон через %.

Якщо взяти один байт і алфавіт із 62 символів, byte % 62 розкладає 256 значень на 62 позиції. 256 не ділиться на 62 без остачі, тож перші 8 символів отримують 5 шансів із 256, а решта 54 — по 4. Ці 8 символів з'являються на 25% частіше. На такому розмірі втрата ентропії невелика, приблизно 0,0045 біта на символ (20-символьний ключ падає зі 119,08 до 118,99 біта), але вона зростає, коли діапазон наближається до розміру джерела. Я заміряв byte % 200 на 2 000 000 вибірок у Node 22: значення 0 випало 15 637 разів, а значення 199 — 7 886 разів, майже рівно передбачені 2:1.

Виправлення — вибірка з відхиленням (rejection sampling): відкидаєте значення з нерівного «хвоста» й тягнете ще раз. Ось допоміжна функція, якою користуються генератори паролів, парольних фраз, PIN-кодів і алфавітно-цифрових ключів на сайті (скорочено):

const UINT32_RANGE = 2 ** 32

export function randomInts(max: number, count: number): number[] {
  // Largest multiple of max that fits in 32 bits; words at or above it are rejected.
  const limit = UINT32_RANGE - (UINT32_RANGE % max)
  const out: number[] = []
  while (out.length < count) {
    const words = crypto.getRandomValues(new Uint32Array(Math.min(16_384, count - out.length + 4)))
    for (const word of words) {
      if (word >= limit) continue
      out.push(word % max)
      if (out.length === count) break
    }
  }
  return out
}

З 32-бітними словами та алфавітом із 62 символів 2³² mod 62 дорівнює 4, тобто слово відкидається 4 рази на приблизно 4,3 мільярда. Аргументу про продуктивність, щоб цього не робити, просто немає. Моя позиція: для алфавіту з 62 символів зміщення за модулем — це помилка коректності, а не практичний злам, але я все одно виправляв би її на будь-якому код-рев'ю, бо той самий шаблон % n дає реальний перекіс того дня, коли хтось застосує його до діапазону на кшталт 200. Генератор випадкових чисел використовує ту саму ідею з BigInt для діапазонів понад 2⁵³.

Скільки випадкових байтів достатньо

Коли джерело — справжній CSPRNG, лишається одна змінна: скільки бітів ви берете. Формула та сама, що й для стійкості пароля:

H = L × log₂(R)

H — ентропія в бітах, L — кількість символів, R — кількість можливих значень одного символу. Для сирих байтів R = 256, тож кожен байт дає 8 біт: 16 байтів — 128 біт, 32 байти — 256. Формула припускає, що R чесне. Токен, зібраний із Math.random(), може мати 40 символів і все одно нести майже нуль бітів проти того, хто бачив чотири інші виходи того самого процесу.

Вихід CSPRNGЕнтропіяДля чого
8 байтів64 бітиЗакоротко для будь-якого секрету
16 байтів128 бітІдентифікатори сесій, токени скидання пароля, CSRF-токени
32 байти256 бітAPI-ключі, секрети для підписів, усе довгоживуче

Для масштабу: одна RTX 4090 перебирає близько 22 мільярдів хешів SHA-256 за секунду (hashcat 6.2.6). Якби перевірка одного кандидата коштувала один SHA-256, пошук у половині 64-бітного простору забрав би на цій карті близько 13 років, а в половині 128-бітного — близько 2,5 × 10²⁰ років. 128-бітний токен ніхто не атакує в лоб. Шукають Math.random(), що стоїть за ним. Ту саму математику для паролів я розбираю в статті про те, скільки ентропії потрібно паролю.

Як перевірити власний код

Почніть із grep. Він за кілька секунд знаходить місця викликів, які мають значення:

grep -rnE "Math\.random\(\)" src/ | grep -iE "token|secret|pass|key|otp|salt|nonce|id"

У власних components/, composables/, utils/ і pages/ SecuredKit пошук Math.random() навіть без фільтра нічого не знаходить, і саме такий результат вам потрібен. Для кожного збігу у вашому коді перевірте дві речі: джерело (crypto.getRandomValues() або crypto.randomUUID() у браузері, crypto.randomBytes() або crypto.randomInt() у Node) і те, як байти перетворюються на діапазон (% n без відхилення дає описане вище зміщення).

Статистичні набори тестів на кшталт NIST SP 800-22 (15 тестів) чи dieharder питання не закриють. xorshift128+ проходить більшість із них і все одно повністю передбачуваний, як показує запуск розв'язувача. Вони корисні, щоб зловити зламаний крок перетворення, але не доводять, що джерело безпечне.

Якщо знайшли секрет, створений через Math.random(), замініть його, а не з'ясовуйте, чи хтось це помітив: нові паролі з Генератора паролів, нові ключі з Генератора секретних ключів або crypto.randomBytes(32), а старі значення анулюйте.

FAQ

Скільки випадкових байтів потрібно для безпечного токена?

16 байтів (128 біт) — практичний мінімум для токенів сесій та ідентифікаторів. Для API-ключів і довгоживучих секретів беріть 32 байти (256 біт). У base64url 16 байтів дають 22 символи, а 32 байти — 43.

Чи можна використовувати crypto.randomUUID() як токен?

Щодо непередбачуваності так: специфікація Web Crypto вимагає криптографічно стійкого джерела, а UUID v4 містить 122 випадкові біти. Для довгоживучих API-ключів я все одно брав би 32 випадкові байти, бо додатковий запас нічого не коштує.

Чи є обмеження на обсяг, який crypto.getRandomValues() повертає за раз?

Так: 65 536 байтів за виклик. У Node 22 запит на 65 537 байтів кидає QuotaExceededError, тож великі буфери заповнюйте частинами. crypto.randomBytes() у Node такого обмеження на звичайних розмірах не має.

Спробуйте безкоштовний генератор паролів

Генеруйте надійні, безпечні паролі миттєво. 100% приватно, на стороні клієнта.

Відкрити генератор паролів