Newer
Older
gn-synapse-client-php / src / Webhook / WebhookVerifier.php
<?php

declare(strict_types=1);

namespace GNexus\Synapse\Webhook;

use GNexus\Synapse\Exception\InvalidWebhookException;

/**
 * Приём s2s-доставки: проверка подписи вебхука Synapse.
 *
 * Одна схема подписи на всю экосистему (docs/05 → «Доставка s2s»), зеркало
 * gnexus-auth `WebhookSignature.php`:
 *
 *   sig = "t=<unix>,v1=" + hash_hmac('sha256', "<t>.<raw_body>", secret)
 *
 * verify() — одна точка входа принимающего сервиса: проверка
 * (константное сравнение, окно свежести против replay) + парс конверта.
 * Тело — байт в байт то, что пришло в сеть ($request->getContent()),
 * до любого парсинга.
 *
 * Использование (Laravel-приёмник Navi):
 *
 *   public function synapseWebhook(Request $r): JsonResponse
 *   {
 *       $envelope = WebhookVerifier::verify($r->getContent(), $r->headers->all(), env('S2S_SECRET_X'));
 *       // $envelope['event_id'], $envelope['subject'], ...
 *       return response()->json(['received' => true]);
 *   }
 */
final class WebhookVerifier
{
    public const SIGNATURE_HEADER = 'x-gnexus-signature';
    public const DEFAULT_MAX_SKEW = 300; // секунд (зеркало gnexus-auth)

    /** private __construct: только статика. */
    private function __construct()
    {
    }

    /** Подпись «сырое» тело (для тестов и локального повторения подписи Synapse). */
    public static function makeSignature(string $rawBody, string $secret, ?int $timestamp = null): string
    {
        $timestamp ??= time();
        $digest = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

        return "t={$timestamp},v1={$digest}";
    }

    /**
     * Проверить подпись и распарсить доставку; возвращает конверт (assoc-array).
     *
     * @param array<string, string|array<string>> $headers произвольный массив заголовков
     *        (Laravel Request::headers->all() подходит — значения могут быть списками)
     *
     * @return array<string, mixed>
     */
    public static function verify(
        string $rawBody,
        array $headers,
        string $secret,
        int $maxSkew = self::DEFAULT_MAX_SKEW,
        ?int $now = null,
    ): array {
        $supplied = self::headerValue($headers, self::SIGNATURE_HEADER);
        $timestamp = null;
        $digest = '';
        foreach (explode(',', $supplied) as $chunk) {
            [$name, $value] = array_pad(explode('=', $chunk, 2), 2, '');
            $name = strtolower(trim($name));
            if ($name === 't') {
                $timestamp = filter_var(trim($value), FILTER_VALIDATE_INT, ['options' => ['default' => null]]);
            } elseif ($name === 'v1') {
                $digest = trim($value);
            }
        }
        if ($timestamp === null || $digest === '') {
            throw new InvalidWebhookException('нет или битый заголовок X-Gnexus-Signature');
        }

        $current = $now ?? time();
        $skew = abs($current - $timestamp);
        if ($skew > $maxSkew) {
            throw new InvalidWebhookException("тело не свежее: отклонение {$skew} с > {$maxSkew} с (replay?)");
        }

        $expected = self::makeSignature($rawBody, $secret, $timestamp);
        [, $expectedDigest] = explode('v1=', $expected, 2);
        if (! hash_equals($expectedDigest, $digest)) {
            throw new InvalidWebhookException('подпись не совпала — чужой секрет или тело искажено');
        }

        try {
            $envelope = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
        } catch (\JsonException $ex) {
            throw new InvalidWebhookException('тело не JSON — Synapse шлёт конверт: ' . $ex->getMessage());
        }
        if (! is_array($envelope) || array_is_list($envelope)) {
            throw new InvalidWebhookException('тело — не JSON-объект конверта');
        }

        return $envelope;
    }

    /** @param array<string, string|array<string>> $headers */
    private static function headerValue(array $headers, string $name): string
    {
        foreach ($headers as $key => $value) {
            if (strtolower($key) !== $name) {
                continue;
            }
            $value = is_array($value) ? ($value[0] ?? '') : $value;

            return is_string($value) ? $value : '';
        }

        return '';
    }
}