Подпись webhook и callback в crypto API: как защитить интеграцию без ложного чувства безопасности

В crypto и payments-интеграциях почти каждая команда довольно быстро доходит до webhook или callback-модели.

Провайдер присылает событие о депозите, изменении статуса invoice, выплате, KYC-результате или внутреннем wallet event. На раннем этапе всё выглядит просто: подняли endpoint, приняли JSON, обработали статус, ответили 200 OK.

Но как только система начинает работать на реальных деньгах, возникает неприятный вопрос: откуда backend знает, что входящий callback действительно отправил доверенный источник, а не кто угодно из интернета?

Именно здесь начинается тема подписи webhook. И именно здесь многие интеграции делают опасную ошибку: добавляют “какую-нибудь проверку”, которая выглядит убедительно в коде, но почти не снижает реальный риск.

Ниже — практический разбор, как делать проверку webhook signatures в crypto API так, чтобы она действительно защищала pipeline, а не создавала ложное чувство безопасности.

Почему IP allowlist и secret в query string — слабая защита

Когда команда спешит запустить интеграцию, обычно пробуют один из таких вариантов:

  • проверять только IP источника;
  • добавлять секрет в URL вроде /callback?token=...;
  • доверять Basic Auth без отдельной подписи тела;
  • считать, что раз endpoint “сложно угадать”, этого достаточно.

Проблема в том, что для платежных и crypto callbacks этого мало.

Что может пойти не так

  1. IP источника меняется — особенно если провайдер использует CDN, несколько регионов или облачную маршрутизацию.
  2. Секрет в query string утекает в логи reverse proxy, APM, error trackers и access logs.
  3. Basic Auth не защищает целостность payload — вы знаете, кто открыл соединение, но не проверяете, что тело запроса не было подменено на промежуточном участке.
  4. “Скрытый URL” не является контролем безопасности вообще.

Если webhook влияет на баланс, статус invoice, активацию payout или изменение ledger, нужна защита именно уровня аутентичности и целостности сообщения.

Что на самом деле должна гарантировать подпись callback

Нормальная схема подписи должна отвечать на три вопроса:

  1. Отправитель подлинный?
  2. Тело не было изменено по дороге?
  3. Старый запрос нельзя безопасно воспроизвести повторно как новый?

Иными словами, подпись должна закрывать три класса угроз:

  • spoofing;
  • tampering;
  • replay.

Очень важно: HMAC сам по себе обычно закрывает только первые два пункта. Для replay protection нужны ещё timestamp, tolerance window и idempotency/dedup layer.

Практически рабочая схема

Если говорить без лишней магии, самый удобный и надёжный baseline для webhook callbacks — это:

  • общий secret на интеграцию;
  • подпись через HMAC-SHA256;
  • подпись считается по сырому request body;
  • в headers передаются timestamp, signature, иногда event-id;
  • сервер проверяет:
    • допустимость времени;
    • корректность HMAC;
    • отсутствие replay/duplicate по event id.

Пример набора заголовков:

  • X-Callback-Timestamp: 1712474400
  • X-Callback-Signature: sha256=<hex>
  • X-Event-Id: evt_01HT...

А canonical string для подписи может выглядеть так:

<timestamp>.<raw_body>

Это лучше, чем подписывать только body, потому что timestamp становится частью защищённого материала и не может быть подменён без поломки подписи.

Ошибка №1: подписывать уже распарсенный JSON

Это одна из самых частых и самых дорогих ошибок.

Команда получает JSON, десериализует его в объект, потом сериализует обратно и уже эту строку подписывает или сравнивает. Кажется логичным, но на практике это ломает верификацию.

Почему:

  • JSON допускает разный порядок полей;
  • пробелы, переносы строк и форматирование могут отличаться;
  • числа и строки иногда сериализуются по-разному в разных языках и SDK;
  • некоторые middleware меняют body ещё до вашей бизнес-логики.

Подпись нужно считать по raw body bytes ровно в том виде, в каком их прислал провайдер.

Если в приложении есть middleware, которое читает body раньше, надо либо сохранить сырой буфер, либо использовать обработчик, который явно забирает raw bytes до любого JSON parsing.

Ошибка №2: обычное сравнение строк вместо constant-time compare

Даже когда HMAC считается правильно, иногда дальше делают:

if provided == expected {
    // ok
}

