Как проверять подписи webhook в crypto callbacks: HMAC, replay protection и ротация секретов

В crypto payments webhook почти никогда не является просто «входящим POST-запросом».

Это точка, через которую в систему попадают события о депозитах, смене статусов invoice, подтверждениях транзакций, payout updates и ошибках провайдера. Если проверка подписи сделана слабо, атакующий может подделать callback, переиграть старое событие или заставить систему принять чужое состояние как настоящее.

Поэтому нормальная защита webhook в crypto API — это не одна строчка if signature == expected, а целый небольшой контур доверия: канонизация payload, HMAC-подпись, проверка времени, защита от replay, идемпотентная обработка и аккуратная ротация секретов.

Ниже — практический вариант, который обычно хорошо работает для Sassoft-подобных API.

Что именно нужно защищать

В типовой crypto/payment интеграции webhook может менять вполне материальные вещи:

  • переводить invoice в paid или confirmed
  • создавать внутреннее событие о депозите
  • запускать crediting на баланс клиента
  • переводить payout в broadcasted или failed
  • инициировать внешнее уведомление клиенту

То есть ошибка здесь — это не просто security issue на бумаге. Это риск:

  • ложного зачисления
  • ложного списания
  • двойной обработки события
  • ручных разборов с клиентами и finance
  • потери доверия к статусам в системе

Какие ошибки встречаются чаще всего

На практике чаще всего ломаются не криптографические алгоритмы, а инженерная дисциплина вокруг них.

Типичные проблемы:

  1. подпись считается не от raw body, а от уже распарсенного JSON
  2. таймстемп не проверяется вообще
  3. один и тот же callback можно проиграть повторно через минуту, час или день
  4. секреты хранятся без версии, поэтому их больно ротировать
  5. endpoint сначала делает бизнес-действие, а потом уже проверяет подпись
  6. успешный ответ 200 OK возвращается только после тяжёлой downstream-логики, из-за чего провайдер начинает ретраить

Почти все реальные инциденты — комбинация двух-трёх пунктов из этого списка.

Базовая модель безопасного webhook

Нормальный pipeline выглядит так:

receive request -> preserve raw body -> extract timestamp/signature -> verify HMAC -> reject stale request -> check replay cache -> persist event -> ack fast -> process asynchronously

Ключевая идея очень простая:

  • сначала подтвердить, что запрос аутентичен
  • потом зафиксировать факт приёма события
  • только потом делать бизнес-обработку

И желательно не смешивать эти этапы в один монолитный handler.

Минимальный контракт webhook, который стоит ввести

Если вы проектируете свой callback-протокол или можете нормализовать интеграции поверх разных провайдеров, полезно требовать следующие поля:

  • X-Signature — сама подпись
  • X-Timestamp — Unix timestamp или ISO8601
  • X-Webhook-Id — уникальный идентификатор доставки
  • X-Key-Id — идентификатор секрета или версии ключа

Почему это удобно:

  • подпись проверяется независимо от бизнес-полей payload
  • timestamp позволяет резать старые запросы
  • webhook id даёт защиту от replay и повторной доставки
  • key id делает ротацию секретов предсказуемой

Если провайдер не даёт такой контракт, его всё равно можно адаптировать во внутренний нормализованный формат после валидации внешних заголовков.

Почему подпись надо считать именно от raw body

Очень частая ошибка — сначала распарсить JSON, а потом подписывать сериализованный объект заново.

Проблема в том, что JSON не гарантирует один-единственный способ представления:

  • порядок ключей может измениться
  • пробелы и переносы строк могут исчезнуть
  • числа и строки иногда нормализуются по-разному
  • escaping Unicode может поехать

В результате провайдер подписал одно байтовое представление, а вы проверяете другое.

Поэтому правило простое:

HMAC считается от исходного raw body, в том виде, в котором он приехал по сети.

Практическая схема HMAC-проверки

Обычно хватает конструкции вроде:

signature = HMAC_SHA256(secret, timestamp + "." + raw_body)

Почему timestamp лучше включать в подписываемую строку:

  • нельзя отдельно подменить время запроса
  • проще защищаться от replay
  • проще логировать и дебажить проверку

Пример на Go:

package webhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "fmt"
)

func ExpectedSignature(secret string, timestamp string, rawBody []byte) string {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(timestamp))
    mac.Write([]byte("."))
    mac.Write(rawBody)
    return hex.EncodeToString(mac.Sum(nil))
}

func VerifySignature(secret string, timestamp string, rawBody []byte, got string) bool {
    expected := ExpectedSignature(secret, timestamp, rawBody)
    return subtle.ConstantTimeCompare([]byte(expected), []byte(got)) == 1
}

func SignedPayload(timestamp string, rawBody []byte) string {
    return fmt.Sprintf("%s.%s", timestamp, string(rawBody))
}

