Попробовать API
</>
Idempotency-Key, HMAC и replay: три слоя защиты API
api-dizaynРазбор
14 мин

Idempotency-Key, HMAC и replay: три слоя защиты API

Любой публичный POST без `Idempotency-Key` создаёт дубль при сетевом сбое. Любой webhook без HMAC-подписи открыт для phishing, а с подписью, но без timestamp+nonce — для replay. TL;DR: это три разных класса проблем, и один механизм не подменяет другой. Разбираю, как закрыть все три дыры в Public API — с кодом `Verifier` из `sdk-php` и полным приёмником на Python.

Коротко

  • Три механизма — три класса проблем. Idempotency-Key защищает состояние от случайных дублей при retry. HMAC-подпись — от запросов с чужого сервера, узнавшего URL webhook. timestamp + event.id — от повторной отправки уже подписанного запроса. Подменить один другим нельзя.
  • Каждый слой имеет тонкость, на которой ошибаются. Ключ идемпотентности привязан к *логической операции*, не к HTTP-вызову. HMAC сравнивают в *постоянном времени* (hash_equals, не ==). timestamp должен быть *частью подписи*, иначе его подменят.
  • Production-приёмник включает все три в порядке возрастания стоимости: дешёвая проверка timestamp → HMAC → обращение в кеш обработанных event.id. Ошибся в порядке — платишь ресурсами на каждом фейке.

Перед открытием публичного API мы прошли через дискуссию «достаточно ли HMAC на webhook и нужен ли Idempotency-Key на POST». Короткий ответ — нет, не достаточно, и да, нужен. Они закрывают три разных класса атак и сбоев, подменить один другим невозможно. Общий контур production-ready POST-контракта мы разбираем отдельно — Production-ready POST: idempotency, retry, rate-limit; здесь фокус на трёх слоях безопасности.

                  [ Клиент ]                    [ BOTIX ]
                       |                            |
   1. POST /contacts   |  ---- Idempotency-Key ---> |   ← защита от дублей при retry
                       |                            |
   2. Webhook event    |  <--- HMAC-SHA256 -------- |   ← защита от phishing
                       |                            |
   3. Webhook event    |  <--- timestamp + event.id |   ← защита от replay

Idempotency защищает состояние от случайных дублей при сетевых ошибках. HMAC защищает от запросов с произвольного сервера, узнавшего URL webhook'а. Replay-защита — от пересылки уже подписанного запроса повторно.

Слой 1: Idempotency-Key на POST

Клиент отправляет POST, в канале сбой — TCP-сброс, таймаут балансировщика, обрыв на стороне провайдера. Клиент не получил ответа, но запрос дошёл и обработан. Клиент делает retry. На сервере — второй заказ, второе списание, второе исходящее сообщение. Дубль.

Идемпотентность — это свойство операции, при котором её повторное выполнение с теми же входными данными даёт тот же результат без побочных эффектов. Для GET/PUT/DELETE она встроена в семантику HTTP (теоретически). Для POST — нет: POST по определению создаёт ресурс, и без защиты повторный вызов создаст второй. Индустриальный стандарт — Idempotency-Key, уникальный UUID в заголовке. У нас он обязателен на всех мутирующих эндпоинтах. Не передал — 400 IDEMPOTENCY_KEY_REQUIRED. Опциональная идемпотентность — это идемпотентность только для дисциплинированных; остальные забудут, и в проде появятся дубли. Лучше падать на отсутствии ключа, чем тихо производить мусор в БД.

yaml
parameters:
  IdempotencyKeyHeader:
    name: Idempotency-Key
    in: header
    required: true
    schema:
      type: string
      format: uuid
      example: 7a3c2d1e-9b8f-4a7e-8c5d-2e1f4a6b8c0d
    description: |
      UUIDv4, уникальный для каждой логической операции.
      При retry той же операции — тот же ключ.
      TTL хранилища 24 часа.

Ответ при коллизии (тот же ключ, другое тело):

yaml
ErrorResponse:
  code:
    type: string
    enum:
      - idempotency_key_required
      - idempotency_key_reuse_conflict
      - idempotency_key_in_progress
      # ... остальные

Что хранится в Redis

Не только тело ответа, но HTTP-статус и заголовки. Иначе при первом ответе 409 Conflict и retry клиент получит 200 OK из кеша. Хранится полная репрезентация: status + headers + body. TTL — 24 часа: компромисс между «не потерять идемпотентность для долгих retry-стратегий» и «не раздувать хранилище». Меньше суток ставить опасно — retry может прийти через 6 часов, когда ответ уже забыт, и операция выполнится повторно.

