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

Проверка 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>

Валидация до проверки подписи

  1. заголовок присутствует и единственный;
  2. он разбирается как t=<целое>,v1=<64-символьный hex>;
  3. timestamp в пределах ±300 секунд от ваших часов;
  4. часть подписи — строчный hex ровно 64 символа (SHA-256);
  5. сравнение 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 событий и пропускайте дубликаты.
  • Секрет в репозитории. Секрет вебхуков живёт только в переменных окружения.