Блог · Безпека

Небезпека чисто-JS криптографії: timing-атаки, одноразовий k і відновлення ключа з підписів

Опубліковано

Алгоритм ДСТУ 4145 математично стійкий — але це не рятує, якщо реалізація тече. Розбираємо для розробників, чому криптографія, переписана «на JavaScript», вразлива там, де нативний код — ні, і що з цим робить WebAssembly.

Опубліковано 7 серпня 2026


Коротка відповідь. Небезпека чисто-JS криптографії — не в математиці алгоритмів, а в реалізації. Час виконання JavaScript залежить від даних (JIT, збирач сміття, оптимізації рушія), і ця залежність витікає назовні як timing side-channel. У схемах на еліптичних кривих — як-от українська ДСТУ 4145 — найкритичніше місце ще підступніше: одноразове число k. Його повторення чи передбачуваність дозволяє арифметично відновити приватний ключ із самих підписів, без жодного доступу до пристрою. Тому DSTUcrypt не переписує криптографію на JS: уся криптоарифметика виконується перевіреним нативним криптоядром, скомпільованим у WebAssembly, з константним часом, коректним k і ентропією з CSPRNG браузера.

Криптографія — не «просто математика в коді»

Поширена помилка розробника: «алгоритм опубліковано в стандарті, отже, будь-яка реалізація, що проходить тестові вектори, — безпечна». Насправді тестові вектори перевіряють лише коректність — що на вхід X виходить Y. Безпека — це інша властивість: реалізація не повинна витікати секретами через побічні канали, повторювати випадкові значення, покладатися на слабку ентропію.

Сучасні атаки на підпис майже ніколи не «ламають математику». ДСТУ 4145 на еліптичних кривих і геш-функції на кшталт «Купини» (ДСТУ 7564:2014) стійкі на папері. Атакують реалізацію: як саме код виконує скалярне множення, звідки бере випадковість, скільки часу займає операція залежно від бітів ключа. І саме тут чисто-JS реалізації — переписані з нуля ентузіастами, без років промислової експлуатації — програють нативним бібліотекам у найважливішому.

Timing side-channel: коли час виконання видає біти ключа

Timing-атака — це відновлення секрету за часом виконання операцій. Якщо обчислення з бітом ключа «1» триває хоч трохи довше, ніж із бітом «0», достатньо статистики за багатьма операціями, щоб відновити ключ біт за бітом. Захист відомий: константний час — код виконує однакову послідовність інструкцій незалежно від значень секрету.

У нативному C/C++ константний час — складна, але розв'язна інженерна задача: розробник контролює інструкції, які виконає процесор. У JavaScript це практично недосяжно, бо між вашим кодом і процесором стоїть рушій, який активно переписує програму на льоту:

  • JIT-компілятор оптимізує «гарячі» шляхи коду залежно від даних, які вже бачив: та сама функція в різні моменти виконується різним машинним кодом із різним таймінгом.
  • Деоптимізації та збирач сміття вносять паузи, що корелюють з обсягом і формою даних — зокрема з довжиною великих чисел, якими представлено координати точок кривої.
  • Арифметика довгих чисел у JS має час, залежний від значень: числа з «коротшим» представленням обробляються швидше, і ця різниця — готовий канал витоку.

Тобто навіть якщо автор чисто-JS бібліотеки сумлінно писав «константний» код, рушій не гарантує, що виконуватиметься саме він. Для звичайної бізнес-логіки це неважливо; для операцій із приватним ключем — критично.

Одноразове число k: найнебезпечніше місце ДСТУ 4145

У схемах підпису на еліптичних кривих родини ECDSA — до якої належить і ДСТУ 4145 — кожен підпис потребує свіжого випадкового числа k (nonce). Підпис — це пара чисел (r, s), де r обчислюється з точки k·P, а s зв'язує в одному рівнянні три величини: геш документа h, приватний ключ d і те саме k — схематично s = k⁻¹(h + d·r).

Тепер механіка катастрофи на пальцях. В одному рівнянні — два невідомих (k і d), тому з одного підпису ключ не дістати. Але якщо k повторився у двох підписах різних документів, невідомих усе ще два — а рівнянь уже два. Віднімаємо одне від одного — d скорочується, і k обчислюється з різниці гешів і різниці s. Підставляємо знайдене k назад — і отримуємо приватний ключ d. Уся «атака» — кілька рядків арифметики над двома публічними підписами: зловмиснику не потрібен ні доступ до пристрою, ні перехоплення ключа. Достатньо двох підписаних файлів.