php
public function handleIdempotent(string $key, array $request, callable $action): Response
{
    // 1. Атомарный захват ключа
    $stored = $this->redis->set(
        "idem:{$key}",
        json_encode(['state' => 'in_progress', 'hash' => $this->hashRequest($request)]),
        ['NX', 'EX' => 86400] // 24 часа
    );

    if (!$stored) {
        $existing = json_decode($this->redis->get("idem:{$key}"), true);

        if ($existing['hash'] !== $this->hashRequest($request)) {
            return new Response(409, ['error' => ['code' => 'idempotency_key_reuse_conflict']]);
        }

        if ($existing['state'] === 'in_progress') {
            return new Response(409, ['error' => ['code' => 'idempotency_key_in_progress']], ['Retry-After' => '5']);
        }

        return Response::fromArray($existing['response']);
    }

    // 2. Выполнение операции
    $response = $action();

    // 3. Сохранение результата
    $this->redis->set(
        "idem:{$key}",
        json_encode([
            'state' => 'done',
            'hash' => $this->hashRequest($request),
            'response' => $response->toArray(),
        ]),
        ['EX' => 86400]
    );

    return $response;
}

Ключевое: атомарный захват через SET NX EX, хранение хеша запроса для проверки коллизии, состояние in_progress для параллельных retry. Последнее закрывает race condition: клиент отправил POST с ключом K, операция идёт 3 секунды, клиент по таймауту делает retry с тем же K. Вторая попытка видит in_progress и получает 409 IDEMPOTENCY_KEY_IN_PROGRESS с Retry-After: 5 — подождёт и получит ответ первой попытки из кеша, а не запустит дубль.

Где генерировать ключ

В SDK, не в пользовательском коде:

python
# from botix import Client (PyPI: pip install botix==1.1.0)
# botix-pro/sdk-python/src/botix/_http.py
import uuid

def post(self, path: str, body: dict, **kwargs) -> dict:
    headers = kwargs.pop("headers", {})
    headers.setdefault("Idempotency-Key", str(uuid.uuid4()))
    return self._request("POST", path, body=body, headers=headers, **kwargs)

«Логическая операция» — место, где интеграторы ошибаются. Ключ привязан к операции в логике приложения, не к HTTP-вызову. Цикл на 100 контактов — каждому свой ключ. Таймаут и retry одного — ключ тот же. Если генерировать новый UUID на каждую попытку, идемпотентность теряется, и мы возвращаемся к исходной проблеме дублей.

Что нельзя брать в качестве ключа

  • Auto-increment ID из локальной БД — не уникален между средами, легко коллизит.
  • Timestamp — два одновременных запроса от двух процессов получат одинаковое значение.
  • Хеш от тела запроса — совпадёт для двух *разных* логических операций с одинаковыми параметрами (два повторных списания одной суммы — это две операции, не одна).

Используйте UUIDv4 или ULID. K должен быть уникальным *для логической операции*, а не для входных данных. Для GET ключ не нужен по семантике HTTP; для PUT/DELETE формально тоже, но если они триггерят побочные эффекты (email при смене статуса) — ключ имеет смысл и там.

Слой 2: HMAC-подпись webhook

URL webhook-эндпоинта публичен по определению — он должен быть доступен из интернета, чтобы платформа могла достучаться. Адрес попадает в .env.example, логи реверс-прокси, утечки через подрядчика, перебор поддоменов. Без подписи злоумышленник формирует фальшивый webhook:

json
{
  "event": "payment.succeeded",
  "amount": 100000,
  "currency": "RUB",
  "order_id": "ORD-42"
}

Сервер клиента видит «нужное» событие, помечает заказ оплаченным, отгружает товар. Сценарий не теоретический — URL webhook-эндпоинтов утекают. HMAC закрывает эту дверь. Платформа и клиент имеют общий секрет (выдаётся один раз при создании подписки на webhook). Платформа считает signature = HMAC-SHA256(secret, raw_request_body) и кладёт в заголовок X-Botix-Signature. Клиент повторяет ту же операцию с тем же секретом и телом; совпало — запрос от платформы, не совпало — подделка. Злоумышленник не знает секрет и не может вычислить правильную подпись для своего тела.

Почему == — это уязвимость