Для внутренних API это часто проходит незаметно, но для security-sensitive callback endpoint лучше использовать constant-time compare. Это базовая гигиена, которая снижает риск timing side-channel на проверке подписи.

В Go это обычно hmac.Equal, в Node.js — crypto.timingSafeEqual.

Ошибка №3: нет защиты от replay

Допустим, злоумышленник или промежуточная система каким-то образом получила валидный webhook целиком: headers, timestamp, body, signature.

Если backend проверяет только HMAC и больше ничего, такой запрос можно воспроизвести повторно. И если downstream-логика не идемпотентна, последствия будут неприятными:

  • повторный crediting депозита;
  • повторная смена статуса;
  • повторная постановка payout job;
  • лишние уведомления клиенту;
  • путаница в audit trail.

Поэтому кроме HMAC нужны ещё два слоя.

1. Timestamp window

Например, принимать только запросы не старше 5 минут.

Если abs(now - timestamp) > 300, запрос отклоняется.

2. Event deduplication

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

На практике это означает:

  • хранить event_id / external_event_id;
  • помечать обработку как idempotent;
  • разделять accepted duplicate и new event;
  • не делать финансовый side effect дважды.

Подпись не отменяет идемпотентность. Они работают вместе.

Базовый flow проверки на сервере

Хороший серверный обработчик обычно выглядит так:

  1. Считать raw body.
  2. Достать timestamp, signature, event_id из headers.
  3. Проверить, что обязательные headers присутствуют.
  4. Проверить формат timestamp.
  5. Проверить time window.
  6. Посчитать expected_hmac(secret, timestamp + "." + raw_body).
  7. Сравнить подписи constant-time способом.
  8. Проверить replay/dedup по event_id.
  9. Только после этого передать событие в бизнес-логику.
  10. Зафиксировать результат: accepted, duplicate, invalid_signature, expired, missing_headers.

Очень важно, что проверка подписи должна происходить до любой финансовой или статусной логики.

Практический пример на Go

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

package webhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "strconv"
    "strings"
    "time"
)

func VerifyCallback(secret string, r *http.Request, now time.Time) ([]byte, error) {
    rawBody, err := io.ReadAll(r.Body)
    if err != nil {
        return nil, ErrReadBody
    }

    tsHeader := r.Header.Get("X-Callback-Timestamp")
    sigHeader := r.Header.Get("X-Callback-Signature")
    if tsHeader == "" || sigHeader == "" {
        return nil, ErrMissingHeaders
    }

    tsUnix, err := strconv.ParseInt(tsHeader, 10, 64)
    if err != nil {
        return nil, ErrInvalidTimestamp
    }

    ts := time.Unix(tsUnix, 0)
    if now.Sub(ts) > 5*time.Minute || ts.Sub(now) > 5*time.Minute {
        return nil, ErrExpiredSignature
    }

    payload := tsHeader + "." + string(rawBody)
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(payload))
    expected := mac.Sum(nil)

    providedHex := strings.TrimPrefix(sigHeader, "sha256=")
    provided, err := hex.DecodeString(providedHex)
    if err != nil {
        return nil, ErrInvalidSignature
    }

    if !hmac.Equal(provided, expected) {
        return nil, ErrInvalidSignature
    }

    return rawBody, nil
}

Это не вся интеграция, но правильный security baseline уже здесь есть:

  • raw body;
  • timestamp;
  • HMAC-SHA256;
  • constant-time compare;
  • tolerance window.

Дальше поверх этого нужен idempotency store по event_id.

Где хранить dedup state

В production лучший выбор обычно зависит от архитектуры.

Вариант 1. PostgreSQL

Подходит, если callback volume умеренный и бизнес-логика уже сидит в Postgres.

Пример подхода:

  • таблица callback_events;
  • уникальный индекс на (provider, external_event_id);
  • вставка через INSERT ... ON CONFLICT DO NOTHING;
  • если conflict есть — событие уже было.

Это простой и понятный путь.

Вариант 2. Redis

Подходит, если нужен быстрый TTL-based dedup и очень дешёвая запись на горячем ingress.

Например:

  • key: cb:{provider}:{event_id}
  • SET key 1 NX EX 86400

Если SET NX не прошёл — событие уже видели.

Но для финансовых операций Redis-only dedup не всегда достаточно как единственный слой, потому что TTL и volatile memory не заменяют audit trail.

Вариант 3. Комбинация ingress cache + persistent journal

