<?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 '';
}
}