python
# Неправильно
if hmac.new(secret, body, sha256).hexdigest() == request.headers["X-BOTIX-Signature"]:
    process_event(body)

Оператор == останавливается на первом несовпадении байтов. Разница в наносекундах, но злоумышленник шлёт тысячи запросов с разными подписями, измеряет время ответа и постепенно подбирает каждый байт правильной подписи — timing-attack, медленный, но рабочий на длинных горизонтах. Правильное сравнение — в постоянном времени: в PHP hash_equals, в Python hmac.compare_digest, в Node crypto.timingSafeEqual, в Go hmac.Equal.

php
// github.com/BOTIX-pro/sdk-php/blob/v1.1.0/src/Webhook/Verifier.php
namespace BOTIX\SDK\Webhook;

class Verifier
{
    public static function verify(
        string $rawBody,
        string $signatureHeader,
        string $timestampHeader,
        string $secret,
        int $tolerance = 300 // секунд
    ): bool {
        // 1. Окно валидности timestamp (±5 минут по умолчанию)
        $now = time();
        $ts = (int) $timestampHeader;
        if (abs($now - $ts) > $tolerance) {
            return false;
        }

        // 2. Канонический payload = timestamp + "." + body
        $signedPayload = $timestampHeader . '.' . $rawBody;
        $expected = hash_hmac('sha256', $signedPayload, $secret);

        // 3. Сравнение в постоянном времени
        return hash_equals($expected, $signatureHeader);
    }
}

12 строк, три проверки в порядке возрастания стоимости: timestamp дешевле HMAC, HMAC дешевле обращения к кешу. Использование на стороне интегратора:

php
use BOTIX\SDK\Webhook\Verifier;

$rawBody = file_get_contents('php://input'); // КРИТИЧНО: raw, не json_decode
$sig = $_SERVER['HTTP_X_BOTIX_SIGNATURE'] ?? '';
$ts  = $_SERVER['HTTP_X_BOTIX_TIMESTAMP'] ?? '';

if (!Verifier::verify($rawBody, $sig, $ts, getenv('BOTIX_WEBHOOK_SECRET'))) {
    http_response_code(401);
    exit;
}

$event = json_decode($rawBody, true);
processEvent($event);

Helper принимает сырой body, не распаршенный JSON. Перепарсить и сериализовать обратно — порядок ключей меняется, подпись не сойдётся. В Express — express.raw({type: 'application/json'}), в Laravel — $request->getContent(), в Flask — request.get_data().

Один вызов во всех трёх SDK

Чтобы интегратор не помнил про timing-attack и raw body, во всех трёх официальных SDK есть helper Webhook::verify, скрывающий детали:

php
// PHP
use Botix\SDK\Webhook;

if (!Webhook::verify($secret, $rawBody, $signatureHeader)) {
    http_response_code(401);
    exit;
}
python
# Python
from botix import Webhook

if not Webhook.verify(secret, raw_body, signature_header):
    return Response(status=401)
javascript
// Node
const { Webhook } = require('@botix/sdk');

if (!Webhook.verify(secret, rawBody, signatureHeader)) {
  return res.status(401).end();
}

Внутри все три используют сравнение в постоянном времени. Если SDK у вас нет — проверьте, что в коде проверки стоит hash_equals / compare_digest / timingSafeEqual, а не ==. Это одна строка, которая отличает работающую защиту от иллюзии защиты.

Слой 3: replay-защита

HMAC отвечает только на вопрос «принадлежит ли сообщение владельцу секрета». Он не отвечает на «пришло сейчас или подобрано из старого лога». Атака replay: прокси, через который однажды прошёл webhook, сохранил подписанное тело и заголовки; злоумышленник берёт уже подписанный запрос и отправляет повторно. Подпись сходится, бизнес-логика выполняется второй раз. Если webhook обрабатывает оплату, бонусы или изменение прав — последствия серьёзные. HMAC здесь бессилен: это функция без понятия времени и без памяти, один и тот же вход всегда даёт один и тот же выход. Защита лежит рядом с подписью — подписанный timestamp и проверка уникальности на приёме. Оба ингредиента нужны одновременно.

3.1. Timestamp в подписи

К webhook добавляется timestamp — время отправки события, включённое в строку HMAC. Получатель сравнивает timestamp с текущим временем, окно ±5 минут (симметрично: из будущего так же опасно, как из прошлого). Этого хватает на сетевые задержки, retry-каскады и расхождение часов между серверами. Критично — timestamp должен быть частью подписи. Если подписывать только body, а timestamp передавать отдельным заголовком вне HMAC, злоумышленник подменит timestamp на свежий, и атака пройдёт.

