Разработка/ PHP/ Быстрые решения

PHP: проверка HMAC-подписи webhook без SDK

Webhook нельзя проверять подписью уже разобранного JSON: повторная сериализация меняет пробелы, порядок полей и результат HMAC. Подписывать и проверять нужно исходную строку запроса.

Контракт запроса

Для своего webhook достаточно трёх заголовков:

  • X-Webhook-Timestamp — Unix time в секундах;
  • X-Webhook-Signature — строка sha256=<hex>;
  • X-Webhook-Id — уникальный идентификатор события для защиты от дублей.

Строка для подписи собирается как timestamp.body. Timestamp ограничивает срок жизни запроса, а ID не даёт выполнить одно событие дважды внутри этого окна.

Проверка подписи

<?php

final class WebhookVerifier
{
    public function verify(
        string $rawBody,
        string $timestamp,
        string $signature,
        string $secret,
        ?int $now = null,
    ): bool {
        if (!ctype_digit($timestamp) || $secret === '') {
            return false;
        }

        $now ??= time();

        if (abs($now - (int) $timestamp) > 300) {
            return false;
        }

        if (!str_starts_with($signature, 'sha256=')) {
            return false;
        }

        $expected = 'sha256='.hash_hmac(
            'sha256',
            $timestamp.'.'.$rawBody,
            $secret,
        );

        return hash_equals($expected, $signature);
    }
}

hash_equals() нужен вместо обычного ===: он сравнивает строки за постоянное время и не раскрывает подпись по разнице во времени ответа.

Порядок в endpoint

$rawBody = file_get_contents('php://input') ?: '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$eventId = $_SERVER['HTTP_X_WEBHOOK_ID'] ?? '';

if (!$verifier->verify($rawBody, $timestamp, $signature, $secret)) {
    http_response_code(401);
    exit;
}

if ($eventId === '') {
    http_response_code(400);
    exit;
}

$payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
$handled = $eventStore->transaction(function () use (
    $eventStore,
    $handler,
    $eventId,
    $payload,
): bool {
    if (!$eventStore->claim($eventId)) {
        return false;
    }

    $handler->handle($payload);

    return true;
});

http_response_code($handled ? 200 : 204);

Проверка HMAC идёт до json_decode(). Метод claim() делает INSERT в таблицу с уникальным индексом по event ID и возвращает false при конфликте. Обычный SELECT перед INSERT не защищает от двух одновременных запросов.

Повтор после ошибки

Claim и локальные изменения выполняются в одной транзакции: exception откатывает оба. Необратимый внешний эффект отправляется через outbox и тоже получает event ID как ключ идемпотентности.

Как формируется подпись

$body = json_encode($event, JSON_THROW_ON_ERROR);
$timestamp = (string) time();
$signature = 'sha256='.hash_hmac(
    'sha256',
    $timestamp.'.'.$body,
    $secret,
);

// Отправить ровно $body, без повторного json_encode().

Секрет хранится в переменной окружения или secret storage. Его нельзя класть в payload, URL, exception message и access log.

Три обязательных теста

$body = '{"event":"invoice.paid","id":42}';
$timestamp = '1780992000';
$secret = 'test-secret';
$signature = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$body, $secret);

self::assertTrue(
    $verifier->verify($body, $timestamp, $signature, $secret, 1780992000),
);
self::assertFalse(
    $verifier->verify($body.' ', $timestamp, $signature, $secret, 1780992000),
);
self::assertFalse(
    $verifier->verify($body, $timestamp, $signature, $secret, 1780992301),
);

Первый тест фиксирует рабочую подпись, второй ловит изменение body даже на один байт, третий проверяет окно в пять минут.

Теги

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