takemypay

Как проверить подпись webhook: HMAC-SHA256 на практике

· 5 мин чтения

Зачем подписывают уведомления о платежах, как устроена подпись HMAC-SHA256, четыре ошибки, из-за которых она не сходится, и защита от повторной отправки.

Webhook — это HTTP-запрос от платёжного сервиса на ваш адрес: «платёж такой-то оплачен». Адрес этот публичный, отправить на него запрос может кто угодно. Поэтому уведомление, которому вы доверяете деньги, обязано быть подписано, а подпись — проверена до того, как заказ уйдёт в отгрузку.

Это касается любого приёма платежей — и через банк, и через шлюз, к которому приходят те, кому не дали эквайринг. Механика подписи от этого не зависит.

Что такое HMAC и почему не просто хэш

Первая мысль — послать вместе с телом его SHA-256. Это не работает: злоумышленник, подделавший тело, точно так же посчитает от него хэш. Хэш подтверждает целостность, но не авторство.

HMAC решает именно вторую задачу. В вычислении участвует секрет, известный только двум сторонам:

signature = HMAC_SHA256(key = секрет, msg = тело_запроса)

Не зная секрета, подпись не подделать. Проверка на вашей стороне — посчитать то же самое от пришедшего тела и сравнить с заголовком.

Как это устроено в takemypay

Секретом служит не сам API-ключ, а его SHA-256 в hex:

X-Signature = HMAC_SHA256(key = hex(SHA256(api_key)), msg = raw_body) → hex

Лишний шаг здесь не украшение. На стороне сервиса хранится только хэш ключа — сам ключ показывается один раз при создании и больше не восстановим. Раз обе стороны знают хэш, он и берётся секретом.

Полностью на Python:

import hmac, hashlib

def verify(raw_body: bytes, signature: str, api_key: str) -> bool:
    secret = hashlib.sha256(api_key.encode()).hexdigest()
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Четыре ошибки, из-за которых подпись не сходится

1. Считать не по сырому телу. Самая частая. Фреймворк уже распарсил JSON, вы берёте объект, сериализуете обратно — и получаете другую строку: поменялся порядок ключей, пропали или добавились пробелы, экранирование юникода поехало. Подпись считается по байтам, которые пришли, до любого разбора. В FastAPI это await request.body(), в Express — express.raw() или сохранённый rawBody, в Django — request.body (но не request.POST).

2. Сравнивать через ==. Обычное сравнение строк выходит на первом несовпавшем байте, и по времени ответа подпись можно подобрать побайтово. Нужна константная по времени функция: hmac.compare_digest в Python, crypto.timingSafeEqual в Node.

3. Перепутать hex и base64. Обе стороны должны сойтись в кодировке. Если digest() вернул байты, а в заголовке hex-строка, сравнение не сойдётся никогда — при том что подпись верна.

4. Не учесть ротацию ключей. Когда у проекта несколько активных ключей, подписано будет новейшим. Заголовок X-Key-Prefix содержит первые 10 символов использованного ключа — по нему выбирается, против какого проверять. Без этого шага ротация ломает приём уведомлений ровно в момент выпуска нового ключа.

Защита от повторной отправки

Верная подпись не означает, что запрос свежий. Перехватив однажды валидное уведомление, его можно отправлять повторно сколько угодно — подпись останется правильной.

Поэтому в подписанное тело кладутся timestamp и nonce. Правило простое: отвергать тело старше пяти минут. Раз оба поля внутри подписи, подменить их, не сломав её, нельзя.

from datetime import datetime, timezone

ts = datetime.fromisoformat(body["timestamp"])
if (datetime.now(timezone.utc) - ts).total_seconds() > 300:
    return False

Ответ должен быть 2xx

Отдельная ловушка, не связанная с криптографией. Доставкой считается ответ 2xx; всё остальное — повод повторить. Редиректы в это «остальное» входят: 301 и 302 не успех.

Ошибка выглядит невинно — фреймворк сам уводит с /webhook на /webhook/ или с http на https. В браузере эндпоинт открывается, тесты зелёные, а уведомления не принимаются ни одно. Обнаруживается это по недоотгруженным заказам через несколько дней, потому что ошибки в логах нет: с точки зрения вашего сервера ничего не произошло.

Проверяется одной командой:

curl -i -X POST https://ваш-домен/webhook -d '{}'

В первой строке должно быть HTTP/1.1 200, а не 301 или 302.

Цена этой ошибки зависит от способа оплаты. Там, где подтверждение приходит только по сети — например, приём оплаты через СБП, — непринятый webhook означает, что об оплате вы не узнаете вообще ничем другим.

Обработчик обязан быть идемпотентным

Одно и то же уведомление придёт к вам не один раз — например, когда ваш ответ не дошёл из-за сетевого таймаута и сработал повтор. Опираться нужно на идентификатор платежа и на то, что заказ уже обработан, а не на сам факт получения запроса. Иначе повтор превратится во вторую отгрузку.

Расписание повторов, заголовки и полный формат тела — в документации по webhook.

Частые вопросы

Почему нельзя просто прислать SHA-256 от тела запроса?

Потому что хэш подтверждает целостность, но не авторство: злоумышленник, подделавший тело, точно так же посчитает от него хэш. В HMAC участвует секрет, известный только двум сторонам, — не зная его, подпись подделать нельзя.

Что служит секретом в takemypay?

Не сам API-ключ, а его SHA-256 в hex. На стороне сервиса хранится только хэш ключа: сам ключ показывается один раз при создании и больше не восстановим. Секретом поэтому берётся то, что известно обеим сторонам.

Подпись не сходится, хотя ключ верный. Где искать?

Первым делом — в том, по чему считается подпись: почти всегда её считают не по сырому телу, а по пересобранному JSON, у которого поехал порядок ключей или экранирование. Остальные три причины: сравнение через == вместо функции с постоянным временем, путаница hex и base64, неучтённая ротация ключей (какой ключ использован, говорит заголовок X-Key-Prefix).

Зачем timestamp и nonce, если подпись верна?

Верная подпись не означает, что запрос свежий: однажды перехваченное уведомление можно отправлять повторно сколько угодно, и подпись останется правильной. Оба поля лежат внутри подписи, поэтому правило «отвергать тело старше пяти минут» обойти, не сломав подпись, нельзя.

Считается ли ответ 302 доставкой уведомления?

Нет. Доставкой считается только 2xx, всё остальное — повод повторить, и редиректы сюда входят. Ошибка выглядит невинно: фреймворк сам уводит с /webhook на /webhook/, в браузере эндпоинт открывается, тесты зелёные, а не принято ни одно уведомление.

Может ли одно и то же уведомление прийти дважды?

Да, это штатное поведение — например, когда ваш ответ не дошёл из-за сетевого таймаута и сработал повтор. Поэтому обработчик обязан опираться на идентификатор платежа и на признак «заказ уже обработан», а не на сам факт получения запроса.

Если приём платежей нужен на практике, а не в теории — кабинет takemypay выдаёт тестовый ключ сразу после регистрации. Вопросы по интеграции — в поддержку.

Поделиться Telegram X

Читать дальше