signed_payload = timestamp + "." + raw_body
expected_sig = HMAC-SHA256(secret, signed_payload)

Формат, аналогичный Stripe.

3.2. Idempotency приёма через event.id

Timestamp-окно отрезает replay через час; в пределах пяти минут replay возможен. Второй слой: получатель помнит, какие события обрабатывал. Каждое событие содержит event.id в формате evt_ + 24 символа, уникальный во всей платформе и не повторяющийся при retry. Если в сообщении есть уникальный идентификатор — а у осмысленного события он есть, — это и есть готовый nonce, который не выдумывают, а используют существующий. Достаточно хранить обработанные event.id за интервал в полтора-два раза больше окна свежести подписи — десять-пятнадцать минут. Это не журнал событий, а короткий кеш для отбрасывания дубликатов.

python
# Полная проверка webhook на стороне Python-получателя
import os, redis, hmac, hashlib
from botix import Webhook  # PyPI: pip install botix==1.1.0

r = redis.from_url(os.environ["REDIS_URL"])

def handle_webhook(raw_body: bytes, sig: str, ts: str, event_id: str) -> tuple[int, str]:
    # Слой 2 + 3.1: HMAC + timestamp
    if not Webhook.verify(
        payload=raw_body,
        signature=sig,
        timestamp=ts,
        secret=XXXXXXXX["BOTIX_WEBHOOK_SECRET"],
        tolerance=300,
    ):
        return 401, "invalid signature or expired"

    # Слой 3.2: idempotency приёма
    key = f"webhook:processed:{event_id}"
    if not r.set(key, "1", ex=900, nx=True):
        return 200, "already processed"

    process_event(raw_body)
    return 200, "ok"

Команда SET … NX EX 900 атомарна. Конкурентные запросы с одним event.id увидят занятый ключ и пройдут мимо. event.id в webhook и Idempotency-Key в Public API — два разных поля разной природы: в Public API ключ генерирует клиент при запросе *в сторону* API, в webhook инициатор обратный (поставщик), и nonce приходит от него. Но логика приёмки одинакова: «уже обрабатывал — не делай повторно».

3.3. Что обычно ломается на практике

Типичных провалов три:

  1. Забывают про timestamp вообще — и обнаруживают проблему, только когда её начинают эксплуатировать.
  2. Проверяют timestamp, но не включают его в подписываемую строку — злоумышленник подменяет timestamp на свежий, HMAC сходится, атака проходит.
  3. Проверяют event.id через SELECT, потом INSERT без транзакции и без ON CONFLICT — при двух одновременных webhook обрабатывают сообщение дважды. Атомарный SET NX EX из примера выше это закрывает.

Раздел «Webhook signatures» на dev-портале описывает все три ошибки явно. SDK закрывает первые две; идемпотентность приёма — обязанность получателя, потому что только он знает, какое хранилище у него под кеш.

Ротация секрета webhook

Простая замена секрета оставляет окно: пока клиент не обновил секрет, ему приходят webhook со старой подписью — и эти события отклоняются, теряются. Правильный механизм — окно ротации: платформа поддерживает два активных секрета, webhook подписываются новым, проверка на клиенте принимает оба. У нас при перевыпуске старый сохраняется со статусом revoked_at = NOW() + INTERVAL 24 HOUR и по истечении физически удаляется. Это даёт разработчику сутки на спокойное обновление.

Чего HMAC не делает

  • Не предотвращает replay сам по себе — только timestamp + nonce закрывают это (слой 3).
  • Не шифрует тело — HMAC подтверждает целостность и происхождение, не конфиденциальность. Для секретного содержимого — HTTPS.
  • Не подтверждает доставку — webhook может быть подписан, но не дойти из-за сетевого сбоя. Надёжность доставки — отдельная задача (retry, очереди, dead-letter); о rate-limit-заголовках и backoff — Rate-limit заголовки, backoff и защита от штормов.

Защита публичного API — три независимых механизма для трёх классов проблем. Подпись не заменяет timestamp, timestamp не заменяет idempotency. Production-приёмник включает все три в этом порядке: дешёвая проверка timestamp перед дорогой HMAC, и обе — перед обращением в кеш обработанных событий.