Здесь важны две вещи:

  • используется ConstantTimeCompare, а не обычное ==
  • подпись строится поверх timestamp и исходного тела запроса

Валидация времени: дешёвая защита, которую часто забывают

Даже корректно подписанный запрос не должен приниматься бесконечно долго.

Если атакующий получил старый валидный webhook, он может попытаться воспроизвести его ещё раз. Поэтому timestamp надо проверять на допустимое окно.

Практичный baseline:

  • допуск по времени: ±5 минут для нормальных webhook
  • для чувствительных payout-событий — иногда даже ±2 минуты

Пример логики:

package webhook

import (
    "errors"
    "strconv"
    "time"
)

func ValidateTimestamp(ts string, maxSkew time.Duration) error {
    unixTs, err := strconv.ParseInt(ts, 10, 64)
    if err != nil {
        return errors.New("invalid timestamp")
    }

    now := time.Now().UTC()
    requestTime := time.Unix(unixTs, 0).UTC()

    if requestTime.Before(now.Add(-maxSkew)) || requestTime.After(now.Add(maxSkew)) {
        return errors.New("timestamp outside allowed window")
    }

    return nil
}

Проверка времени сама по себе не решает replay problem полностью, но очень сильно сужает окно атаки.

Replay protection: без неё webhook всё ещё уязвим

Даже если подпись и timestamp валидны, запрос может быть воспроизведён повторно в допустимом временном окне.

Поэтому нужен отдельный слой replay protection.

Обычно для этого хранят один из ключей:

  • webhook_id
  • provider_event_id
  • или хэш вида provider + timestamp + signature

Что делать на практике:

  1. после успешной проверки подписи формировать replay key
  2. проверять, не был ли он уже обработан
  3. если был — вернуть безопасный success/duplicate response без повторной бизнес-логики
  4. если нет — сохранить key с TTL

Варианты storage:

  • Redis с TTL — удобно для скорости
  • PostgreSQL с unique index — удобно для сильной гарантии
  • комбинация Redis + БД — удобно для нагруженных систем

Для crypto API это особенно важно, потому что один replay может привести к повторному crediting или повторной отправке клиентского callback.

Подпись webhook — это не замена идемпотентности

Это важный момент, на котором часто спотыкаются.

  • signature verification отвечает на вопрос: «это правда отправил наш провайдер?»
  • idempotency отвечает на вопрос: «если это событие пришло повторно, сломаем ли мы бизнес-логику?»

Нужны оба слоя.

Даже легитимный провайдер может прислать один и тот же callback несколько раз:

  • из-за сетевого таймаута
  • из-за слишком медленного ответа вашего endpoint
  • из-за собственных retry-правил
  • из-за повторной доставки после failover

Поэтому после security-проверки событие всё равно должно проходить через idempotent handler.

Как отвечать провайдеру правильно

Плохой путь:

  • проверить подпись
  • сходить в пять внутренних сервисов
  • записать десять таблиц
  • дождаться очереди
  • отправить клиенту уведомление
  • только потом вернуть 200 OK

Такой endpoint сам провоцирует повторы и дубли.

Более здоровый путь:

  1. принять raw body
  2. проверить подпись и timestamp
  3. проверить replay key
  4. быстро записать событие в durable storage / inbox table
  5. вернуть 200 OK
  6. дальше обрабатывать через очередь или worker

То есть webhook endpoint должен быть коротким и дешёвым. Тяжёлая бизнес-логика — уже после ack.

Что логировать для расследований

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

Минимум, который стоит логировать:

  • provider
  • webhook_id
  • key_id
  • timestamp
  • request_received_at
  • signature_prefix (не полную подпись)
  • verification_result
  • reject_reason
  • remote_addr
  • trace_id

Важно: не надо писать в логи полный secret и полный raw payload, если в нём есть чувствительные данные. Лучше логировать digest, длину body и технические идентификаторы.

Безопасная ротация секретов

Самая частая operational проблема — секрет надо поменять, а система умеет жить только с одним значением.

Из-за этого команды либо откладывают ротацию, либо устраивают risky cutover.

Нормальная схема:

  • у секрета есть key_id или версия
  • верификация умеет проверять текущий и предыдущий ключ
  • старый ключ живёт в grace period
  • после завершения миграции старый ключ удаляется

Практически это выглядит так:

  1. создаёте новый secret и присваиваете key_id=v2
  2. просите провайдера начать подписывать новым ключом
  3. ваш endpoint принимает v1 и v2
  4. метрики показывают, что трафик на v1 ушёл в ноль
  5. отключаете v1

Это гораздо надёжнее, чем мгновенно подменять один секрет другим.

Нужны ли IP allowlists

Как дополнительный слой — да.

Как основной механизм доверия — нет.

