Skip to content

Проверка подписи вебхука ​

Каждая доставка подписана. Проверяйте подпись до любой обработки.

Заголовок подписи ​

X-ElevenPay-Signature: t=<unix_timestamp>,v1=<hex_hmac>[,v1=<hex_hmac>...]
  • t — Unix‑время (секунды), когда сформирована подпись.
  • v1 — HMAC‑SHA256 в hex. Во время ротации секрета может присутствовать несколько v1.

Подписываемый материал ​

Подпись — это HMAC‑SHA256 над строкой:

<t> + "." + <raw_body>

где <raw_body> — точные сырые байты тела запроса, а ключ HMAC — ваш секрет подписи вебхука.

Проверяйте по сырым байтам тела

Проверяйте подпись по точным исходным байтам HTTP‑тела. Не разбирайте JSON и не сериализуйте его заново перед проверкой — любая пересборка (пробелы, порядок ключей, экранирование) изменит байты и сломает проверку.

Алгоритм проверки ​

  1. Захватите сырые байты тела.
  2. Разберите заголовок: t и список v1.
  3. Проверьте, что t в допустимом окне (рекомендуется ±5 минут) — защита от повторов.
  4. Вычислите HMAC-SHA256(secret, f"{t}." + raw_body).
  5. Сравните в постоянном времени с каждым v1 (поддержите несколько v1).
  6. Только после успешной проверки разбирайте JSON.

Несколько подписей и ротация ​

Во время ротации секрета Eleven Pay может подписывать текущим и предыдущим секретом одновременно, что даёт несколько v1. Проверка считается успешной, если любой v1 совпадает с подписью, вычисленной любым из принимаемых секретов. Обновите верификатор на новый секрет до следующей ротации. Фиксированной длительности перекрытия нет.

Пример: Node.js ​

javascript
import crypto from "node:crypto";

const TOLERANCE = 300; // 5 минут

function parseSignatureHeader(header) {
  const parts = String(header).split(",");
  if (parts.length < 2 || !parts[0].startsWith("t=")) return null;
  const ts = parts[0].slice(2);
  if (!/^\d+$/.test(ts)) return null;
  const signatures = [];
  for (const p of parts.slice(1)) {
    if (!p.startsWith("v1=")) return null;
    signatures.push(p.slice(3));
  }
  return signatures.length ? { timestamp: Number(ts), signatures } : null;
}

// secrets: [текущий] или [текущий, предыдущий] во время ротации. rawBody: Buffer (сырые байты).
export function verifySignature(secrets, signatureHeader, rawBody) {
  const parsed = parseSignatureHeader(signatureHeader);
  if (!parsed) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - parsed.timestamp) > TOLERANCE) return false;
  const material = Buffer.concat([Buffer.from(`${parsed.timestamp}.`, "ascii"), rawBody]);
  let matched = false;
  for (const secret of secrets) {
    const expected = crypto.createHmac("sha256", secret).update(material).digest("hex");
    for (const candidate of parsed.signatures) {
      if (expected.length === candidate.length &&
          crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate))) {
        matched = true; // без раннего выхода
      }
    }
  }
  return matched;
}

Пример: Python ​

python
import hashlib
import hmac
import time


def verify_signature(secrets, signature_header, raw_body, *, tolerance=300):
    parts = signature_header.split(",")
    if len(parts) < 2 or not parts[0].startswith("t="):
        return False
    ts_text = parts[0][2:]
    if not ts_text.isdigit():
        return False
    timestamp = int(ts_text)
    signatures = [p[3:] for p in parts[1:] if p.startswith("v1=")]
    if len(signatures) != len(parts) - 1 or not signatures:
        return False
    if abs(int(time.time()) - timestamp) > tolerance:
        return False
    material = f"{timestamp}.".encode("ascii") + raw_body
    matched = False
    for secret in secrets:
        expected = hmac.new(secret.encode("utf-8"), material, hashlib.sha256).hexdigest()
        for candidate in signatures:
            if hmac.compare_digest(expected, candidate):  # постоянное время, без раннего выхода
                matched = True
    return matched

Оба примера соответствуют проверенному тест‑вектору из репозитория (см. Обработчик вебхуков). После проверки подписи дедуплицируйте событие и обрабатывайте его идемпотентно.