Проверка HMAC-подписи
Проверка подписи вебхуков на Node.js, PHP и Python — сравнение за постоянное время и 5-минутное временное окно.
Проверяйте, что вебхук действительно пришёл от ZeroKYC Pay, прежде чем реагировать. Каждая доставка несёт один заголовок X-ZKP-Signature и подписана HMAC-SHA256 от строки {timestamp}.{raw_body}.
Заголовки
X-ZKP-Signature: t=<unix seconds>,v1=<hex hmac-sha256>
Валидация до проверки подписи
- заголовок присутствует и единственный;
- он разбирается как
t=<целое>,v1=<64-символьный hex>; - timestamp в пределах ±300 секунд от ваших часов;
- часть подписи — строчный hex ровно 64 символа (SHA-256);
- сравнение HMAC — в
try/catch: некорректный ввод не должен превращаться в 500.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
// Важно: raw body, а не распарсенный JSON
app.use(express.raw({ type: "application/json" }));
function verify(rawBody, signatureHeader) {
// разбор t=...,v1=...
const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
const timestamp = parts.t;
const signature = (parts.v1 || "").toLowerCase();
// timestamp — целое число в 5-минутном окне
if (!/^d{1,12}$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
// подпись — hex, ровно 64 символа для SHA-256
if (!/^[0-9a-f]{64}$/.test(signature)) return false;
const expected = createHmac("sha256", process.env.ZKP_WEBHOOK_SECRET)
.update(timestamp + "." + rawBody)
.digest("hex");
try {
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
} catch {
return false;
}
}
app.post("/webhooks/zkp", (req, res) => {
if (!verify(req.body, req.headers["x-zkp-signature"])) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(req.body);
// дедупликация: один event.id может быть доставлен повторно
if (seen(event.id)) return res.sendStatus(200);
console.log("verified:", event.type, event.data.invoice_id);
res.sendStatus(200);
});PHP
<?php
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_ZKP_SIGNATURE'] ?? '';
// 1. Структура: t=<int>,v1=<64 hex>
if (!preg_match('/^t=(d{1,12}),v1=([0-9a-f]{64})$/', $signature, $m)) {
http_response_code(400); exit('malformed signature header');
}
$timestamp = $m[1]; $sig = $m[2];
// 2. Окно 5 минут
if (abs(time() - (int)$timestamp) > 300) { http_response_code(400); exit('stale timestamp'); }
// 3. Пересчёт HMAC от timestamp.rawBody, сравнение в постоянном времени
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, getenv('ZKP_WEBHOOK_SECRET'));
if (!hash_equals($expected, $sig)) { http_response_code(400); exit('bad signature'); }
$event = json_decode($rawBody, true);
// дедупликация: один event.id может быть доставлен повторно
if (alreadySeen($event['id'] ?? '')) { http_response_code(200); exit; }
error_log('verified: ' . $event['type']);
http_response_code(200);Python
import hmac
import hashlib
import re
import time
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = b"whsec_..."
@app.post("/webhooks/zkp")
def webhook():
header = request.headers.get("X-ZKP-Signature", "")
m = re.fullmatch(r"t=(d{1,12}),v1=([0-9a-f]{64})", header)
if not m:
abort(400, "malformed signature header")
timestamp, sig = m.group(1), m.group(2)
expected = hmac.new(
WEBHOOK_SECRET,
f"{timestamp}.".encode() + request.get_data(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, sig):
abort(400, "bad signature")
event = request.get_json()
if already_seen(event.get("id")):
return "", 200
print("verified:", event["type"])
return "", 200Тестовый вектор
Проверьте свою реализацию на этих значениях до приёма live-вебхуков:
secret: whsec_zkp_test_vector_2026
timestamp: 1788788073
raw body: {"id":"evt_test_001","type":"payment.confirmed","invoice_id":"inv_test_001"}
signed: 1788788073.{"id":"evt_test_001","type":"payment.confirmed","invoice_id":"inv_test_001"}
signature: v1=ade537fa13aec79a6d1648bd7f197872066c161676c389243ab5c6b13fea7f52
signature — hex HMAC-SHA256 от строки signed. Если ваш код даёт другое значение, исправьте его до приёма live-вебхуков.
Частые ошибки
- Парсинг JSON до проверки подписи. После повторной сериализации байты уже не совпадут - верифицируйте raw body.
- Проверка подписи без timestamp. Легитимный запрос можно воспроизвести; окно 5 минут существует именно против этого.
- Сравнение через
==. Строковое равенство открывает timing-атаки - используйтеtimingSafeEqual,hash_equalsилиcompare_digest. timingSafeEqualна буферах разной длины. Node бросает исключение и вы получите 500 - сначала проверьте длину и формат.int(timestamp)на сыром вводе. В Python отсутствующий или нечисловой заголовок бросит исключение до чистого ответа - валидируйте регуляркой.- Доверие одному
event.idдважды. Доставки повторяются; храните id событий и пропускайте дубликаты. - Секрет в репозитории. Секрет вебхуков живёт только в переменных окружения.