Проверка подписи
Каждый запрос подписан 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. - События — состав тела, которое вы только что проверили.