Перевірка підпису

Кожен запит підписано HMAC-SHA256 на секреті вашого ендпоінта. Перевірка підпису — обов'язковий крок: без неї будь-хто, хто дізнався адресу приймача, зможе надіслати вам вигадану подію.

Секрет

  • Формат — whsec_ + 43 символи base64url. Генерується на сервері Smengo; задати свій не можна.
  • Показується рівно один раз — під час створення ендпоінта та після ротації. Переглянути його повторно не можна ні в інтерфейсі, ні через API: зберігається він під обмеженим доступом і ніколи не потрапляє в логи.
  • Секрет втрачено — натисніть «Змінити секрет» і збережіть новий.

Формат заголовка

Smengo-Signature: t=1785921323,v1=5a0f3c9e…c81b
  • t — unix-секунди моменту підпису (те саме значення, що в Smengo-Timestamp).
  • v1 — HMAC-SHA256 у нижньому регістрі hex від рядка `${t}.${rawBody}`, де rawBodyсире тіло запиту до розбору.
  • У вікні ротації підписів два, вони розділені пробілом:
Smengo-Signature: t=1785921323,v1=5a0f3c9e…c81b v1=9d2b71af…40ea

Невідомі ключі (наприклад, v2= з майбутньої версії) ігноруйте — вони з'являться раніше, ніж перестане працювати v1.

Алгоритм перевірки

  1. Візьміть сирі байти тіла — до JSON.parse і до будь-яких перетворень. Перезібраний JSON дасть інший рядок і інший підпис.
  2. Розберіть заголовок: одне значення t і всі значення v1.
  3. Відхиліть запит, якщо |now − t| > 300 секунд (вікно 5 хвилин, захист від повторного програвання старого запиту). Розбирайте t строго як ASCII-цифри: у Python isdigit() пропускає юнікодні цифри на кшталт ١٢٣, а int() на них падає.
  4. Порахуйте HMAC-SHA256(secret, "${t}.${rawBody}") у hex.
  5. Порівняйте з кожним v1 у constant-time і за байтами, а не за рядками: timingSafeEqual кидає виняток на буферах різної довжини (звіряйте довжину заздалегідь), а compare_digestTypeError на рядку з не-ASCII-символами (кодуйте кандидата в байти).
  6. Збіглося хоча б одне — подія справжня. Не збіглося жодне — відповідайте 401/403 і нічого не обробляйте.

Ітеруйте за всіма v1. Клієнт, який бере лише перший підпис, перестане приймати події на добу під час першої ж ротації секрету.

Node.js

const crypto = require('node:crypto')

const TOLERANCE_SEC = 300

