Проверка подписи вебхука
Каждая доставка подписана. Проверяйте подпись до любой обработки.
Заголовок подписи
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 и не сериализуйте его заново перед проверкой — любая пересборка (пробелы, порядок ключей, экранирование) изменит байты и сломает проверку.
Алгоритм проверки
- Захватите сырые байты тела.
- Разберите заголовок:
tи списокv1. - Проверьте, что
tв допустимом окне (рекомендуется ±5 минут) — защита от повторов. - Вычислите
HMAC-SHA256(secret, f"{t}." + raw_body). - Сравните в постоянном времени с каждым
v1(поддержите несколькоv1). - Только после успешной проверки разбирайте JSON.
Несколько подписей и ротация
Во время ротации секрета Eleven Pay может подписывать текущим и предыдущим секретом одновременно, что даёт несколько v1. Проверка считается успешной, если любой v1 совпадает с подписью, вычисленной любым из принимаемых секретов. Обновите верификатор на новый секрет до следующей ротации. Фиксированной длительности перекрытия нет.
Пример: Node.js
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
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Оба примера соответствуют проверенному тест‑вектору из репозитория (см. Обработчик вебхуков). После проверки подписи дедуплицируйте событие и обрабатывайте его идемпотентно.