Перевірка підпису
Кожен запит підписано HMAC-SHA256 на секреті вашого ендпоінта. Перевірка підпису — обов'язковий крок: без неї будь-хто, хто дізнався адресу приймача, зможе надіслати вам вигадану подію.
Секрет
- Формат —
whsec_+ 43 символи base64url. Генерується на сервері Smengo; задати свій не можна. - Показується рівно один раз — під час створення ендпоінта та після ротації. Переглянути його повторно не можна ні в інтерфейсі, ні через API: зберігається він під обмеженим доступом і ніколи не потрапляє в логи.
- Секрет втрачено — натисніть «Змінити секрет» і збережіть новий.
Формат заголовка
Smengo-Signature: t=1785921323,v1=5a0f3c9e…c81b
t— unix-секунди моменту підпису (те саме значення, що вSmengo-Timestamp).v1— HMAC-SHA256 у нижньому регістрі hex від рядка`${t}.${rawBody}`, деrawBody— сире тіло запиту до розбору.- У вікні ротації підписів два, вони розділені пробілом:
Smengo-Signature: t=1785921323,v1=5a0f3c9e…c81b v1=9d2b71af…40ea
Невідомі ключі (наприклад, v2= з майбутньої версії) ігноруйте — вони з'являться раніше, ніж перестане працювати v1.
Алгоритм перевірки
- Візьміть сирі байти тіла — до
JSON.parseі до будь-яких перетворень. Перезібраний JSON дасть інший рядок і інший підпис. - Розберіть заголовок: одне значення
tі всі значенняv1. - Відхиліть запит, якщо
|now − t| > 300секунд (вікно 5 хвилин, захист від повторного програвання старого запиту). Розбирайтеtстрого як ASCII-цифри: у Pythonisdigit()пропускає юнікодні цифри на кшталт١٢٣, аint()на них падає. - Порахуйте
HMAC-SHA256(secret, "${t}.${rawBody}")у hex. - Порівняйте з кожним
v1у constant-time і за байтами, а не за рядками:timingSafeEqualкидає виняток на буферах різної довжини (звіряйте довжину заздалегідь), аcompare_digest—TypeErrorна рядку з не-ASCII-символами (кодуйте кандидата в байти). - Збіглося хоча б одне — подія справжня. Не збіглося жодне — відповідайте
401/403і нічого не обробляйте.
Ітеруйте за всіма
v1. Клієнт, який бере лише перший підпис, перестане приймати події на добу під час першої ж ротації секрету.
Node.js
const crypto = require('node:crypto')
const TOLERANCE_SEC = 300
function verifySmengoSignature(rawBody, header, secret) {
if (typeof header !== 'string' || header.length === 0) return false
// 1. Розбір заголовка: одне t, ВСІ v1 (під час ротації їх два).
let timestamp = null
const signatures = []
for (const token of header.split(/[\s,]+/)) {
const eq = token.indexOf('=')
if (eq <= 0) continue
const key = token.slice(0, eq)
const value = token.slice(eq + 1)
if (key === 't') {
if (timestamp === null && /^\d+$/.test(value)) timestamp = Number(value)
} else if (key === 'v1' && value.length > 0) {
signatures.push(value)
}
}
if (timestamp === null || signatures.length === 0) return false
// 2. Вікно 5 хвилин — ДО порівняння хешів.
const nowSec = Math.floor(Date.now() / 1000)
if (Math.abs(nowSec - timestamp) > TOLERANCE_SEC) return false
// 3. Підпис рахується за СИРИМ тілом.
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`, 'utf8').digest('hex'),
'utf8',
)
// 4. Constant-time порівняння з попередньою перевіркою довжини.
return signatures.some((candidate) => {
const actual = Buffer.from(candidate, 'utf8')
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected)
})
}
Приймання в Express — тіло обов'язково сире:
const express = require('express')
const app = express()
// express.raw, а не express.json: потрібен вихідний Buffer.
app.post('/hooks/smengo', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8')
if (!verifySmengoSignature(rawBody, req.get('Smengo-Signature'), process.env.SMENGO_WEBHOOK_SECRET)) {
return res.status(401).end()
}
const event = JSON.parse(rawBody)
// Дедуплікація за event.id (= заголовок Smengo-Event-Id) та АСИНХРОННА обробка:
// відповісти потрібно в межах 15 секунд.
enqueue(event)
res.status(200).end()
})
Python
import hashlib
import hmac
import time
TOLERANCE_SEC = 300
def verify_smengo_signature(raw_body: bytes, header: str, secret: str) -> bool:
if not header:
return False
# 1. Розбір заголовка: одне t, ВСІ v1 (під час ротації їх два).
timestamp = None
signatures = []
for token in header.replace(",", " ").split():
key, _, value = token.partition("=")
if key == "t":
# НЕ isdigit(): він пропускає юнікодні цифри («١٢٣», «²³»), на яких
# int() кидає ValueError. Обмеження довжини — від заголовка з
# тисячами цифр (int() на ньому теж падає).
if timestamp is None and value.isascii() and value.isdecimal() and len(value) <= 20:
timestamp = int(value)
elif key == "v1" and value:
signatures.append(value)
if timestamp is None or not signatures:
return False
# 2. Вікно 5 хвилин — ДО порівняння хешів.
if abs(int(time.time()) - timestamp) > TOLERANCE_SEC:
return False
# 3. Підпис рахується за СИРИМИ байтами тіла.
expected = hmac.new(
secret.encode("utf-8"),
f"{timestamp}.".encode("utf-8") + raw_body,
hashlib.sha256,
).hexdigest().encode("ascii")
# 4. Constant-time порівняння за БАЙТАМИ. compare_digest на РЯДКУ з не-ASCII
# кидає TypeError (перевірка довжини не рятує: len("é" * 64) == 64), а
# заголовок надсилає будь-хто. На байтах різної довжини він просто
# повертає False — окремої перевірки довжини не потрібно.
return any(
hmac.compare_digest(candidate.encode("utf-8", "ignore"), expected)
for candidate in signatures
)
Приймання у Flask — request.get_data() віддає сирі байти:
from flask import Flask, request
app = Flask(__name__)
@app.post("/hooks/smengo")
def smengo_webhook():
raw_body = request.get_data()
header = request.headers.get("Smengo-Signature", "")
if not verify_smengo_signature(raw_body, header, SMENGO_WEBHOOK_SECRET):
return "", 401
event = request.get_json()
enqueue(event) # дедуплікація за event["id"], обробка асинхронна
return "", 200
Ротація секрету
Кнопка «Змінити секрет» у картці вебхука видає новий секрет і запускає вікно на 24 години:
- Одразу після ротації кожна доставка підписується обома секретами — старим і новим (два
v1через пробіл). - Протягом доби оновіть секрет на своєму боці. Приймання не переривається: код, що ітерує за всіма
v1, приймає події і до, і після перемикання. - Після закінчення вікна старий секрет перестає використовуватися, у заголовку лишається один підпис.
Поки вікно не закрилося, повторна ротація недоступна. Якщо секрет скомпрометовано і старий треба вимкнути негайно — видаліть ендпоінт і створіть новий.
Часті помилки
| Симптом | Причина |
|---|---|
| Підпис не сходиться, тіло виглядає правильним | тіло перезібрано після розбору (JSON.stringify(JSON.parse(body))) — рахуйте HMAC за сирими байтами |
| Приймання зламалося рівно після зміни секрету | код бере перше v1 замість перебору всіх |
| Виняток усередині порівняння | timingSafeEqual викликано на буферах різної довжини (звіряйте довжину заздалегідь) або compare_digest — на рядку з не-ASCII (порівнюйте байти) |
На сміттєвий заголовок приймач відповідає 500 замість 401 |
t розібрано через isdigit() або підпис порівнюється рядками — і те, і те падає на заголовку, який може надіслати будь-хто, навіть не знаючи секрету |
| Усі запити відхиляються як «старі» | годинник приймача розійшовся; вікно допуску — 5 хвилин, синхронізуйте час за NTP |
| Підпис зійшовся, але події надходять двічі | це штатний режим at-least-once — дедуплікуйте за Smengo-Event-Id |
Далі
- Повтори та збої — що буде, якщо відповісти не
2xx. - Події — склад тіла, яке ви щойно перевірили.