Гірше того: k не обов'язково має повторитися повністю. Якщо він просто передбачуваний або зміщений — кілька старших бітів невипадкові, генератор має вузький діапазон — існують методи, які відновлюють ключ із десятків-сотень підписів, зібраних пасивно. Тому вимога до k потрійна: унікальний для кожного підпису, рівномірно випадковий, назавжди секретний. Порушення будь-якого пункту в реалізації означає, що кожен виданий підпис — це фрагмент вашого приватного ключа, викладений публічно.

Слабка ентропія: Math.random проти crypto.getRandomValues

Звідки чисто-JS реалізація бере випадковість для k? Правильна відповідь одна: crypto.getRandomValues() — криптографічний генератор (CSPRNG), яким браузер обгортає системне джерело ентропії. Але в JS-екосистемі історично поруч лежить Math.random() — швидкий некриптографічний генератор для ігор і анімацій. Його внутрішній стан невеликий і відновлюється спостерігачем за кількома вихідними значеннями, після чого всі наступні «випадкові» числа передбачувані.

crypto.getRandomValues() Math.random()
Призначення криптографія (CSPRNG) ігри, анімації, вибірки
Джерело системна ентропія ОС детермінований алгоритм із малим станом
Передбачуваність обчислювально непередбачуваний стан відновлюється за кількома значеннями
Придатність для k так — єдиний варіант ні: передбачуваний k = відновлюваний ключ

Небезпека в тому, що зовні різниці не видно: підпис із k від Math.random() валідний, проходить усі перевірки і працює роками — доки хтось не збере достатньо підписів і не обчислить ключ. Це помилка, яку неможливо помітити тестуванням, — лише аудитом реалізації.

Окрема пастка: перевірка підпису в браузері

Дзеркальна проблема — не підпис, а перевірка. Припустімо, ви робите вхід за КЕП і перевіряєте підпис користувача JS-бібліотекою на клієнті: бібліотека повернула true — пускаємо в кабінет. Це діра в авторизації: браузер повністю контролює користувач. Відкрити консоль і замінити результат перевірки на true — або просто надіслати вашому бекенду «підпис перевірено» — може будь-хто, взагалі без ключа.

Правило просте: рішення про допуск ухвалює лише бекенд на основі серверної перевірки. Саме так побудований вхід за КЕП у DSTUcrypt: ваш бекенд отримує одноразовий challenge, користувач підписує його у віджеті, а підпис ваш сервер віддає на POST /api/auth/verify — DSTUcrypt криптографічно перевіряє його нативним ядром на сервері, куди фронтенд не має доступу, і повертає підтверджену особу (ПІБ, РНОКПП, ЄДРПОУ). Одноразовість challenge унеможливлює повторне використання підпису. Те саме стосується будь-яких критичних рішень: приймаєте документ чи платіж — перевіряйте через серверний POST /api/verify, а не вірте вердикту з браузера.

Чому WASM-збірка нативної бібліотеки — принципово інша річ

Може здатися, що WebAssembly — це «той самий браузер, ті самі проблеми». Ні. Принципова різниця — не в місці виконання, а в походженні коду. WASM-модуль DSTUcrypt — це не переписування алгоритмів на новій мові, а компіляція тієї самої C/C++-кодової бази, що роками працює в українській PKI нативно — у ЦСК, серверних і десктопних системах. Константний час операцій із секретами, коректна генерація одноразового k, робота з пам'яттю — усе це закладено й перевірено в реалізації задовго до того, як вона потрапила у браузер.

До того ж WASM виконує низькорівневі інструкції з передбачуваною арифметикою фіксованої розрядності — без динамічних типів, прихованих алокацій і залежних від значень «зрізань» довгих чисел, які роблять таймінг JS-коду функцією секретних даних. А ентропію ядро бере з crypto.getRandomValues() — CSPRNG браузера. Бонус — швидкість, близька до нативної: гешування і підпис великих файлів працюють швидко навіть на слабких пристроях.

