"""Приём 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",
"EVENT_TYPE_HEADER",
"SOURCE_HEADER",
"DEFAULT_MAX_SKEW",
"verify_webhook",
"make_signature",
"parse_event_type",
"ack",
]
SIGNATURE_HEADER = "x-gnexus-signature"
EVENT_TYPE_HEADER = "x-gnexus-event-type" # "<source>.<subject>.<action>"
SOURCE_HEADER = "x-synapse-source"
DEFAULT_MAX_SKEW = 300 # секунд (зеркало gnexus-auth)
def parse_event_type(header: str | None) -> tuple[str, str, str] | None:
"""`"<source>.<subject>.<action>"` → кортеж; None — нет/битый заголовок.
Контекст приёма: первый экран обработки — обычно маршрутизация
по тройке из `X-Gnexus-Event-Type`. Имена источника не валидируются
(это метрика, не конверт): валидирует отправитель на ingestion.
"""
if not header:
return None
parts = header.split(".")
if len(parts) != 3 or not all(parts):
return None
return parts[0], parts[1], parts[2]
def ack(event_id: str | None = None) -> dict[str, object]:
"""Тело ответа 2xx приёмника: {\"received\": true, \"event_id\": ...}."""
body: dict[str, object] = {"received": True}
if event_id:
body["event_id"] = event_id
return body
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