Перейти к содержимому

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

Обновлено 17 октября 2026

Каждую доставку подписки Gratio подписывает секретом этой подписки. Проверка подписи доказывает, что запрос прислал Gratio и тело не меняли по дороге. Принимайте доставку, только если подпись сошлась и она свежая.

Заголовок

Подпись приходит в заголовке доставки:

X-Gratio-Signature: t=1791823660,v1=7b36aa47219531b4691a6391718232d8b54a9bd3c8f81bbba7d9345c037de163
Часть Что
t время подписи, секунды Unix
v1 HMAC-SHA256 в шестнадцатеричном виде, 64 знака в нижнем регистре

Алгоритм

  1. Возьмите сырое тело запроса — байты как пришли, до разбора JSON.
  2. Разберите заголовок: разделите по ,, каждую часть — по первому =. Нужны t и v1. Нет одной из них или t не число — отклоните.
  3. Сравните t с текущим временем. Разница больше 300 секунд в любую сторону — отклоните: это старый или повторно отправленный злоумышленником запрос.
  4. Посчитайте HMAC-SHA256 с ключом — секретом подписки — от строки <t>.<сырое тело>: значение t как в заголовке, точка, затем байты тела.
  5. Сравните результат с v1 сравнением за постоянное время (hmac.compare_digest, crypto.timingSafeEqual, hash_equals). Совпало — примите, иначе ответьте 401.

Отказ 401 подписка считает отказом: после 5 неудачных попыток уведомление уходит в отравленные. Поэтому проверьте функцию на тестовом примере ниже, прежде чем включать подписку.

Python

Функция проверки:

import hashlib
import hmac
import time


def verify_signature(secret: str, body: bytes, header: str, now: float | None = None, tolerance: int = 300) -> bool:
    """Проверить X-Gratio-Signature. body — сырые байты запроса, как пришли."""
    parts = {}
    for piece in header.split(","):
        key, sep, value = piece.strip().partition("=")
        if sep:
            parts[key] = value
    if not parts.get("t", "").isdigit() or not parts.get("v1"):
        return False
    t = int(parts["t"])
    if abs((time.time() if now is None else now) - t) > tolerance:
        return False
    want = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, parts["v1"])

В обработчике FastAPI тело читайте байтами — await request.body():

import json
import os

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()
SECRET = os.environ["GRATIO_SUB_SECRET"]


@app.post("/hooks/gratio")
async def gratio_subscription(request: Request) -> dict:
    body = await request.body()  # сырые байты — до разбора JSON
    if not verify_signature(SECRET, body, request.headers.get("x-gratio-signature", "")):
        raise HTTPException(status_code=401)
    event = json.loads(body)
    print(request.headers.get("x-gratio-delivery"), event["typeWebhook"])
    return {"ok": True}

Node.js

Функция проверки, rawBody — Buffer:

import crypto from "node:crypto";

export function verifySignature(secret, rawBody, header = "", now = Date.now() / 1000, tolerance = 300) {
  const parts = {};
  for (const piece of header.split(",")) {
    const i = piece.indexOf("=");
    if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
  }
  if (!/^\d+$/.test(parts.t || "") || !parts.v1) return false;
  const t = Number(parts.t);
  if (Math.abs(now - t) > tolerance) return false;
  const want = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
  const a = Buffer.from(want);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

В Express подключите express.raw на этом маршруте — тогда req.body будет Buffer, а не разобранный объект:

import express from "express";
import { verifySignature } from "./signature.mjs";

const app = express();

app.post("/hooks/gratio", express.raw({ type: "application/json", limit: "1mb" }), (req, res) => {
  const header = req.get("x-gratio-signature") || "";
  if (!verifySignature(process.env.GRATIO_SUB_SECRET, req.body, header)) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString("utf8"));
  res.json({ ok: true });
  console.log(req.get("x-gratio-delivery"), event.typeWebhook);
});

app.listen(8000);

Не ставьте express.json() глобально перед этим маршрутом: он разберёт тело раньше, и сырых байтов не останется.

PHP

Функция проверки и обработчик — тело читайте из php://input:

<?php
function gratio_verify_signature(string $secret, string $body, string $header, ?int $now = null, int $tolerance = 300): bool
{
    $parts = [];
    foreach (explode(',', $header) as $piece) {
        $pair = explode('=', trim($piece), 2);
        if (count($pair) === 2) {
            $parts[$pair[0]] = $pair[1];
        }
    }
    if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
        return false;
    }
    $t = (int) $parts['t'];
    if (abs(($now ?? time()) - $t) > $tolerance) {
        return false;
    }
    $want = hash_hmac('sha256', $t . '.' . $body, $secret);
    return hash_equals($want, $parts['v1']);
}

$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_GRATIO_SIGNATURE'] ?? '';
if (!gratio_verify_signature(getenv('GRATIO_SUB_SECRET'), $body, $header)) {
    http_response_code(401);
    exit;
}
$event = json_decode($body, true);
header('Content-Type: application/json');
echo '{"ok":true}';

Тестовый пример

Проверьте свою функцию на этих данных. Секрет:

5d0c3f1a9b7e4c2d8a6f0e1b3c5d7e9f

Тело — ровно эти 192 байта, одной строкой, без пробелов и перевода строки в конце:

{"typeWebhook":"stateInstanceChanged","instanceData":{"idInstance":7100000001,"wid":"79990000001@c.us","typeInstance":"v3","channel":"max"},"timestamp":1791823658,"stateInstance":"authorized"}

Заголовок, который должен пройти проверку при now = 1791823660:

t=1791823660,v1=7b36aa47219531b4691a6391718232d8b54a9bd3c8f81bbba7d9345c037de163

Проверка на Python:

SECRET = "5d0c3f1a9b7e4c2d8a6f0e1b3c5d7e9f"
BODY = b'{"typeWebhook":"stateInstanceChanged","instanceData":{"idInstance":7100000001,"wid":"79990000001@c.us","typeInstance":"v3","channel":"max"},"timestamp":1791823658,"stateInstance":"authorized"}'
HEADER = "t=1791823660,v1=7b36aa47219531b4691a6391718232d8b54a9bd3c8f81bbba7d9345c037de163"

assert verify_signature(SECRET, BODY, HEADER, now=1791823660)
assert not verify_signature(SECRET, BODY + b" ", HEADER, now=1791823660)  # тело изменили
assert not verify_signature(SECRET, BODY, HEADER, now=1791824000)  # подпись старше 5 минут

То же в командной строке — openssl должен вывести то же значение v1:

BODY='{"typeWebhook":"stateInstanceChanged","instanceData":{"idInstance":7100000001,"wid":"79990000001@c.us","typeInstance":"v3","channel":"max"},"timestamp":1791823658,"stateInstance":"authorized"}'
printf '%s' "1791823660.$BODY" | openssl dgst -sha256 -hmac "5d0c3f1a9b7e4c2d8a6f0e1b3c5d7e9f"

Частые ошибки

Признак Причина
подпись не сходится ни у одной доставки тело разобрали и собрали обратно: порядок ключей, пробелы и экранирование кириллицы поменялись. Считайте подпись по сырым байтам
не сходится только у сообщений на русском тело перекодировали; Gratio шлёт UTF-8 без \u-экранирования
перестала сходиться после смены адреса повторный POST subscriptions без secret выдал новый секрет — возьмите его из ответа
отказ по времени часы сервера отстают или спешат больше чем на 5 минут — включите синхронизацию времени
в Node.js TypeError при подсчёте HMAC req.body уже объект: перед маршрутом сработал express.json(). Подключите express.raw

Что дальше

Была ли статья полезна?