Хороший production-компромисс:

  • Redis/queue layer для быстрого первичного dedup;
  • Postgres journal для долговременной трассировки и расследований.

Что логировать при проверке webhook signature

Если security-проверка не даёт нормального operational сигнала, расследования будут мучительными.

Полезно логировать и метрики, и структурированные поля.

В логах

  • provider
  • event_id
  • signature_status
  • timestamp_skew_seconds
  • request_id
  • source_ip
  • endpoint
  • dedup_hit=true|false

В метриках

  • callback_verify_total{provider,result}
  • callback_signature_failures_total{provider,reason}
  • callback_replay_rejected_total{provider}
  • callback_timestamp_skew_seconds
  • callback_duplicate_total{provider}

Тогда команда видит не просто “что-то ломается”, а конкретно:

  • провайдер шлёт некорректные подписи;
  • у вас поехали часы;
  • кто-то льёт мусор на endpoint;
  • вырос duplicate/replay rate.

Clock skew — недооценённая причина ложных отказов

Если вы включили timestamp window, но не следите за временем на хостах, можно случайно устроить себе outage на ровном месте.

Типичный сценарий:

  • webhook подписан корректно;
  • провайдер отправил его вовремя;
  • но ваш сервер отстаёт или спешит на несколько минут;
  • валидные callbacks начинают падать как expired.

Поэтому для callback security важно не только приложение, но и инфраструктурная гигиена:

  • NTP / chrony должен быть исправен;
  • drift времени должен мониториться;
  • tolerance window должен быть реалистичным, а не 15 секунд “из соображений строгости”.

В большинстве production-интеграций окно 3–5 минут — нормальный компромисс.

Нужно ли шифровать сам payload

Обычно — нет.

Для webhook callbacks почти всегда достаточно:

  • HTTPS/TLS для транспорта;
  • HMAC для аутентичности и целостности;
  • replay protection;
  • idempotent processing.

Шифрование payload поверх TLS имеет смысл только в специфичных регуляторных или high-sensitivity сценариях. В типичном crypto/payments API основная проблема не в том, что кто-то увидит JSON, а в том, что кто-то попытается подделать или повторно воспроизвести событие.

Как делать secret rotation без простоя

Ещё одна практическая тема, которую часто забывают до первого инцидента.

Если у вас один активный secret и жёсткая замена “в моменте”, интеграция легко ломается во время ротации.

Рабочая схема обычно такая:

  1. backend поддерживает current secret и previous secret;
  2. при проверке подписи пытается сначала current, потом previous;
  3. провайдер переключается на новый secret;
  4. после переходного окна previous удаляется.

Для крупных интеграций полезно ещё вести secret_version, чтобы видеть, на каком ключе пришёл запрос.

Что делать, если провайдер не поддерживает подписи вообще

Такое всё ещё встречается.

Если повлиять на формат нельзя, тогда минимальный practical fallback:

  • HTTPS обязателен;
  • mTLS — если реально возможно;
  • жёсткий IP allowlist как дополнительный, но не единственный слой;
  • отдельный secret header хотя бы для аутентификации клиента;
  • очень строгая идемпотентность и reconciliation downstream;
  • отдельный alerting на аномалии callback traffic.

Это хуже, чем полноценная подпись, но лучше, чем просто публичный endpoint без контроля.

Минимальный production checklist

Если нужен короткий, но рабочий checklist для Sassoft-подобного callback ingress, я бы фиксировал такой baseline:

  • Проверка идёт по raw request body
  • В подпись включён timestamp
  • Используется HMAC-SHA256
  • Сравнение подписи constant-time
  • Есть tolerance window на replay
  • Есть dedup/idempotency по event_id
  • Проверка происходит до business side effects
  • Есть structured logs и метрики по причинам отказа
  • Есть secret rotation без жёсткого cutover
  • Мониторится clock skew и duplicate rate

Если хотя бы половины этого нет, интеграция обычно ещё не production-grade.

Вывод

Webhook signature в crypto API — это не декоративная галочка в integration checklist. Это кусок реального control plane, который защищает денежный pipeline от подделок, подмены и опасных повторов.

Сильная схема не ограничивается “посчитать HMAC”. Она объединяет:

  • raw body verification;
  • timestamp-based replay protection;
  • idempotent event processing;
  • нормальную observability;
  • безопасную ротацию secret.

Именно такая комбинация делает callback ingress не просто рабочим, а надёжным для production-нагрузки и реальных денег.