Docs

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

Проверяйте исходное тело и envelope webhook-протокола v2.

Собирайте точные Buffer-байты до JSON middleware, ограничивая чтение 256 KiB. Отклоняйте повторные заголовки подписи по исходному списку HTTP; только затем приводите имена к нижнему регистру. Выбирайте секрет из доверенной карты по X-Key-Id; неизвестный ID отклоняйте. До проверки ID — лишь подсказка поиска. Храните старые нескомпрометированные секреты для существующих доставок. Синхронизируйте часы сервера.

Обязательные заголовки: X-Webhook-Version, X-Event-Id, X-Delivery-Id, X-Event-Type, X-Key-Id, X-Timestamp, X-Attempt, X-Idempotency-Key, X-Signature-SHA256. Namespace подписи остаётся буквально vaultless.webhook. Подписывается JSON.stringify упорядоченного массива ниже: версия — число 2, остальные значения envelope — строки.

Функция предполагает, что HTTP-обработчик уже отклонил повторные заголовки, ограничил размер тела и выбрал доверенный секрет.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyBramaPayWebhook(
  rawBody: Buffer,
  headers: Record<string, string | undefined>,
  secret: string,
) {
  const required = (name: string) => {
    const value = headers[name.toLowerCase()];
    if (!value) throw new Error(`Missing ${name}`);
    return value;
  };

  const version = required("x-webhook-version");
  const eventId = required("x-event-id");
  const deliveryId = required("x-delivery-id");
  const eventType = required("x-event-type");
  const timestamp = required("x-timestamp");
  const keyId = required("x-key-id");
  const attempt = required("x-attempt");
  const signature = required("x-signature-sha256");

  if (version !== "2" || required("x-idempotency-key") !== eventId)
    throw new Error("Invalid webhook envelope");
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300)
    throw new Error("Stale webhook");
  if (!/^[0-9a-f]{64}$/.test(signature) || !/^[1-9]\d*$/.test(attempt))
    throw new Error("Invalid signature encoding or attempt");

  const input = JSON.stringify([
    "vaultless.webhook",
    2,
    eventId,
    deliveryId,
    eventType,
    timestamp,
    keyId,
    attempt,
    rawBody.toString("utf8"),
  ]);
  const expected = Buffer.from(createHmac("sha256", secret).update(input).digest("hex"), "hex");
  const actual = Buffer.from(signature, "hex");
  if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
    throw new Error("Invalid webhook signature");
  return { eventId, eventType, keyId };
}

Разбирайте JSON и проверяйте схему и подписанный тип события только после успеха проверки. Одной подписи недостаточно для выдачи: сверяйте сессию, проект, режим, заказ, валюту и цену с авторизованным PAID-ответом. Отклоняйте Test-событие для Live-заказа. Тест связи может получить 2xx после проверки, но не оплачивает заказ. При временном сбое чтения/хранения возвращайте non-2xx для повтора. См. дедупликацию и сессии.