Минулого уроку ти навмисно лишив у ньому дірку. Сьогодні ти її закриваєш — і закриваєш так, як це зроблено у Stripe, Slack і GitHub
| Етап | Хв | Що робимо |
|---|---|---|
| Атака-демо | 10 | Учитель кладе чужий запис у твій журнал, знаючи лише URL |
| Теорія: HMAC | 20 | Два однакові «млинки» хешування — рахуємо підпис вручну, міняємо 1 символ |
| Перевірка підпису | 30 | hash_hmac і порівняння за постійний час — X-Signature, відповідь 401 при розбіжності |
| Теорія: replay-атака | 15 | Перехоплений валідний запит надіслано вдруге — шукаємо захист |
| Timestamp і nonce | 25 | Вікно 300 с і сховище використаних nonce — тестуємо 409/401 на повторі |
| Стійкість до помилок | 10 | Тіло {{{ і порожній POST — доводимо 400 без падіння процесу |
| Автоперевірка | 5 | Усі 🔒-пункти теми 6 — доводимо до зеленого |
| Підсумок | 5 | Правило «URL — не секрет; секрет — це підпис» |
| Разом | 120 |
Слова HMAC, replay і nonce поки нічого не означають. Розберемо їх по черзі, кожне на своєму місці.
Урок 11 закінчився файлом api/hook.php. Він дописує рядок
у ~/data/events.jsonl кожному, хто надішле POST. Будь-кому.
Урок 3 додавав до схожого приймача токен у заголовку. Це вже краще, але сьогодні ти зробиш крок далі. Замість «покажи пароль» буде підпис самого тіла запиту.
один рядок, однаковий у кожному запиті
три заголовки, які працюють тільки разом
hook.php перевірку X-Signature
через hash_hmac і hash_equals;300 секунд для X-Timestamp;nonce у ~/data/nonces/
і закриєш повтор остаточно;api/security.md — так, щоб чужа
команда змогла надіслати тобі запит, не питаючи тебе.api/hook.php і api/security.md. Секрет
HOOK_SECRET ти дописуєш у ~/.env по SSH —
не по FTP і не в жодному файлі всередині ~/www. Теку
~/data/nonces/ створюєш теж по SSH, і вона теж поза
~/www.Учитель знає тільки адресу твого ендпойнта. Цього достатньо,
щоб у твоєму events.jsonl з'явився рядок, якого ти не писав
Двісті. Приймач відповів «прийнято», бо заперечити йому нічим: він не знає, хто це був.
У журналі цей рядок не відрізняється від справжнього нічим. Той самий
source, той самий формат, та сама структура. Відрізнити його
ти не зможеш і завтра.
На уроці 3 ти клав у заголовок X-Auth-Token довгий випадковий
рядок із ~/.env. Це вже щось: випадковий бот його не вгадає.
Але подивись, що з цим токеном відбувається далі. Він їде мережею в кожному запиті — і щоразу той самий.
| Загроза | Токен | Підпис HMAC |
|---|---|---|
| Випадковий бот, який знає лише URL | відсіює | відсіює |
| Хтось підмінив одне поле в тілі дорогою | не помітить | 401 |
| Секрет потрапив у чужий журнал чи скріншот | повний доступ назавжди | у запиті секрету немає взагалі |
| Валідний запит надіслано вдруге | пройде | пройде — потрібні мітка часу і nonce |
access.log записує все, що прийшло. І є ще
скріншот, який ти скинув у чат, коли просив допомогти. Токен у всіх цих
місцях — робочий ключ. Підпис — просто 64 символи, придатні рівно
до одного запиту.Однокласник каже: «мій вебхук ходить по HTTPS, підслухати канал неможливо, тому токена цілком достатньо, підпис — зайва складність». Що ти йому відповіси?
Порахувати звичайний хеш може будь-хто. А порахувати його разом із твоїм секретом — тільки той, хто цей секрет знає
Уяви сургучеву печатку на конверті. Підробити відбиток без персня не вийде, а перевірити його може кожен, хто цей перстень бачив. Приблизно так працює й підпис запиту.
Почнімо з хеша. Це м'ясорубка в один бік: подаєш будь-який текст і отримуєш рівно 256 біт (у SHA-256). Зібрати текст назад із цих бітів неможливо.
Далі все тримається на двох його властивостях:
Ідея на вигляд логічна. Нехай відправник рахує sha256(тіло)
і кладе результат у заголовок. А сервер перевіряє збіг.
Спробуй сам знайти, що з нею не так, перш ніж читати врізку.
1cb43615ff24bfaa54226e428ccdd1736726c982f3598b405616d382c54e7896,
і це число може отримати кожен, у кого є те саме тіло. Перевір:
printf '%s' '<тіло>' | sha256sum.Ось тут і з'являється HMAC. У м'ясорубку разом із повідомленням заходить секрет, який знають лише дві сторони. Без нього число не порахувати.
Розшифровується назва так: MAC — Message Authentication Code, код автентичності повідомлення. Літера H попереду означає «побудований на хеші». Ось означення з RFC 2104:
HMAC(K, m) = H( (K ⊕ opad) ‖ H( (K ⊕ ipad) ‖ m ) ) H — хеш-функція, у нас SHA-256 K — секрет, доповнений нулями до розміру блоку (для SHA-256 це 64 байти) m — повідомлення, яке підписуємо ipad — байт 0x36, повторений 64 рази opad — байт 0x5C, повторений 64 рази ‖ — просте склеювання байтів ⊕ — побітове «виключне або»
Секрет тут підмішується двічі, і щоразу по-різному. Це не примха авторів.
Проста схема sha256(секрет ‖ тіло) у хешів сімейства SHA-2
має відому ваду — атаку подовженням повідомлення. Зловмисник
не знає секрету, але дописує байти в кінець тіла й рахує правильний підпис
для нового, довшого тіла.
Подвійна конструкція HMAC це закриває. Тому власну схему вигадувати
не треба — треба взяти готову hash_hmac().
d5d5027a… на схемі справжні: це
HMAC-SHA256 від рядка 1788338467.a1b2c3d4e5f60718.{"source":…}
з тим секретом, який ти побачиш у лабораторії наступного кроку.
Перерахувати їх можна самому — там же, на цій сторінці.Однокласник зробив так: кладе в заголовок X-Digest
значення sha256(тіло), а сервер перевіряє збіг.
Каже: «підміну тіла спіймаю — хеш не зійдеться». Що він не врахував?
Підпис нижче обчислюється по-справжньому, у твоєму браузері,
через crypto.subtle з алгоритмом HMAC-SHA256. Це те саме число,
яке видасть hash_hmac у PHP
Сервер у лабораторії — не справжній: це модель твого
hook.php тут-таки на сторінці, з секретом
2f8b1c…2a8f, вікном 300 секунд і сховищем nonce
в пам'яті вкладки. А от підпис рахується по-справжньому, тим самим
алгоритмом, що й на сервері: скопіюй рядок і перевір
echo -n '<рядок>' | openssl dgst -sha256 -hmac '<секрет>'.
: замість ., тіло після
json_decode і повторного json_encode — і підпис
ніколи не зійдеться, хоч секрет правильний. Тому в
security.md рядок для підпису описують посимвольно.Твій вебхук раптом почав відповідати 401 на всі
запити від сервісу, хоч секрет ти не міняв. У лабораторії підпис
рахується правильно. Куди дивитись першим ділом?
== тут вразливістьПідпис ти вже вмієш рахувати. Лишилося порівняти два рядки — і саме на цьому рядку коду ламаються дорослі проєкти
Порівняння рядків у будь-якій мові працює очевидно. Воно йде посимвольно й виходить на першій розбіжності. Швидко й правильно.
І саме тому небезпечно. Час відповіді сервера залежить від того, скільки перших символів підпису зловмисник угадав. Що більше вгадав, то довше відповідь.
hash_equals варте того, щоб стати
автоматизмом. Воно коштує нуль зусиль. А той самий код завтра поїде
туди, де такий канал уже має значення. Це вимога критерію на будь-якому
ревʼю коду.// ✗ ВРАЗЛИВО: вихід на першій розбіжності
if ($sig !== $expected) { http_response_code(401); exit('bad signature'); }
// ✓ ПРАВИЛЬНО: сталий час, перший аргумент — відомий (наш) підпис
if (!hash_equals($expected, $sig)) { http_response_code(401); exit('bad signature'); }
hash_equals() є в PHP із версії 5.6 і не потребує розширень.
Він спершу порівнює довжини. Потім проходить усі байти через
XOR і накопичує різницю.
Тому він працює однаково довго, хоч рядки розійшлися на першому символі, хоч на останньому. Виміряти час і щось із нього дізнатися вже не вийде.
Порядок аргументів у документації заданий прямо. Перший — відомий рядок, той, що порахував ти. Другий — той, що прийшов ззовні.
У чужому коді ти бачиш такий рядок:
if (substr($sig, 0, 8) === substr($expected, 0, 8)).
Автор пояснює: «порівнюю перші 8 символів, цього досить, зате швидко».
Що тут не так?
Найнеприємніша атака цього уроку тим, що в ній усе валідне. Підпис справжній, секрет не витік, тіло не змінене
Уяви квиток у кіно зі справжнім штрихкодом. Підробити його не вийшло б. А от відсканувати чужий і показати той самий код ще двадцять разів — цілком.
Із запитами буває так само. Твій вебхук приймає подію «зарахувати оплату 200 грн». Зловмисник не знає секрету й підробити підпис не може.
Зате він перехопив один валідний запит цілком: тіло і всі заголовки. І тепер надсилає його ще раз. І ще. І ще двадцять разів. Це й зветься replay-атакою — повтором.
500 уже після запису в журнал —
відправник вважає, що не дійшло, і надсилає ще раз;report.sh двічі й отримував той самий звіт.
Це і є ідемпотентність: повтор нічого не змінює. Різниця в тому,
хто повторює. На уроці 9 це був ти, тут — чужий сервіс або зловмисник.
А nonce робить ідемпотентним навіть той обробник, що всередині
просто дописує рядок у файл.Твій вебхук отримав той самий підписаний запит двічі. У журналі два однакові записи, підпис в обох правильний, секрет не витікав, мітка часу свіжа. Чого ти не передбачив?
Відправник кладе в заголовок X-Timestamp час,
коли він підписав запит. Сервер дивиться, наскільки цей час відстав
від його власного
$age = abs(time() - (int)$ts); // модуль: майбутнє теж підозріле
if ($age > 300) { http_response_code(401); exit('stale timestamp'); }
Модуль тут не для краси. Без нього пройде перевірку запит із міткою часу на добу вперед.
А далі він лежатиме в зловмисника як «вічний квиток». Годиться цілу добу й проходить щоразу.
hook.php.X-Timestamp. Повтор
проходить, бо підпис від тіла не змінився. Саме тому наш рядок для підпису —
ts . '.' . nonce . '.' . тіло: змінив мітку часу — зламав підпис.| Система | Перевірити зсув |
|---|---|
| Linux / VPS | timedatectl status — дивись рядки System clock synchronized і NTP service |
| Windows | w32tm /query /status, синхронізувати — w32tm /resync |
| macOS | sntp -sS time.apple.com або перемикач у «Дата й час» |
| будь-де | порівняй свій час із заголовком Date у відповіді сервера: curl -sI https://example.com | grep -i ^date |
Однокласник поставив вікно 2 секунди: «чим вужче, тим
безпечніше». Наступного дня сервіс скаржиться, що половина його
доставок падає в 401, хоч підписи правильні. Що сталося?
Ти рахуєш підпис так: hash_hmac('sha256', $raw, $secret) —
тільки від тіла. Мітку часу перевіряєш окремо, вікно 300 с.
Зловмисник перехопив твій запит учора. Чи зупинить його вікно?
На вході в клуб браслет надягають один раз і вдруге не приймають. Тут те саме, тільки браслет — це число в заголовку
Відправник кладе в кожен запит унікальний ідентифікатор. Це
16 випадкових шістнадцяткових символів, наприклад
a1b2c3d4e5f60718.
Сервер перевіряє підпис, а потім дивиться: чи бачив він це число раніше?
Якщо бачив, відповідає 409 Conflict і далі не робить нічого.
Таке одноразове число зветься nonce — від англійського number used once, «число, використане один раз».
| Питання | Відповідь для нашого hook.php |
|---|---|
| Хто генерує nonce | відправник, для кожного запиту наново: openssl rand -hex 8 |
| Чи входить у підпис | так — інакше його можна було б підмінити й обійти перевірку |
| Де зберігається | ~/data/nonces/ — порожній файл з іменем nonce, поза ~/www |
| Скільки зберігати | довше за вікно; беремо 900 с = 3 × 300 с |
| Чому не вічно | тека росла б без меж; старіші за вікно nonce вже й так відсіє перевірка часу |
| Що при повторі | 409, у журнал подій нічого не додається |
nonce маленьким,
а сховище nonce закриває ту дірку, яку вікно лишає
всередині себе. Поодинці не працює ні те, ні те.| Спосіб | Плюс | Мінус |
|---|---|---|
| файл-мітка в теці | атомарно через fopen(..., 'x'), нуль залежностей, переживає перезапуск | тека з тисячами дрібних файлів; треба прибирати старі |
| SQLite | один файл, індекс, зручне DELETE WHERE ts < | потрібне розширення pdo_sqlite; блокування при паралельних записах |
| масив у пам'яті процесу | найпростіше | не працює зовсім: кожен запит PHP — новий процес, пам'ять порожня. Після перезапуску replay проходить знову |
nonce у змінній, у $_SESSION або
в static-масиві. Виглядає працездатно на одному запиті
з браузера, а на другому вже ні: PHP на кожен HTTP-запит стартує заново.
Сховище має бути на диску — і воно має пережити перезавантаження сервера.Однокласник додав nonce, перевірив у себе — повтор дає 409,
усе працює. Через тиждень хостер перезавантажив сервер, і чекер
знову провів replay успішно. Де він зберігав nonce?
Надішли валідний запит, потім натисни «повторити той самий» і дивись, як три різні обробники поводяться з тими самими байтами
HMAC перевіряється, більше нічого
записів у журналівікно 300 секунд
записів у журналіте, що ти зробиш сьогодні
записів у журналіСимулятор моделює три обробники й свій годинник. Підпис у ньому не рахується — це зроблено в лабораторії кроку 4; тут важлива сама логіка рішень і коди відповіді.
409. Ось воно: підпис і мітка часу від повтору не рятують;401 stale, A все ще пише запис;У симуляторі обробник B після «+6 хв» починає відхиляти повтор. Однокласник робить висновок: «отже, мітки часу достатньо, nonce — зайве ускладнення». Чим ти йому заперечиш?
Перевірки не переставляються місцями довільно. Порядок — це теж частина захисту, і він має власне пояснення на кожному кроці
400
і 401 повторювати марно, на 500 — має сенс.| Код | Коли | Що робить нормальний відправник |
|---|---|---|
| 200 | усе гаразд, подія записана | вважає доставку успішною |
| 400 | підпис правильний, а тіло — не JSON | не повторює: помилка в нього самого |
| 401 | немає підпису, підпис не сходиться або мітка часу поза вікном | не повторює: розбіжність секретів або схеми |
| 405 | не POST | не повторює |
| 409 | цей nonce уже був — це повтор | вважає, що подія вже дійшла, і заспокоюється |
| 413 | тіло більше за 8 КБ | не повторює |
| 500 | зламався ти: немає секрету, не пишеться файл | повторює — тому 500 не можна віддавати на чужі помилки |
error_handling надсилає тіло {{{ і чекає
400. Це працює тільки тому, що чекер правильно підписує
це бите тіло. Підпис рахується від сирих байтів, і {{{
підписується так само, як валідний JSON. Якщо ті самі {{{
надіслати без підпису, ти маєш віддати 401, а не
400 — бо до розбору тіла справа не доходить. Обидві поведінки
правильні; головне — розуміти, чому вони різні.~/data/hook-errors.log. Але ніколи не пиши туди
ні секрет, ні повний підпис, що приїхав, — інакше твій журнал стає
підказкою. Перших 8 символів підпису для розслідування досить.Однокласник переставив перевірки: спершу json_decode
і запис у журнал, потім перевірка підпису. Каже: «різниці немає,
все одно на невалідному підписі я віддаю 401». У чому він помиляється?
api/hook.php — версія з підписомЦе той самий файл із уроку 11, у який дописано кроки 0—5 конвеєра. Скопіюй, розберись у кожному рядку, залий по FTP
<?php
declare(strict_types=1);
const WINDOW = 300; // вікно допустимого віку запиту, секунд
const NONCE_TTL = 900; // скільки тримаємо nonce: утричі більше за вікно
const MAX_BODY = 8192; // 8 КБ — більше подія важити не має
function fail(int $code, string $msg): void {
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
exit($msg);
}
// ── 0. Секрет: тільки з ~/.env, поза веб-корінням ────────────────
$home = dirname(__DIR__, 2); // ~/www/api -> ~/www -> ~
$env = @parse_ini_file($home . '/.env');
$secret = (string)($env['HOOK_SECRET'] ?? '');
if ($secret === '') {
fail(500, 'server misconfigured'); // 500 — бо зламався САМ сервер
}
// ── 1. Метод і розмір ─────────────────────────────────────────────
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
fail(405, 'method not allowed');
}
$raw = file_get_contents('php://input');
if (strlen($raw) > MAX_BODY) {
fail(413, 'body too large');
}
// ── 2. Заголовки на місці й правдоподібні ────────────────────────
$ts = (string)($_SERVER['HTTP_X_TIMESTAMP'] ?? '');
$nonce = (string)($_SERVER['HTTP_X_NONCE'] ?? '');
$sig = (string)($_SERVER['HTTP_X_SIGNATURE'] ?? '');
if ($ts === '' || $nonce === '' || $sig === '') {
fail(401, 'unsigned');
}
// nonce стане ІМЕНЕМ ФАЙЛУ — пускаємо лише hex, інакше буде ../../ у шляху
if (!ctype_digit($ts) || !preg_match('/^[a-f0-9]{16,64}$/i', $nonce)
|| !preg_match('/^[a-f0-9]{64}$/i', $sig)) {
fail(401, 'bad headers');
}
// ── 3. Вік запиту: модуль, щоб «майбутнє» теж не проходило ───────
if (abs(time() - (int)$ts) > WINDOW) {
fail(401, 'stale timestamp');
}
// ── 4. Підпис: від СИРИХ байтів, до будь-якого розбору ───────────
$expected = hash_hmac('sha256', $ts . '.' . $nonce . '.' . $raw, $secret);
if (!hash_equals($expected, strtolower($sig))) { // відомий рядок — ПЕРШИЙ
fail(401, 'bad signature');
}
// ── 5. Nonce: тільки ПІСЛЯ перевірки підпису ─────────────────────
$dir = $home . '/data/nonces';
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
fail(500, 'nonce store unavailable');
}
foreach (glob($dir . '/*') ?: [] as $old) { // прибираємо протухлі
if (filemtime($old) < time() - NONCE_TTL) { @unlink($old); }
}
$fh = @fopen($dir . '/' . strtolower($nonce), 'x'); // 'x' — атомарно
if ($fh === false) {
fail(409, 'replay'); // файл уже існував
}
fclose($fh);
// ── 6. Тепер — і тільки тепер — розбираємо тіло ──────────────────
$data = json_decode($raw, true);
if (!is_array($data)) {
fail(400, 'bad json');
}
$event = [
'ts' => gmdate('c'),
'source' => substr((string)($data['source'] ?? 'unknown'), 0, 40),
'type' => substr((string)($data['type'] ?? 'event'), 0, 40),
'title' => substr((string)($data['title'] ?? ''), 0, 200),
];
// ── 7. Записуємо ПОЗА веб-корінням і відповідаємо ────────────────
$line = json_encode($event, JSON_UNESCAPED_UNICODE) . "\n";
if (file_put_contents($home . '/data/events.jsonl', $line, FILE_APPEND | LOCK_EX) === false) {
fail(500, 'cannot write journal');
}
http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['ok' => true]);
file_exists() плюс touch() такої гарантії не дає — між ними встигає прослизнути другий процес.A1B2 і a1b2 не стали двома різними файлами й не дали лазівку для повтору. Те саме зроблено з підписом перед hash_equals.../../www/index.php дозволило б створювати файли де завгодно. Правило уроку 4 діє й тут: усе, що прийшло ззовні, — недовірене.~/.env як КЛЮЧ=значення. Секрет роби з літер і цифр без лапок, # і пробілів — інакше цей парсер зрозуміє рядок не так, як ти задумав.500 тут лише там, де винен сервер: немає секрету, не пишеться файл. На чужу помилку 500 віддавати не можна — відправник почне повторювати доставку.nonce записується до розбору JSON. Отже, якщо відправник
надіслав битий JSON, він отримає 400 — а його nonce
вже витрачений. Повторити той самий запит із виправленим тілом не вийде:
треба брати новий nonce. Це нормально й навіть правильно:
«той самий nonce = той самий запит», а виправлене тіло — це вже інший запит.Учень пише в обробнику:
$body = json_encode(json_decode($raw, true)); —
«щоб нормалізувати JSON», і рахує підпис від $body.
Секрет правильний, годинники синхронні. Що він побачить?
Перевіряти вміє тільки той, хто вміє підписувати.
Спершу — секрет, який ніколи не потрапляє у ~/www
~/.env2f8b1c…2a8f надрукований у лабораторії, у схемах
і в цьому терміналі. Він відомий усьому класу. Свій секрет згенеруй сам командою вище
й нікому не показуй. Зокрема не вставляй його в security.md,
у скріншот і в чат із проханням допомогти.curl плюс дві командиНайпростіший чесний спосіб перевірити свій обробник — підписати запит
прямо в оболонці. Тіло, мітка часу й nonce склеюються в один
рядок. Від нього й рахується HMAC.
SECRET='твій-секрет-із-.env'
BODY='{"source":"test","type":"manual","title":"підписана подія"}'
TS=$(date +%s)
NONCE=$(openssl rand -hex 8)
SIG=$(printf '%s' "$TS.$NONCE.$BODY" \
| openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
http://91.219.61.4/s/твій-логін/api/hook.php \
-H 'Content-Type: application/json' \
-H "X-Timestamp: $TS" \
-H "X-Nonce: $NONCE" \
-H "X-Signature: $SIG" \
--data-raw "$BODY"
curl там — псевдонім Invoke-WebRequest,
тому пиши curl.exe.
2. && між командами не працює — став
; або if ($?) { … }.
3. Головне: echo й > у PowerShell
дописують BOM і переводять рядок — а підпис рахується від
точних байтів. Тому в PowerShell рахуй підпис не через файли,
а класом HMACSHA256, як у блоці нижче.$secret = 'твій-секрет-із-.env'
$body = '{"source":"test","type":"manual","title":"підписана подія"}'
$ts = [int][Math]::Floor(((Get-Date).ToUniversalTime() - [datetime]'1970-01-01').TotalSeconds)
$nonce = -join ((1..16) | ForEach-Object { '{0:x}' -f (Get-Random -Maximum 16) })
$h = New-Object System.Security.Cryptography.HMACSHA256
$h.Key = [Text.Encoding]::UTF8.GetBytes($secret)
$msg = [Text.Encoding]::UTF8.GetBytes("$ts.$nonce.$body")
$sig = ($h.ComputeHash($msg) | ForEach-Object { $_.ToString('x2') }) -join ''
curl.exe -s -o NUL -w "%{http_code}" -X POST `
http://91.219.61.4/s/твій-логін/api/hook.php `
-H 'Content-Type: application/json' `
-H "X-Timestamp: $ts" -H "X-Nonce: $nonce" -H "X-Signature: $sig" `
--data-raw $body
curlТут той самий план Б, що й на уроці 11. Безкоштовні тарифи Zapier та IFTTT не дають вебхука на власний сервер. Тому події надсилає Apps Script, прив'язаний до форми. Тепер він ще й підписує їх.
function onFormSubmit(e) {
const SECRET = PropertiesService.getScriptProperties().getProperty('HOOK_SECRET');
const body = JSON.stringify({
source: 'apps-script',
type: 'form',
title: String(e.namedValues['Назва заявки'] || '').slice(0, 200)
});
const ts = Math.floor(Date.now() / 1000);
const nonce = Utilities.getUuid().replace(/-/g, '').slice(0, 16);
const sigBytes = Utilities.computeHmacSha256Signature(
ts + '.' + nonce + '.' + body, SECRET);
const sig = sigBytes
.map(b => ((b & 0xFF) + 0x100).toString(16).slice(1)).join('');
UrlFetchApp.fetch('http://91.219.61.4/s/твій-логін/api/hook.php', {
method: 'post',
contentType: 'application/json',
payload: body, // ті самі байти, що підписані
headers: { 'X-Timestamp': String(ts), 'X-Nonce': nonce, 'X-Signature': sig },
muteHttpExceptions: true
});
}
api/security.md: щоб чужа команда підключилася без тебеДокумент пишеться не для вчителя. Він пишеться для людини, яка завтра захоче надіслати тобі подію, а тебе поруч не буде
Перевірити якість цього файлу просто. Дай його однокласникові й подивись, чи збере він правильний підпис із першої спроби.
Якщо не зібрав — у документі бракує рядка. Найчастіше про те, у якому порядку склеювати частини, або про кодування.
# Підпис запитів до /api/hook.php
## Заголовки
| Заголовок | Формат |
|---------------|---------------------------------|
| X-Timestamp | секунди Unix, ціле число |
| X-Nonce | 16 шістнадцяткових символів |
| X-Signature | 64 шістнадцяткові символи, малі |
## Рядок для підпису
X-Timestamp + "." + X-Nonce + "." + тіло-запиту
Тіло береться в тому вигляді, у якому воно йде в мережу — байт у байт,
у UTF-8, без BOM і без завершального переводу рядка.
## Алгоритм
X-Signature = HMAC-SHA256(рядок-для-підпису, HOOK_SECRET) у hex
Секрет видається окремим каналом і в цьому файлі не наводиться.
## Правила приймання
| Умова | Відповідь |
|-----------------------------------------|-----------|
| усе гаразд | 200 |
| не POST | 405 |
| тіло більше за 8192 байти | 413 |
| немає одного з трьох заголовків | 401 |
| вік мітки часу більший за 300 с | 401 |
| підпис не збігається | 401 |
| X-Nonce уже використовували | 409 |
| підпис правильний, тіло не JSON | 400 |
Nonce зберігається 900 секунд. Повторна доставка з тим самим nonce
завжди дає 409 і НЕ створює другого запису.
## Приклад коректного запиту
X-Timestamp: 1788338467
X-Nonce: a1b2c3d4e5f60718
X-Signature: d5d5027a9a872bc190677dcf95a9b4f1a672861c4b39ad568e47b65cf5b5bd6f
тіло: {"source":"apps-script","type":"form","title":"Заявка на гурток робототехніки"}
секрет: ***
## Приклад некоректного запиту
Те саме, але в тілі "…робототехніки!" — один доданий символ.
Очікувана відповідь: 401 bad signature.
~/www/api/ і віддається по HTTP усьому світу
за адресою /api/security.md. Секрет, приклад із реальним
секретом, «тимчасовий» секрет для тестів, вміст ~/.env —
усе це там дорівнює публікації. На крос-аудиті знайдений секрет
у security.md — блокуючий провал.Однокласник написав чудовий security.md, а щоб приклад
був «повністю відтворюваним», додав туди справжній секрет —
«однаково ж це навчальний сервер». Що станеться і що робити?
Кроки 1—4 і 12—14 — по SSH на сервері. Кроки 5—11 і 15 —
у себе на комп'ютері. По FTP їдуть два файли:
api/hook.php з кроку 12 і api/security.md
з кроку 16. Галочки зберігаються
~/www ніколи
Сам секрет HOOK_SECRET; файл ~/.env;
тека ~/data/nonces/ разом із журналом
~/data/events.jsonl. Усе це живе вище веб-кореня.
Якщо будь-що з цього віддається по HTTP, роботу поки не приймуть.
Решта зробленого цього не переважить. Прибери файл із ~/www
й натисни перевірку ще раз — спроби не обмежені.hook.php і security.md:
чи не лишився там секрет «для налагодження»? Найшвидша перевірка вже
на сервері — grep -r "$(grep HOOK_SECRET ~/.env | cut -d= -f2)" ~/www.
Команда має не вивести нічого.Чекер знає твій секрет (ти сам поклав його в ~/.env,
а чекер читає його на сервері від твого імені) і надсилає п'ять запитів:
один валідний і чотири зіпсовані
Демонстраційний режим: результат згенеровано для показу.
На сервері ця кнопка викликає POST /api/check, який запускає
/opt/lessons/12/check.sh від імені учня.
| Критерій | Що надсилає чекер | Чого чекає |
|---|---|---|
| error_handling | правильно підписане тіло {{{ | 400, і наступний запит теж обслуговується |
| sig_bad | валідний запит, у якому змінено один байт тіла | 401, у журналі нічого не додано |
| sig_missing | те саме тіло без заголовка X-Signature | 401 |
| replay | валідний запит, а потім він же ще раз, байт у байт | перший 200, другий 409 або 401; у журналі один рядок |
| stale | правильно підписаний запит із X-Timestamp на 10 хвилин у минуле | 401 |
replay
Обробник відповідає 409, але рядок у журнал уже дописаний —
бо запис зроблено до перевірки nonce. Чекер дивиться
не тільки на код відповіді, а й на кількість рядків
у events.jsonl до і після.X-Timestamp і X-Signature
у своєму відправнику — саме крок обчислення підпису, а не
готовий результат;nonce після двох запитів:
ls -l ~/data/nonces/ — скільки там файлів і чому саме стільки;~/.env — має бути 600;security.md, і просить назвати рядок для підпису
напам'ять, посимвольно;nonce стоїть після
перевірки підпису, а не перед нею.Це те, що бачить учитель, коли виставляє оцінку.
| Складник | Вага | Результат |
|---|
hash_equals, а не через
===: час відповіді не має нічого підказувати;nonce, і зберігати його
треба на диску, бо PHP на кожен запит стартує заново;json_decode і взагалі до будь-якого розбору.nonce зберігається в пам'яті процесу або в
$_SESSION: після перезапуску сервера replay проходить знову;nonce стоїть перед перевіркою підпису:
будь-хто заповнює тобі сховище мільйоном чужих nonce;events.jsonl дописується до перевірки
nonce: код відповіді правильний, а другий запис усе одно є;security.md або
scenario.md усередині ~/www —
блокуючий провал на крос-аудиті;hash_equals викликано з переставленими аргументами
або замінено на == «бо так коротше»;500 віддається на чужі помилки — відправник вважає
це тимчасовим збоєм і повторює доставку по колу.Додай у ~/www/api/security.md два приклади запиту:
коректний і зіпсований. У кожному — усі три заголовки з конкретними
значеннями, тіло і код відповіді, який має прийти. Секрет у прикладі
заміни на ***.
Зіпсований приклад зроби так, щоб він відрізнявся від коректного рівно одним символом у тілі. Постав поруч обидва підписи — буде видно, наскільки вони різні.
Другим абзацом опиши, як ти замінятимеш секрет, якщо він завтра витече. У якому порядку, щоб доставки не зупинилися. І що робити з подіями, які прийдуть якраз у момент заміни.
Підписи на цій сторінці справжні: будь-який із них
перераховується в лабораторії кроку 4 або командою
openssl dgst -sha256 -hmac
H((K⊕opad) ‖ H((K⊕ipad) ‖ m)),
значення ipad = 0x36, opad =
0x5C і розмір блоку 64 байти.
rfc-editor.org/rfc/rfc2104Stripe-Signature з полями
t= і v1=, рядок для підпису
timestamp + "." + payload, допуск за замовчуванням
300 секунд і пряма вимога порівнювати підписи в сталий час.
docs.stripe.com · webhooksX-Slack-Request-Timestamp і X-Slack-Signature,
вікно 5 хвилин із прямою згадкою replay-атаки як причини.
api.slack.com · verifying requestsX-Hub-Signature-256, HMAC-SHA256 від сирого тіла
й вимога користуватися порівнянням у сталий час.
docs.github.com · validating webhookshash_hmac() — повертає hex-рядок у нижньому
регістрі; для sha256 це 64 символи.
php.net · hash_hmachash_equals() — порівняння в сталий час,
доступне з PHP 5.6. У документації прямо задано порядок аргументів:
перший — відомий рядок, другий — той, що прийшов від користувача.
php.net · hash_equalsfopen() — режим 'x'
створює файл і повертає false, якщо файл уже існує;
операція виконується атомарно. Саме на цьому стоїть перевірка
nonce.
php.net · fopensha256(секрет ‖ повідомлення) не є MAC
для хешів конструкції Меркла — Дамґарда, до яких належить SHA-2.
wikipedia · length extension attackSubtleCrypto.sign() — те, чим
лабораторія кроку 4 рахує підпис у браузері:
importKey з {name:'HMAC', hash:'SHA-256'}
і далі sign('HMAC', …).
developer.mozilla.org · SubtleCrypto.signUtilities.computeHmacSha256Signature —
повертає масив знакових байтів, звідси арифметика
(b & 0xFF) + 0x100 у прикладі кроку 12.
developers.google.com · Utilities400,
401, 405, 409 Conflict,
413 Content Too Large.
rfc-editor.org/rfc/rfc9110Повний список із поясненнями, що звідки взято, які числа
перевіряються, а які є ілюстративними, — у файлі
urok-12-джерела.md поруч із цією сторінкою.