§ 8.2

Подпись и повторы

Проверка HMAC-подписи, повторные доставки, идемпотентность.

обновлено 24 сентября 20266 мин чтениятолько на тарифахКомандаБизнес

Вебхук приходит из интернета, и убедиться, что его отправил DoneBy, а не кто-то, кто узнал адрес, — задача вашего обработчика. Для этого каждая доставка подписана секретом адреса, а повторы устроены так, чтобы одно событие можно было безопасно обработать один раз.

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

Заголовок
X-DoneBy-Signature: t=1758707412,v1=3b9f0c7e1d4a26f58c0e9b7a1f2d3c4b5a69788f7e6d5c4b3a2918070f6e5d4c
ЧастьЗначение
tВремя отправки — Unix-время в секундах.
v1HMAC-SHA256 в hex от строки t.тело. Ключ — секрет адреса. Во время перевыпуска секрета v1 может быть два.

Подписывается строка из значения t, точки и сырого тела запроса — байт в байт, как оно пришло: 1758707412.{"id":"evt_6Rk2Pw",...}.

Как проверить подпись

  1. Возьмите сырое тело запроса — до разбора JSON.
  2. Разберите заголовок X-DoneBy-Signature: значение t и все значения v1.
  3. Проверьте время: если t отличается от текущего больше чем на 5 минут, отклоните запрос. Это защита от повторной отправки перехваченного запроса.
  4. Посчитайте HMAC-SHA256 от t + "." + тело с секретом адреса.
  5. Сравните результат с каждым v1 функцией сравнения за постоянное время. Совпала хотя бы одна подпись — запрос настоящий.
  6. Не совпала — ответьте 401 и не обрабатывайте событие.

Node.js

verify.js
import crypto from 'node:crypto';

const TOLERANCE_SEC = 300;

export function verifySignature(rawBody, header, secret, now = Date.now()) {
  const pairs = String(header || '').split(',').map((p) => p.trim().split('='));
  const t = Number((pairs.find(([k]) => k === 't') || [])[1]);
  const sigs = pairs.filter(([k]) => k === 'v1').map(([, v]) => v);
  if (!t || sigs.length === 0) return false;
  if (Math.abs(now / 1000 - t) > TOLERANCE_SEC) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest();

  return sigs.some((s) => {
    const got = Buffer.from(s, 'hex');
    return got.length === expected.length && crypto.timingSafeEqual(got, expected);
  });
}
server.js · Express
import express from 'express';
import { verifySignature } from './verify.js';

const app = express();

app.post('/hooks/doneby', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  const header = req.get('X-DoneBy-Signature');

  if (!verifySignature(raw, header, process.env.DONEBY_WEBHOOK_SECRET)) {
    return res.status(401).end();
  }

  const event = JSON.parse(raw);
  res.status(200).end();
  queue.add(event);
});

Python

app.py · Flask
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

SECRET = os.environ['DONEBY_WEBHOOK_SECRET'].encode()
TOLERANCE_SEC = 300

app = Flask(__name__)


def verify_signature(raw: bytes, header: str) -> bool:
    pairs = [p.strip().split('=', 1) for p in header.split(',') if '=' in p]
    ts = next((v for k, v in pairs if k == 't'), '')
    sigs = [v for k, v in pairs if k == 'v1']
    if not ts.isdigit() or not sigs:
        return False
    if abs(time.time() - int(ts)) > TOLERANCE_SEC:
        return False
    message = ts.encode() + b'.' + raw
    expected = hmac.new(SECRET, message, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in sigs)


@app.post('/hooks/doneby')
def doneby_hook():
    raw = request.get_data()
    if not verify_signature(raw, request.headers.get('X-DoneBy-Signature', '')):
        abort(401)
    event = request.get_json()
    queue.put(event)
    return '', 200

Важно

Не сравнивайте подписи обычным равенством строк. Такое сравнение останавливается на первом несовпадающем символе, и по времени ответа подпись можно подобрать. crypto.timingSafeEqual и hmac.compare_digest сравнивают за постоянное время.

Совет

Чаще всего подпись «не сходится», потому что фреймворк уже разобрал JSON и подписывается JSON.stringify(req.body) — с другими пробелами и порядком ключей. В Express подключайте для этого маршрута express.raw(), во Flask берите request.get_data(). Второй частый случай — часы сервера отстают: включите синхронизацию времени (NTP).

Перевыпуск секрета

Если секрет мог утечь, откройте Настройки → Вебхуки, выберите адрес и нажмите «Перевыпустить секрет». Новый секрет действует сразу, старый — ещё 24 часа. В это время заголовок содержит две подписи: v1=<новая>,v1=<старая>. Успейте обновить секрет на своём сервере за это время — примеры выше проверяют обе подписи и переживут замену без потерянных событий.

Повторные доставки

Если доставка не удалась — не 2xx за 10 секунд, — DoneBy повторяет её по расписанию. Каждая пауза отсчитывается от предыдущей попытки:

ПопыткаПауза перед нейС первой попытки
1сразу—
21 мин1 мин
35 мин6 мин
430 мин36 мин
52 ч2 ч 36 мин
612 ч14 ч 36 мин

Всего 6 попыток, последняя — примерно через 14 ч 36 мин после первой. Каждая повторная доставка несёт то же тело и тот же id события, но новую метку t и новую подпись.

Идемпотентность обработчика

Одно событие может прийти дважды. Например, вы обработали его, но ответили позже чем через 10 секунд — для DoneBy это неудача, и через минуту придёт повтор. Поэтому:

  • Сохраняйте id обработанных событий и пропускайте повторы. Хранить их достаточно сутки — все повторы одного события укладываются в 14 ч 36 мин.
  • Ставьте уникальный индекс на id события в своей базе — это надёжнее проверки в коде, особенно если обработчиков несколько.
  • Делайте действия повторяемыми: «поставить статус Done» безопаснее, чем «увеличить счётчик на 1».
Отсечь дубль одной вставкой · PostgreSQL
export async function handleEvent(event, db) {
  const res = await db.query(
    `INSERT INTO doneby_events (id, type, received_at)
     VALUES ($1, $2, now())
     ON CONFLICT (id) DO NOTHING`,
    [event.id, event.type]
  );
  if (res.rowCount === 0) return;

  await applyEvent(event);
}

Отключение адреса

Если за 3 суток подряд на адрес не прошло ни одной успешной доставки, DoneBy отключает его и отправляет письмо владельцу и администраторам пространства: какой адрес отключён и какие ошибки были последними. События, которые произошли, пока адрес был отключён, не копятся и не досылаются.

Чтобы включить адрес, откройте Настройки → Вебхуки и нажмите «Включить». Пропущенные изменения заберите через API: GET /tasks?updated_since=<время отключения> — см. список задач.

Совет

На время коротких работ на своём сервере адрес отключать не нужно: повторы перекрывают простой примерно до 14 ч 36 мин. Если простой дольше, после восстановления дотяните изменения через updated_since.

Чек-лист обработчика

  • Подпись проверяется по сырому телу, сравнение — за постоянное время.
  • Метка t не старше 5 минут, часы сервера синхронизируются.
  • Ответ 2xx уходит сразу, обработка — в очереди.
  • Дубли отсекаются по id события.
  • Секрет лежит в переменной окружения или хранилище секретов, а не в коде.
  • Есть план на случай отключения адреса: досинхронизация через updated_since.
см. также
Статья помогла?Отметьте галочкой — так мы понимаем, какие статьи дописать первыми.