До списку уроків
10 клас · Урок 12 з 16

Підпис вебхука HMAC, мітка часу і захист від replay

виконано0%
Крок 1

Твій приймач приймає подію від будь-кого

Минулого уроку ти навмисно лишив у ньому дірку. Сьогодні ти її закриваєш — і закриваєш так, як це зроблено у Stripe, Slack і GitHub

Чого навчишся за цей урок

Хід уроку

ЕтапХвЩо робимо
Атака-демо10Учитель кладе чужий запис у твій журнал, знаючи лише URL
Теорія: HMAC20Два однакові «млинки» хешування — рахуємо підпис вручну, міняємо 1 символ
Перевірка підпису30hash_hmac і порівняння за постійний час — X-Signature, відповідь 401 при розбіжності
Теорія: replay-атака15Перехоплений валідний запит надіслано вдруге — шукаємо захист
Timestamp і nonce25Вікно 300 с і сховище використаних nonce — тестуємо 409/401 на повторі
Стійкість до помилок10Тіло {{{ і порожній POST — доводимо 400 без падіння процесу
Автоперевірка5Усі 🔒-пункти теми 6 — доводимо до зеленого
Підсумок5Правило «URL — не секрет; секрет — це підпис»
Разом120

Слова HMAC, replay і nonce поки нічого не означають. Розберемо їх по черзі, кожне на своєму місці.

Урок 11 закінчився файлом api/hook.php. Він дописує рядок у ~/data/events.jsonl кожному, хто надішле POST. Будь-кому.

Урок 3 додавав до схожого приймача токен у заголовку. Це вже краще, але сьогодні ти зробиш крок далі. Замість «покажи пароль» буде підпис самого тіла запиту.

Було: спільний токен

один рядок, однаковий у кожному запиті

  • хто побачив його один раз — надішле що завгодно;
  • він нічого не каже про вміст запиту;
  • валідний запит можна перехопити й повторити;
  • він осідає в логах проксі, CDN і сервісу-відправника.
Стане: підпис + мітка часу + nonce

три заголовки, які працюють тільки разом

  • секрет мережею не їде — їде підпис;
  • змінився хоч один байт тіла — підпис не сходиться;
  • запит, старший за 5 хвилин, не приймається;
  • той самий запит удруге не проходить ніколи.

Що ти зробиш за 120 хвилин

  1. подивишся, як учитель кладе чужий запис у твій журнал, знаючи лише URL;
  2. розберешся, чим HMAC відрізняється від звичайного хеша;
  3. порахуєш підпис у лабораторії просто на цій сторінці й побачиш, що робить один змінений символ;
  4. додаси в hook.php перевірку X-Signature через hash_hmac і hash_equals;
  5. додаси вікно 300 секунд для X-Timestamp;
  6. заведеш сховище nonce у ~/data/nonces/ і закриєш повтор остаточно;
  7. опишеш свою схему в api/security.md — так, щоб чужа команда змогла надіслати тобі запит, не питаючи тебе.
Межа «локально / на сервер» та сама, що й завжди Код ти пишеш у себе на комп'ютері. По FTP у кабінет їдуть два текстові файли: api/hook.php і api/security.md. Секрет HOOK_SECRET ти дописуєш у ~/.env по SSH — не по FTP і не в жодному файлі всередині ~/www. Теку ~/data/nonces/ створюєш теж по SSH, і вона теж поза ~/www.
Одне речення, заради якого весь урок URL — не секрет. Токен — теж майже не секрет. Секрет — це те, чим ти підписуєш. Він не залишає двох машин: твого сервера й того, хто має право до нього стукати.
Крок 2

Атака-демо: чужий запис у твоєму журналі

Учитель знає тільки адресу твого ендпойнта. Цього достатньо, щоб у твоєму events.jsonl з'явився рядок, якого ти не писав

учительський ноутбук — нічого, крім твоєї адреси
$ 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' -d '{"source":"apps-script","type":"form","title":"Заявка на гурток робототехніки"}' 200

Двісті. Приймач відповів «прийнято», бо заперечити йому нічим: він не знає, хто це був.

У журналі цей рядок не відрізняється від справжнього нічим. Той самий source, той самий формат, та сама структура. Відрізнити його ти не зможеш і завтра.

«Ну я ж додам токен» — і чому цього мало

На уроці 3 ти клав у заголовок X-Auth-Token довгий випадковий рядок із ~/.env. Це вже щось: випадковий бот його не вгадає.

Але подивись, що з цим токеном відбувається далі. Він їде мережею в кожному запиті — і щоразу той самий.

Чим спільний токен відрізняється від підпису тіла Угорі: у трьох запитах поспіль їде той самий рядок токена, тому перехоплений один раз токен дозволяє надіслати будь-яке інше тіло. Унизу: підпис у кожному запиті інший, бо він рахується від тіла, і перехоплений підпис не підходить до іншого тіла. Спільний токен: однаковий рядок у кожному запиті запит 1 · тіло «Заявка А» X-Auth-Token: 7f3a…c19 запит 2 · тіло «Заявка Б» X-Auth-Token: 7f3a…c19 запит 3 · тіло «Заявка В» X-Auth-Token: 7f3a…c19 Побачив один запит — маєш ключ від усіх наступних: POST /api/hook.php + той самий токен + БУДЬ-ЯКЕ тіло → 200 Підпис тіла: у кожному запиті інший запит 1 · тіло «Заявка А» X-Signature: d5d5027a… запит 2 · тіло «Заявка Б» X-Signature: dab061f8… запит 3 · тіло «Заявка В» X-Signature: 8ae30f12… Побачив один запит — маєш підпис рівно до ОДНОГО тіла: інше тіло + той самий підпис → 401, бо секрету в тебе немає Але той самий запит цілком, байт у байт, поки що пройде. Це replay — про нього крок 7.
Токен доводить «я знаю пароль». Підпис доводить «я знаю пароль і ось цей конкретний текст я справді надсилав». Уся різниця — в другій половині речення.
ЗагрозаТокенПідпис HMAC
Випадковий бот, який знає лише URLвідсіюєвідсіює
Хтось підмінив одне поле в тілі дорогоюне помітить401
Секрет потрапив у чужий журнал чи скріншотповний доступ назавждиу запиті секрету немає взагалі
Валідний запит надіслано вдругепройдепройде — потрібні мітка часу і nonce
Де запити «протікають», навіть коли скрізь HTTPS HTTPS захищає канал між двома точками. Але заголовки після цього каналу читає ще купа місць. Сам сервіс-відправник і його служба підтримки бачать усе, що надсилають від твого імені. Проксі або CDN перед твоїм сервером розшифровує запит, щоб його переслати. Панель «Task history» у Zapier зберігає тіло й заголовки кожного запуску — щоб ти міг їх переглянути. Твій власний access.log записує все, що прийшло. І є ще скріншот, який ти скинув у чат, коли просив допомогти. Токен у всіх цих місцях — робочий ключ. Підпис — просто 64 символи, придатні рівно до одного запиту.
Питання

Однокласник каже: «мій вебхук ходить по HTTPS, підслухати канал неможливо, тому токена цілком достатньо, підпис — зайва складність». Що ти йому відповіси?

Крок 3

HMAC: хеш, який уміє порахувати тільки той, хто знає секрет

Порахувати звичайний хеш може будь-хто. А порахувати його разом із твоїм секретом — тільки той, хто цей секрет знає

Уяви сургучеву печатку на конверті. Підробити відбиток без персня не вийде, а перевірити його може кожен, хто цей перстень бачив. Приблизно так працює й підпис запиту.

Почнімо з хеша. Це м'ясорубка в один бік: подаєш будь-який текст і отримуєш рівно 256 біт (у SHA-256). Зібрати текст назад із цих бітів неможливо.

Далі все тримається на двох його властивостях:

детермінованість
той самий вхід завжди дає той самий вихід. Інакше перевірити підпис було б неможливо в принципі.
лавинний ефект
змінив один біт входу — приблизно половина бітів виходу перевернулася. Не «схожий хеш», а зовсім інше число.

Чому не просто хеш від тіла

Ідея на вигляд логічна. Нехай відправник рахує sha256(тіло) і кладе результат у заголовок. А сервер перевіряє збіг.

Спробуй сам знайти, що з нею не так, перш ніж читати врізку.

Хеш від тіла порахує будь-хто SHA-256 — публічний алгоритм без жодного секрету. Той, хто підміняє тіло, просто рахує хеш нового тіла й кладе його в заголовок. Така перевірка ловить помилку передавання, а не зловмисника. Для тіла з демо кроку 2 хеш дорівнює 1cb43615ff24bfaa54226e428ccdd1736726c982f3598b405616d382c54e7896, і це число може отримати кожен, у кого є те саме тіло. Перевір: printf '%s' '<тіло>' | sha256sum.

Ось тут і з'являється HMAC. У м'ясорубку разом із повідомленням заходить секрет, який знають лише дві сторони. Без нього число не порахувати.

Розшифровується назва так: MAC — Message Authentication Code, код автентичності повідомлення. Літера H попереду означає «побудований на хеші». Ось означення з RFC 2104:

HMAC — означення з 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().

Як рахується HMAC і чому обидві сторони отримують те саме число Ліворуч відправник: бере мітку часу, nonce і тіло, склеює їх у рядок для підпису, додає секрет і отримує 64 шістнадцяткові символи. Праворуч сервер: бере ті самі заголовки й ті самі байти тіла, додає свою копію секрету, рахує те саме число й порівнює. Секрет мережею не передається; передається лише підпис. Відправник (Apps Script, Zapier, curl) рядок для підпису — склеюємо три речі: 1788338467 . a1b2c3d4e5f60718 . {"source":"apps-script","type":"form",…} HOOK_SECRET копія секрету hash_hmac('sha256', рядок, секрет) d5d5027a9a872bc1…5cf5b5bd6f Твій сервер, api/hook.php складає рядок із ТИХ САМИХ байтів: $ts . '.' . $nonce . '.' . $raw $raw — сирі байти, до json_decode ~/.env, права 600 той самий секрет той самий hash_hmac d5d5027a9a872bc1…5cf5b5bd6f заголовки і тіло → секрет ніколи X-Signature Сервер НЕ «розшифровує» підпис: HMAC не шифрування. Сервер рахує свій підпис — і порівнює. Мітка часу й nonce входять у підписуваний рядок — інакше їх можна було б підмінити безкарно.
Числа d5d5027a… на схемі справжні: це HMAC-SHA256 від рядка 1788338467.a1b2c3d4e5f60718.{"source":…} з тим секретом, який ти побачиш у лабораторії наступного кроку. Перерахувати їх можна самому — там же, на цій сторінці.
Питання

Однокласник зробив так: кладе в заголовок X-Digest значення sha256(тіло), а сервер перевіряє збіг. Каже: «підміну тіла спіймаю — хеш не зійдеться». Що він не врахував?

Крок 4

Лабораторія підпису: рахуй сам і дивись, що ламається

Підпис нижче обчислюється по-справжньому, у твоєму браузері, через crypto.subtle з алгоритмом HMAC-SHA256. Це те саме число, яке видасть hash_hmac у PHP

Рахуємо HMAC-SHA256

збігається із секретом сервера
2026-09-02 08:41:07 UTC
16 шістнадцяткових символів
довжина: 0 байтів
Рядок, який іде в м'ясорубку
X-Signature — 64 шістнадцяткові символи
відрізняється від еталона: 0 символів із 64 рахував:
Надсилаємо на api/hook.php і дивимось конвеєр перевірок

    Сервер у лабораторії — не справжній: це модель твого hook.php тут-таки на сторінці, з секретом 2f8b1c…2a8f, вікном 300 секунд і сховищем nonce в пам'яті вкладки. А от підпис рахується по-справжньому, тим самим алгоритмом, що й на сервері: скопіюй рядок і перевір echo -n '<рядок>' | openssl dgst -sha256 -hmac '<секрет>'.

    Що ти щойно побачив

    Найчастіша помилка новачка — не в алгоритмі, а в рядку Дві сторони мусять склеювати абсолютно однаковий рядок. Зайвий пробіл, інша послідовність частин, : замість ., тіло після json_decode і повторного json_encode — і підпис ніколи не зійдеться, хоч секрет правильний. Тому в security.md рядок для підпису описують посимвольно.
    Питання

    Твій вебхук раптом почав відповідати 401 на всі запити від сервісу, хоч секрет ти не міняв. У лабораторії підпис рахується правильно. Куди дивитись першим ділом?

    Крок 5

    Порівняння в сталий час: чому == тут вразливість

    Підпис ти вже вмієш рахувати. Лишилося порівняти два рядки — і саме на цьому рядку коду ламаються дорослі проєкти

    Порівняння рядків у будь-якій мові працює очевидно. Воно йде посимвольно й виходить на першій розбіжності. Швидко й правильно.

    І саме тому небезпечно. Час відповіді сервера залежить від того, скільки перших символів підпису зловмисник угадав. Що більше вгадав, то довше відповідь.

    Чому звичайне порівняння рядків видає підказку за часом Зверху звичайне порівняння: воно зупиняється на першій розбіжності, тому чим більше перших символів угадано, тим довше працює сервер, і час відповіді підказує зловмиснику, що він на вірному шляху. Знизу hash_equals: воно завжди проходить усі 64 символи, і час відповіді однаковий для будь-якого підпису. Звичайне порівняння: зупиняється на першій розбіжності правильний: d 5 d 5 0 2 7 a 9 a 8 7 … спроба 1: a 0 0 0 0 0 0 0 0 0 0 0 … стоп на 1-му · 0,8 мкс спроба 2: d 0 0 0 0 0 0 0 0 0 0 0 … стоп на 2-му · 0,9 мкс спроба 3: d 5 d 0 0 0 0 0 0 0 0 0 … стоп на 4-му · 1,1 мкс Різниця мікроскопічна — але вона є, і вона систематична. Мільйон вимірів усереднює шум, і підпис добирається символ за символом. hash_equals: завжди всі 64 символи, у будь-якому випадку будь-яка спроба: ─────── усі 64 ─────── завжди · 1,9 мкс Час відповіді не залежить від того, скільки символів угадано. Каналу витоку немає.
    Числа в мікросекундах — ілюстративні, вони показують порядок і напрям, а не заміряні на конкретній машині. Реальна різниця ще менша, і саме тому атаку роблять мільйонами повторів із усередненням.
    Наскільки це реальна загроза для шкільного сервера Чесно: сама по собі — невелика. Мережевий шум на порядки більший за різницю в мікросекундах, і щоб її витягти, потрібні мільйони запитів із дуже близької точки. Але правило 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 символів, цього досить, зате швидко». Що тут не так?

    Крок 6

    Replay: правильний підпис, який усе одно ламає тобі дані

    Найнеприємніша атака цього уроку тим, що в ній усе валідне. Підпис справжній, секрет не витік, тіло не змінене

    Уяви квиток у кіно зі справжнім штрихкодом. Підробити його не вийшло б. А от відсканувати чужий і показати той самий код ще двадцять разів — цілком.

    Із запитами буває так само. Твій вебхук приймає подію «зарахувати оплату 200 грн». Зловмисник не знає секрету й підробити підпис не може.

    Зате він перехопив один валідний запит цілком: тіло і всі заголовки. І тепер надсилає його ще раз. І ще. І ще двадцять разів. Це й зветься replay-атакою — повтором.

    Що бачить твій сервер Підпис перевіряється — сходиться. Тіло не змінене — так і є. Відправник знає секрет — так, знає, бо запит справді колись підписав законний відправник. Сервер робить рівно те, що йому сказали: записує подію. Двадцять один раз.
    Replay у часі: де його зупиняє мітка часу, а де ні Вісь часу. О 10:00 законний відправник надсилає підписаний запит, сервер приймає. Зловмисник копіює запит. О 10:02, у межах вікна 300 секунд, повтор проходить: підпис валідний і мітка часу свіжа. О 10:40 повтор відхиляється кодом 401, бо мітці часу вже понад 5 хвилин. Ліворуч підпис не рятує, бо він у повторі правильний. час 10:00:00 законний запит ts=1788338467 підпис ✓ → 200 перехопив копію цілком 10:02:00 повтор № 1 ts=1788338467 підпис ✓ → 200 (!) 10:40:00 повтор № 2 ts=1788338467, вік 40 хв мітка часу → 401 вікно 300 с: тут запит вважається свіжим тут запит уже застарий Читай схему в такому порядку: 1. підпис від replay НЕ рятує взагалі: у повторі він правильний; 2. мітка часу зменшує вікно атаки з «назавжди» до 5 хвилин; 3. повтор № 1 усередині вікна лишається — його ловить лише nonce (крок 9).
    Мітка часу — не захист від replay, а обмеження терміну придатності перехопленого запиту. Дірка звужується з нескінченності до 5 хвилин, але не зникає.

    Де це трапляється не в теорії

    Ідемпотентність — те саме слово, що на уроці 9 Там ти запускав report.sh двічі й отримував той самий звіт. Це і є ідемпотентність: повтор нічого не змінює. Різниця в тому, хто повторює. На уроці 9 це був ти, тут — чужий сервіс або зловмисник. А nonce робить ідемпотентним навіть той обробник, що всередині просто дописує рядок у файл.
    Питання

    Твій вебхук отримав той самий підписаний запит двічі. У журналі два однакові записи, підпис в обох правильний, секрет не витікав, мітка часу свіжа. Чого ти не передбачив?

    Крок 7

    Вікно допустимого віку: чому саме 5 хвилин

    Відправник кладе в заголовок X-Timestamp час, коли він підписав запит. Сервер дивиться, наскільки цей час відстав від його власного

    уся перевірка — два рядки
    $age = abs(time() - (int)$ts);          // модуль: майбутнє теж підозріле
    if ($age > 300) { http_response_code(401); exit('stale timestamp'); }

    Модуль тут не для краси. Без нього пройде перевірку запит із міткою часу на добу вперед.

    А далі він лежатиме в зловмисника як «вічний квиток». Годиться цілу добу й проходить щоразу.

    Вікно допустимого віку запиту й обидва його краї Смуга часу з центром «зараз» на сервері. Зелена ділянка — 300 секунд у минуле і 300 у майбутнє, тут запит приймається. Ліворуч від неї запит застарий, праворуч — з майбутнього. Нижче показано, чим погане надто широке вікно (перехоплений запит живе годинами) і надто вузьке (розбіжність годинників у кілька секунд відкидає чесні запити). Вісь часу очима сервера старіше за 5 хв 401 stale вікно: −300 c … +300 c підпис перевіряємо далі з майбутнього 401 stale time() — зараз −300 c +300 c Вікно надто широке: 24 години перехоплений запит придатний цілу добу; сховище nonce мусить тримати добу записів; сенс мітки часу практично зникає Вікно надто вузьке: 2 секунди годинник відправника відстав на 4 с — і всі його чесні запити падають у 401; мережева затримка теж їсть це вікно Звідки береться розбіжність годинників домашній ноутбук без синхронізації може відставати на хвилини; сервер із увімкненим NTP тримає точність у межах часток секунди; 300 с — компроміс: покриває будь-який реальний зсув і не дає жити повтору.
    5 хвилин — не магічне число, а галузева домовленість. Stripe і Slack за замовчуванням беруть саме 300 секунд, і саме тому цю цифру бере й твій hook.php.
    Мітка часу мусить входити в підпис. Інакше вона нічого не варта Якщо підпис рахується тільки від тіла, зловмисник бере перехоплений запит і ставить у заголовок свіжий X-Timestamp. Повтор проходить, бо підпис від тіла не змінився. Саме тому наш рядок для підпису — ts . '.' . nonce . '.' . тіло: змінив мітку часу — зламав підпис.

    Що робити з годинником у себе

    СистемаПеревірити зсув
    Linux / VPStimedatectl status — дивись рядки System clock synchronized і NTP service
    Windowsw32tm /query /status, синхронізувати — w32tm /resync
    macOSsntp -sS time.apple.com або перемикач у «Дата й час»
    будь-депорівняй свій час із заголовком Date у відповіді сервера: curl -sI https://example.com | grep -i ^date
    Питання

    Однокласник поставив вікно 2 секунди: «чим вужче, тим безпечніше». Наступного дня сервіс скаржиться, що половина його доставок падає в 401, хоч підписи правильні. Що сталося?

    Питання

    Ти рахуєш підпис так: hash_hmac('sha256', $raw, $secret) — тільки від тіла. Мітку часу перевіряєш окремо, вікно 300 с. Зловмисник перехопив твій запит учора. Чи зупинить його вікно?

    Крок 8

    Nonce: сервер пам'ятає, що цей запит уже був

    На вході в клуб браслет надягають один раз і вдруге не приймають. Тут те саме, тільки браслет — це число в заголовку

    Відправник кладе в кожен запит унікальний ідентифікатор. Це 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 можна тримати недовго Ліворуч перевірка часу відсікає все, що старше за 300 секунд. Праворуч сховище nonce тримає записи 900 секунд. Через це запит, чий nonce уже видалено зі сховища, і так не пройде перевірку часу — два захисти перекриваються з великим запасом. Два фільтри перекриваються, і в цьому весь фокус вікно часу: 300 с старше — 401, підпис не рахуємо сховище nonce: 900 с записи живуть утричі довше за вікно 300 c 900 c вік запиту вік < 300 c: nonce ще у сховищі → 409 300…900 c: уже 401 за часом понад 900 c: теж 401 за часом Тому сховище й не мусить рости вічно: щойно nonce протух, його запит однаково не проходить за віком. Запас утричі покриває будь-яку розбіжність годинників.
    Вікно часу тримає сховище nonce маленьким, а сховище nonce закриває ту дірку, яку вікно лишає всередині себе. Поодинці не працює ні те, ні те.

    Три способи зберігати nonce — і чому ми беремо перший

    СпосібПлюсМінус
    файл-мітка в теціатомарно через fopen(..., 'x'), нуль залежностей, переживає перезапусктека з тисячами дрібних файлів; треба прибирати старі
    SQLiteодин файл, індекс, зручне DELETE WHERE ts <потрібне розширення pdo_sqlite; блокування при паралельних записах
    масив у пам'яті процесунайпростішене працює зовсім: кожен запит PHP — новий процес, пам'ять порожня. Після перезапуску replay проходить знову
    Найпоширеніша помилка на цій темі Зберегти nonce у змінній, у $_SESSION або в static-масиві. Виглядає працездатно на одному запиті з браузера, а на другому вже ні: PHP на кожен HTTP-запит стартує заново. Сховище має бути на диску — і воно має пережити перезавантаження сервера.
    Питання

    Однокласник додав nonce, перевірив у себе — повтор дає 409, усе працює. Через тиждень хостер перезавантажив сервер, і чекер знову провів replay успішно. Де він зберігав nonce?

    Крок 9

    Симулятор replay: три обробники, один запит

    Надішли валідний запит, потім натисни «повторити той самий» і дивись, як три різні обробники поводяться з тими самими байтами

    Один і той самий підписаний запит проти трьох рівнів захисту

    годинник сервера
    10:00:00
    вік запиту в буфері
    буфер порожній
    A · тільки підпис

    HMAC перевіряється, більше нічого

    записів у журналі
    0
      B · підпис + мітка часу

      вікно 300 секунд

      записів у журналі
      0
        C · підпис + час + nonce

        те, що ти зробиш сьогодні

        записів у журналі
        0

          Симулятор моделює три обробники й свій годинник. Підпис у ньому не рахується — це зроблено в лабораторії кроку 4; тут важлива сама логіка рішень і коди відповіді.

          Порядок дій, який варто пройти

          1. «надіслати новий валідний запит» — усі три пропускають, скрізь по 1 запису;
          2. «повторити той самий» — A і B пропускають знову, C відповідає 409. Ось воно: підпис і мітка часу від повтору не рятують;
          3. «час +6 хв», потім знову «повторити» — B нарешті відповідає 401 stale, A все ще пише запис;
          4. «час +20 хв» і ще повтор — A продовжує писати. Він робитиме це вічно: у нього немає жодної причини відмовити.
          Питання

          У симуляторі обробник B після «+6 хв» починає відхиляти повтор. Однокласник робить висновок: «отже, мітки часу достатньо, nonce — зайве ускладнення». Чим ти йому заперечиш?

          Крок 10

          Порядок перевірок і код відповіді на кожній

          Перевірки не переставляються місцями довільно. Порядок — це теж частина захисту, і він має власне пояснення на кожному кроці

          Конвеєр перевірок у обробнику вебхука з кодами відповіді Сім послідовних перевірок згори вниз: метод і розмір віддають 405 або 413; відсутні заголовки — 401; вік запиту поза вікном 300 секунд — 401; розбіжність HMAC — 401; уже використаний nonce — 409; битий JSON — 400. Якщо всі пройдено, подія записується і повертається 200. Праворуч від кожного кроку пояснено, чому він стоїть саме тут. 1 · метод POST і розмір ≤ 8 КБ найдешевша перевірка — першою 405 / 413 не читаємо велике тіло в пам'ять 2 · три заголовки на місці й правдоподібні X-Timestamp, X-Nonce, X-Signature 401 nonce перевіряємо регуляркою: він стане іменем файлу 3 · вік запиту: |зараз − ts| ≤ 300 с ще до підпису — це просте віднімання 401 відсіює старе, не витрачаючи процесор на HMAC 4 · HMAC від ts.nonce.тіло, hash_equals межа довіри проходить рівно тут 401 усе, що вище, — недовірене; усе, що нижче, — від свого 5 · nonce ще не використовували запис у сховище — атомарно 409 ПІСЛЯ підпису: інакше чужий заллє тобі сховище сміттям 6 · json_decode і перелік відомих полів розбираємо тіло тільки тепер 400 парсер — складний код; не годуємо його чужими байтами дарма 7 · рядок у ~/data/events.jsonl FILE_APPEND | LOCK_EX 200 журнал лежить поза ~/www Правило порядку: спершу дешеве, потім дороге; спершу перевірка підпису, потім будь-яка робота з умістом тіла. До кроку 4 сервер не вірить у запиті жодному байту.
          Кожна відмова — окремий код. Це не формальність: за кодом відправник розуміє, повторювати доставку чи ні. На 400 і 401 повторювати марно, на 500 — має сенс.

          Коди, які ти віддаєш, і що вони означають відправнику

          КодКолиЩо робить нормальний відправник
          200усе гаразд, подія записанавважає доставку успішною
          400підпис правильний, а тіло — не JSONне повторює: помилка в нього самого
          401немає підпису, підпис не сходиться або мітка часу поза вікномне повторює: розбіжність секретів або схеми
          405не POSTне повторює
          409цей nonce уже був — це повторвважає, що подія вже дійшла, і заспокоюється
          413тіло більше за 8 КБне повторює
          500зламався ти: немає секрету, не пишеться файлповторює — тому 500 не можна віддавати на чужі помилки
          Тонкість, яку помітить чекер Критерій error_handling надсилає тіло {{{ і чекає 400. Це працює тільки тому, що чекер правильно підписує це бите тіло. Підпис рахується від сирих байтів, і {{{ підписується так само, як валідний JSON. Якщо ті самі {{{ надіслати без підпису, ти маєш віддати 401, а не 400 — бо до розбору тіла справа не доходить. Обидві поведінки правильні; головне — розуміти, чому вони різні.
          Чи писати відмови в окремий журнал Так, і це недорого: рядок «час, код, причина, IP» у ~/data/hook-errors.log. Але ніколи не пиши туди ні секрет, ні повний підпис, що приїхав, — інакше твій журнал стає підказкою. Перших 8 символів підпису для розслідування досить.
          Питання

          Однокласник переставив перевірки: спершу json_decode і запис у журнал, потім перевірка підпису. Каже: «різниці немає, все одно на невалідному підписі я віддаю 401». У чому він помиляється?

          Крок 11

          api/hook.php — версія з підписом

          Це той самий файл із уроку 11, у який дописано кроки 0—5 конвеєра. Скопіюй, розберись у кожному рядку, залий по FTP

          ~/www/api/hook.php — повний файл
          <?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]);
          fopen(..., 'x')
          режим «створити, і тільки якщо файлу ще немає». На рівні файлової системи це атомарна операція: два однакові запити, що прийшли одночасно, не зможуть обидва створити той самий файл. Перевірка file_exists() плюс touch() такої гарантії не дає — між ними встигає прослизнути другий процес.
          strtolower($nonce)
          щоб A1B2 і a1b2 не стали двома різними файлами й не дали лазівку для повтору. Те саме зроблено з підписом перед hash_equals.
          preg_match на nonce
          nonce потрапляє в шлях до файлу. Без цього фільтра значення ../../www/index.php дозволило б створювати файли де завгодно. Правило уроку 4 діє й тут: усе, що прийшло ззовні, — недовірене.
          $raw до json_decode
          підпис рахується від байтів, що прийшли. Найпоширеніша причина «підпис ніколи не сходиться» — розібрати JSON і зібрати назад: пробіли, порядок ключів і екранування зміняться, а байти мусили лишитися ті самі.
          @parse_ini_file
          читає ~/.env як КЛЮЧ=значення. Секрет роби з літер і цифр без лапок, # і пробілів — інакше цей парсер зрозуміє рядок не так, як ти задумав.
          fail(500, …)
          500 тут лише там, де винен сервер: немає секрету, не пишеться файл. На чужу помилку 500 віддавати не можна — відправник почне повторювати доставку.
          Один компроміс, який варто розуміти nonce записується до розбору JSON. Отже, якщо відправник надіслав битий JSON, він отримає 400 — а його nonce вже витрачений. Повторити той самий запит із виправленим тілом не вийде: треба брати новий nonce. Це нормально й навіть правильно: «той самий nonce = той самий запит», а виправлене тіло — це вже інший запит.
          Питання

          Учень пише в обробнику: $body = json_encode(json_decode($raw, true)); — «щоб нормалізувати JSON», і рахує підпис від $body. Секрет правильний, годинники синхронні. Що він побачить?

          Крок 12

          Бік відправника: секрет, підпис і три заголовки

          Перевіряти вміє тільки той, хто вміє підписувати. Спершу — секрет, який ніколи не потрапляє у ~/www

          Секрет: створити й покласти в ~/.env

          по SSH, не по FTP
          $ openssl rand -hex 20 2f8b1c7d4e9a6053b7c2f1a8d3e560947c1b2a8f $ echo 'HOOK_SECRET=2f8b1c7d4e9a6053b7c2f1a8d3e560947c1b2a8f' >> ~/.env $ chmod 600 ~/.env $ mkdir -p ~/data/nonces && chmod 700 ~/data/nonces $ ls -ld ~/.env ~/data/nonces -rw------- 1 ТВІЙ-ЛОГІН ТВІЙ-ЛОГІН 116 Sep 2 13:20 /home/ТВІЙ-ЛОГІН/.env drwx------ 2 ТВІЙ-ЛОГІН ТВІЙ-ЛОГІН 4096 Sep 2 13:20 /home/ТВІЙ-ЛОГІН/data/nonces
          Секрет із цієї сторінки — навчальний. Не бери його Рядок 2f8b1c…2a8f надрукований у лабораторії, у схемах і в цьому терміналі. Він відомий усьому класу. Свій секрет згенеруй сам командою вище й нікому не показуй. Зокрема не вставляй його в security.md, у скріншот і в чат із проханням допомогти.

          Підписуємо запит: curl плюс дві команди

          Найпростіший чесний спосіб перевірити свій обробник — підписати запит прямо в оболонці. Тіло, мітка часу й nonce склеюються в один рядок. Від нього й рахується HMAC.

          Git Bash, macOS, Linux — підписаний запит одним блоком
          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"
          Windows PowerShell 5.1: три пастки в одному блоці 1. curl там — псевдонім Invoke-WebRequest, тому пиши curl.exe. 2. && між командами не працює — став ; або if ($?) { … }. 3. Головне: echo й > у PowerShell дописують BOM і переводять рядок — а підпис рахується від точних байтів. Тому в PowerShell рахуй підпис не через файли, а класом HMACSHA256, як у блоці нижче.
          Windows PowerShell 5.1 — той самий підписаний запит
          $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

          Google Apps Script: справжні події, а не curl

          Тут той самий план Б, що й на уроці 11. Безкоштовні тарифи Zapier та IFTTT не дають вебхука на власний сервер. Тому події надсилає Apps Script, прив'язаний до форми. Тепер він ще й підписує їх.

          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
            });
          }
          ScriptProperties
          секрет живе в налаштуваннях проєкту Apps Script, а не в тексті скрипта. Інакше він поїде разом із копією скрипта до будь-кого, з ким ти поділишся файлом.
          payload: body
          у мережу йде той самий рядок, який підписано. Якщо передати сюди об'єкт, бібліотека серіалізує його по-своєму — і байти розійдуться з підписом.
          (b & 0xFF) + 0x100
          Apps Script віддає байти зі знаком (від −128 до 127). Ця арифметика перетворює їх на два шістнадцяткові символи без «−7f».
          Крок 13

          api/security.md: щоб чужа команда підключилася без тебе

          Документ пишеться не для вчителя. Він пишеться для людини, яка завтра захоче надіслати тобі подію, а тебе поруч не буде

          Перевірити якість цього файлу просто. Дай його однокласникові й подивись, чи збере він правильний підпис із першої спроби.

          Якщо не зібрав — у документі бракує рядка. Найчастіше про те, у якому порядку склеювати частини, або про кодування.

          ~/www/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, а щоб приклад був «повністю відтворюваним», додав туди справжній секрет — «однаково ж це навчальний сервер». Що станеться і що робити?

          Крок 14

          Практика: секрет, підпис, вікно, nonce

          Кроки 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 й натисни перевірку ще раз — спроби не обмежені.
          Перш ніж заливати по FTP Прожени очима свій hook.php і security.md: чи не лишився там секрет «для налагодження»? Найшвидша перевірка вже на сервері — grep -r "$(grep HOOK_SECRET ~/.env | cut -d= -f2)" ~/www. Команда має не вивести нічого.
          Крок 15

          Автоперевірка

          Чекер знає твій секрет (ти сам поклав його в ~/.env, а чекер читає його на сервері від твого імені) і надсилає п'ять запитів: один валідний і чотири зіпсовані

          Демонстраційний режим: результат згенеровано для показу. На сервері ця кнопка викликає POST /api/check, який запускає /opt/lessons/12/check.sh від імені учня.

          Які саме п'ять запитів надійде

          КритерійЩо надсилає чекерЧого чекає
          error_handlingправильно підписане тіло {{{400, і наступний запит теж обслуговується
          sig_badвалідний запит, у якому змінено один байт тіла401, у журналі нічого не додано
          sig_missingте саме тіло без заголовка X-Signature401
          replayвалідний запит, а потім він же ще раз, байт у байтперший 200, другий 409 або 401; у журналі один рядок
          staleправильно підписаний запит із X-Timestamp на 10 хвилин у минуле401
          Найчастіша причина, чому падає replay Обробник відповідає 409, але рядок у журнал уже дописаний — бо запис зроблено до перевірки nonce. Чекер дивиться не тільки на код відповіді, а й на кількість рядків у events.jsonl до і після.

          Що вчитель дивиться на твоєму екрані

          Крок 16

          Шкала виконання

          Це те, що бачить учитель, коли виставляє оцінку.

          СкладникВагаРезультат
          Виконано
          0%
          Рекомендований бал за 12-бальною

          Сім речень, які варто запам'ятати

          Де найчастіше помиляються

          Домашнє завдання

          Додай у ~/www/api/security.md два приклади запиту: коректний і зіпсований. У кожному — усі три заголовки з конкретними значеннями, тіло і код відповіді, який має прийти. Секрет у прикладі заміни на ***.

          Зіпсований приклад зроби так, щоб він відрізнявся від коректного рівно одним символом у тілі. Постав поруч обидва підписи — буде видно, наскільки вони різні.

          Другим абзацом опиши, як ти замінятимеш секрет, якщо він завтра витече. У якому порядку, щоб доставки не зупинилися. І що робити з подіями, які прийдуть якраз у момент заміни.

          Джерела

          Звідки взяті числа й формулювання

          Підписи на цій сторінці справжні: будь-який із них перераховується в лабораторії кроку 4 або командою openssl dgst -sha256 -hmac

          Повний список із поясненнями, що звідки взято, які числа перевіряються, а які є ілюстративними, — у файлі urok-12-джерела.md поруч із цією сторінкою.