function verifySmengoSignature(rawBody, header, secret) {
  if (typeof header !== 'string' || header.length === 0) return false

  // 1. Розбір заголовка: одне t, ВСІ v1 (під час ротації їх два).
  let timestamp = null
  const signatures = []
  for (const token of header.split(/[\s,]+/)) {
    const eq = token.indexOf('=')
    if (eq <= 0) continue
    const key = token.slice(0, eq)
    const value = token.slice(eq + 1)
    if (key === 't') {
      if (timestamp === null && /^\d+$/.test(value)) timestamp = Number(value)
    } else if (key === 'v1' && value.length > 0) {
      signatures.push(value)
    }
  }
  if (timestamp === null || signatures.length === 0) return false

  // 2. Вікно 5 хвилин — ДО порівняння хешів.
  const nowSec = Math.floor(Date.now() / 1000)
  if (Math.abs(nowSec - timestamp) > TOLERANCE_SEC) return false

  // 3. Підпис рахується за СИРИМ тілом.
  const expected = Buffer.from(
    crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`, 'utf8').digest('hex'),
    'utf8',
  )

  // 4. Constant-time порівняння з попередньою перевіркою довжини.
  return signatures.some((candidate) => {
    const actual = Buffer.from(candidate, 'utf8')
    return actual.length === expected.length && crypto.timingSafeEqual(actual, expected)
  })
}

Приймання в Express — тіло обов'язково сире:

const express = require('express')
const app = express()

// express.raw, а не express.json: потрібен вихідний Buffer.
app.post('/hooks/smengo', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8')
  if (!verifySmengoSignature(rawBody, req.get('Smengo-Signature'), process.env.SMENGO_WEBHOOK_SECRET)) {
    return res.status(401).end()
  }

  const event = JSON.parse(rawBody)
  // Дедуплікація за event.id (= заголовок Smengo-Event-Id) та АСИНХРОННА обробка:
  // відповісти потрібно в межах 15 секунд.
  enqueue(event)
  res.status(200).end()
})

Python

import hashlib
import hmac
import time

TOLERANCE_SEC = 300


def verify_smengo_signature(raw_body: bytes, header: str, secret: str) -> bool:
    if not header:
        return False

    # 1. Розбір заголовка: одне t, ВСІ v1 (під час ротації їх два).
    timestamp = None
    signatures = []
    for token in header.replace(",", " ").split():
        key, _, value = token.partition("=")
        if key == "t":
            # НЕ isdigit(): він пропускає юнікодні цифри («١٢٣», «²³»), на яких
            # int() кидає ValueError. Обмеження довжини — від заголовка з
            # тисячами цифр (int() на ньому теж падає).
            if timestamp is None and value.isascii() and value.isdecimal() and len(value) <= 20:
                timestamp = int(value)
        elif key == "v1" and value:
            signatures.append(value)
    if timestamp is None or not signatures:
        return False

    # 2. Вікно 5 хвилин — ДО порівняння хешів.
    if abs(int(time.time()) - timestamp) > TOLERANCE_SEC:
        return False

    # 3. Підпис рахується за СИРИМИ байтами тіла.
    expected = hmac.new(
        secret.encode("utf-8"),
        f"{timestamp}.".encode("utf-8") + raw_body,
        hashlib.sha256,
    ).hexdigest().encode("ascii")

    # 4. Constant-time порівняння за БАЙТАМИ. compare_digest на РЯДКУ з не-ASCII
    #    кидає TypeError (перевірка довжини не рятує: len("é" * 64) == 64), а
    #    заголовок надсилає будь-хто. На байтах різної довжини він просто
    #    повертає False — окремої перевірки довжини не потрібно.
    return any(
        hmac.compare_digest(candidate.encode("utf-8", "ignore"), expected)
        for candidate in signatures
    )

Приймання у Flask — request.get_data() віддає сирі байти:

from flask import Flask, request

app = Flask(__name__)


@app.post("/hooks/smengo")
def smengo_webhook():
    raw_body = request.get_data()
    header = request.headers.get("Smengo-Signature", "")
    if not verify_smengo_signature(raw_body, header, SMENGO_WEBHOOK_SECRET):
        return "", 401

    event = request.get_json()
    enqueue(event)  # дедуплікація за event["id"], обробка асинхронна
    return "", 200

Ротація секрету

Кнопка «Змінити секрет» у картці вебхука видає новий секрет і запускає вікно на 24 години:

  1. Одразу після ротації кожна доставка підписується обома секретами — старим і новим (два v1 через пробіл).
  2. Протягом доби оновіть секрет на своєму боці. Приймання не переривається: код, що ітерує за всіма v1, приймає події і до, і після перемикання.
  3. Після закінчення вікна старий секрет перестає використовуватися, у заголовку лишається один підпис.

Поки вікно не закрилося, повторна ротація недоступна. Якщо секрет скомпрометовано і старий треба вимкнути негайно — видаліть ендпоінт і створіть новий.

Часті помилки

Симптом Причина
Підпис не сходиться, тіло виглядає правильним тіло перезібрано після розбору (JSON.stringify(JSON.parse(body))) — рахуйте HMAC за сирими байтами
Приймання зламалося рівно після зміни секрету код бере перше v1 замість перебору всіх
Виняток усередині порівняння timingSafeEqual викликано на буферах різної довжини (звіряйте довжину заздалегідь) або compare_digest — на рядку з не-ASCII (порівнюйте байти)
На сміттєвий заголовок приймач відповідає 500 замість 401 t розібрано через isdigit() або підпис порівнюється рядками — і те, і те падає на заголовку, який може надіслати будь-хто, навіть не знаючи секрету
Усі запити відхиляються як «старі» годинник приймача розійшовся; вікно допуску — 5 хвилин, синхронізуйте час за NTP
Підпис зійшовся, але події надходять двічі це штатний режим at-least-once — дедуплікуйте за Smengo-Event-Id

Далі

Чи була стаття корисною?