Почему allowlist полезен:

  • режет шум и мусорный трафик
  • помогает быстрее отсекать случайные запросы
  • даёт дополнительный сигнал при странной активности

Почему его недостаточно:

  • адреса провайдеров могут меняться
  • при ошибке сети возможны неожиданные маршруты
  • IP не подтверждает целостность payload
  • внутри облачной инфраструктуры IP-уровень вообще может быть слабым сигналом

Вывод простой: IP allowlist может дополнять подпись, но не заменяет её.

Что стоит мониторить

Если webhook security сделан серьёзно, его тоже надо наблюдать, а не просто надеяться.

Полезные метрики:

  • webhook_requests_total{provider}
  • webhook_signature_failures_total{provider,reason}
  • webhook_timestamp_reject_total{provider}
  • webhook_replay_reject_total{provider}
  • webhook_verified_total{provider,key_id}
  • webhook_ack_duration_seconds{provider}

Эти метрики помогают быстро понять:

  • провайдер реально присылает мусор
  • у вас сломалась канонизация payload
  • серверы разъехались по времени
  • после ротации кто-то всё ещё шлёт старым ключом
  • endpoint начал отвечать слишком медленно

Практический handler целиком

Ниже — упрощённый каркас production-friendly webhook handler на Go.

func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
    rawBody, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "bad request", http.StatusBadRequest)
        return
    }

    timestamp := r.Header.Get("X-Timestamp")
    signature := r.Header.Get("X-Signature")
    keyID := r.Header.Get("X-Key-Id")
    webhookID := r.Header.Get("X-Webhook-Id")

    secret, ok := h.SecretStore.Get(keyID)
    if !ok {
        http.Error(w, "unauthorized", http.StatusUnauthorized)
        return
    }

    if err := ValidateTimestamp(timestamp, 5*time.Minute); err != nil {
        http.Error(w, "stale request", http.StatusUnauthorized)
        return
    }

    if !VerifySignature(secret, timestamp, rawBody, signature) {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }

    replayKey := fmt.Sprintf("%s:%s", keyID, webhookID)
    if h.ReplayStore.Seen(replayKey) {
        w.WriteHeader(http.StatusOK)
        _, _ = w.Write([]byte("duplicate"))
        return
    }

    if err := h.ReplayStore.Mark(replayKey, 15*time.Minute); err != nil {
        http.Error(w, "temporary error", http.StatusInternalServerError)
        return
    }

    if err := h.InboxStore.Save(rawBody, timestamp, signature, webhookID, keyID); err != nil {
        http.Error(w, "temporary error", http.StatusInternalServerError)
        return
    }

    w.WriteHeader(http.StatusOK)
    _, _ = w.Write([]byte("ok"))

    h.Queue.Publish(ProcessWebhookJob{WebhookID: webhookID})
}

Это не «полный production код», но структура правильная:

  • raw body читается сразу
  • подпись проверяется до бизнес-логики
  • timestamp валидируется
  • replay режется отдельно
  • событие фиксируется в durable storage
  • ответ наружу уходит быстро

Где здесь место для automation

Когда у команды webhook layer сделан правильно, на него уже можно навешивать полезную автоматизацию:

  • автоалерт, если резко вырос invalid signature rate
  • автоалерт, если после ротации старый key_id ещё используется
  • автоматический quarantine mode для провайдера с подозрительным replay burst
  • авто-reconciliation для событий, которые были приняты, но не дошли до final state
  • периодическая проверка clock skew между инстансами

Это как раз тот случай, где security и operations очень плотно связаны.

Стартовый чеклист для Sassoft-подобного webhook endpoint

Если нужен короткий практический baseline, я бы проверял по пунктам:

  1. подпись считается от raw body, а не от распарсенного JSON
  2. в подпись включён timestamp
  3. сравнение делается constant-time методом
  4. есть допустимое окно времени для запроса
  5. есть replay protection по webhook_id или эквиваленту
  6. endpoint быстро подтверждает приём и не тянет тяжёлую логику в синхронный path
  7. события дальше обрабатываются идемпотентно
  8. секреты имеют версию и поддерживают безопасную ротацию
  9. есть метрики по подписи, replay и ack latency
  10. логов хватает, чтобы расследовать отказ за минуты, а не за полдня

Вывод

Защита webhook в crypto API — это не «добавить HMAC и закрыть тикет».

Надёжный callback endpoint строится из нескольких слоёв: проверка подписи, timestamp, replay protection, быстрая фиксация события, идемпотентная downstream-обработка и безопасная ротация секретов.

Когда эти слои есть вместе, webhook перестаёт быть хрупкой дырой в платёжной системе и становится нормальной управляемой точкой входа. А для production crypto/payments это уже не nice-to-have, а базовая инженерная гигиена.