"""Приём s2s-доставки: проверка подписи вебхука Synapse.
Одна схема подписи на всю экосистему (docs/05 → «Доставка s2s»), зеркало
`app/signature.py` сервера и webhooks gnexus-auth:
sig = "t=<unix>,v1=" + hex(hmac_sha256("<t>.<raw_body>", secret))
`verify_webhook()` — одна точка входа принимающего сервиса: проверяет
подпись (константным сравнением) и окно свежести (replay), возвращает
распарсенный конверт события. Тело — байт в байт то, что пришло в сеть,
до любого парсинга.
Использование (FastAPI-приёмник Navi):
@router.post("/webhooks/synapse")
async def synapse_webhook(request: Request) -> dict:
raw = await request.body()
envelope = verify_webhook(raw, request.headers, S2S_SECRET)
... # envelope["event_id"], envelope["subject"], ...
"""
from __future__ import annotations
import hashlib
import hmac
import json
import time
from collections.abc import Mapping
from .exceptions import SynapseWebhookError
__all__ = ["SIGNATURE_HEADER", "DEFAULT_MAX_SKEW", "verify_webhook", "make_signature"]
SIGNATURE_HEADER = "x-gnexus-signature"
DEFAULT_MAX_SKEW = 300 # секунд (зеркало gnexus-auth)
def make_signature(body: bytes, secret: str, timestamp: int | None = None) -> str:
"""Подпись «сырое» тело (для тестов и отправщиков, приём — не его работа)."""
if timestamp is None:
timestamp = int(time.time())
digest = hmac.new(
secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
).hexdigest()
return f"t={timestamp},v1={digest}"
def verify_webhook(
raw_body: bytes,
headers: Mapping[str, str],
secret: str,
*,
max_skew: int = DEFAULT_MAX_SKEW,
now: int | None = None,
) -> dict[str, object]:
"""Проверить подпись и распарсить доставку. Возвращает конверт (dict).
Тело — байт в байт как пришло (request.body(), не json-объект).
Заголовки ищутся без учёта регистра. Ошибка → SynapseWebhookError:
нет/битый `X-Gnexus-Signature`, replay (старше max_skew), чужой секрет,
не-JSON тело. Все — одна ловушка, разбирать по detail.
"""
supplied: str = ""
for key, value in headers.items():
if key.lower() == SIGNATURE_HEADER:
supplied = value
break
parts: dict[str, str] = {}
for chunk in supplied.split(","):
name, _, raw_value = chunk.partition("=")
parts[name.strip().lower()] = raw_value.strip()
try:
timestamp = int(parts["t"])
digest = parts["v1"]
except (KeyError, ValueError):
raise SynapseWebhookError("нет или битый заголовок X-Gnexus-Signature") from None
if not digest:
raise SynapseWebhookError("подпись без v1-части")
current = int(time.time()) if now is None else now
if abs(current - timestamp) > max_skew:
raise SynapseWebhookError(f"тело не свежее: отклонение {abs(current - timestamp)} с > {max_skew} с (replay?)")
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, digest):
raise SynapseWebhookError("подпись не совпала — чужой секрет или тело искажено")
try:
envelope = json.loads(raw_body)
except (ValueError, UnicodeDecodeError):
raise SynapseWebhookError("тело не JSON — приём Synapse шлёт конверт") from None
if not isinstance(envelope, dict):
raise SynapseWebhookError("тело — не JSON-объект конверта")
return envelope