Що це дає в DSTUcrypt. Криптоарифметика — перевірена нативна C/C++-бібліотека, скомпільована у WebAssembly: константний час операцій (захист від timing-атак), коректний одноразовий k при кожному підписі (захист від відновлення ключа з підписів — класичної слабкості чисто-JS реалізацій ДСТУ), ентропія — з CSPRNG браузера. Це production-ядро з роками експлуатації в українській PKI, а не community-експеримент.

Для інтегратора це виглядає як звичайний виклик SDK — уся арифметика відбувається у WASM-ядрі всередині iframe, ключ і пароль не покидають браузер користувача:

підпис у браузері — арифметика у WASM-ядрі

import { embed } from 'https://dstucrypt.io/embed/dstucrypt-embed.mjs';

// віджет працює в iframe на origin dstucrypt.io;
// уся криптоарифметика — у WASM-збірці нативного ядра
const signer = await embed('sign', { mount: 'modal' });
const { signature, accredited } = await signer.sign(fileBytes, {
  format: 'CAdES-T',
  digest: 'gost-34311', // або 'kupyna-256' — «Купина» ДСТУ 7564
});
signature.download('document.p7s');

Чекліст для розробника

  • Не пишіть і не форкайте криптографію самі. Коректність за тестовими векторами ≠ безпека реалізації.
  • Перевіряйте походження криптоядра. Компіляція перевіреної нативної бібліотеки (C/C++ → WASM) — добре; алгоритм, переписаний на JS з нуля, — червоний прапорець для операцій із приватним ключем.
  • Питайте про константний час. Якщо в документації реалізації немає ні слова про side-channel — вважайте, що захисту немає.
  • k і ентропія — лише з CSPRNG. Будь-який слід Math.random() поруч із підписом — привід зупинити інтеграцію.
  • Рішення про допуск — лише після серверної перевірки. Вердикт JS-бібліотеки в браузері підробляється; використовуйте серверні POST /api/verify та POST /api/auth/verify.
  • Приватний ключ не має покидати браузер. Ізольований iframe на чужому origin захищає ключ навіть при XSS на вашому сайті.

Поширені запитання

Чи можна взагалі безпечно підписувати документи КЕП у браузері?

Так, якщо криптоарифметику виконує не саморобний JavaScript, а перевірена нативна бібліотека, скомпільована у WebAssembly. У DSTUcrypt це криптоядро, що роками працює в українській PKI: константний час операцій, коректне одноразове k і ентропія з CSPRNG браузера. Ключ і пароль при цьому не покидають браузер користувача.

Що станеться, якщо одноразове число k повториться у двох підписах?

Приватний ключ можна буде обчислити арифметично із самих підписів. Два підписи з однаковим k дають систему з двох рівнянь із двома невідомими — k і приватним ключем, — яка розв'язується на папері. Зловмиснику не потрібен доступ до пристрою: достатньо самих підписаних документів.

Чому не можна перевіряти підпис для входу за КЕП JS-бібліотекою в браузері?

Браузер контролює користувач, тому результат перевірки на клієнті можна підробити — наприклад, змінити відповідь скриптом у консолі. Рішення про допуск має ухвалювати бекенд на основі серверної перевірки: DSTUcrypt перевіряє підпис нативним криптоядром на сервері, а одноразовий challenge унеможливлює повторне використання.

Чим WebAssembly-збірка безпечніша за чисто-JS реалізацію?

WASM виконує ту саму кодову базу нативної C/C++-бібліотеки, що й у десктопних та серверних системах: константний час і коректна робота з одноразовим k закладені в реалізацію, перевірену роками промислової експлуатації. Чисто-JS реалізація — це окреме переписування алгоритму, в якому ці властивості треба забезпечувати з нуля, а рушій JavaScript цьому активно заважає.

Читайте також

КЕП на перевіреному нативному ядрі — за 10 хвилин

Готові віджети підпису, перевірки та шифрування за ДСТУ. Ключ і пароль не покидають браузер користувача. На кожному новому домені — 7 днів безкоштовно.

Живе демоЯк підключити

Спробуйте КЕП на своєму сайті

Готові віджети підпису, перевірки та входу за ДСТУ. На кожному новому домені — 7 днів безкоштовно.