Перейти к содержанию

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

Все уведомления подписываются асимметричным ключом через KMS на стороне Pert. Подпись передаётся в заголовке X-Webhook-Signature в base64 и позволяет вам убедиться, что запрос пришёл именно от Pert и тело не было изменено.

Параметр Значение
Алгоритм ECDSA на кривой SECP256K1
Хеш-функция SHA-256
Формат подписи DER-encoded ASN.1 (r, s), затем base64
Что подписано Сырые байты тела HTTP-запроса

Всегда проверяйте подпись

Подпись — это единственная гарантия того, что запрос пришёл от Pert. Никогда не доверяйте полям тела (event_id, data и т. д.) до успешной верификации. Запросы с невалидной подписью отбрасывайте с кодом 401 Unauthorized и логируйте.

Получение публичного ключа

Эндпоинт открыт без авторизации:

curl https://gateway.pert.paranoid.security/api/v1/webhooks/signing-key

Ответ:

{
  "public_key": "LS0tLS1CRUdJTi...(base64-кодированный PEM)...LS0tLS0K",
  "algorithm": "ECDSA-SECP256K1-SHA256"
}

Поле public_key — это PEM-кодированный публичный ключ, дополнительно завёрнутый в base64. Чтобы получить PEM-строку, декодируйте public_key из base64.

Кешируйте публичный ключ

Публичный ключ редко меняется. Получите его один раз при старте приложения и кешируйте — нет смысла запрашивать его при каждой доставке.

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

  1. Получите публичный ключ с /api/v1/webhooks/signing-key и декодируйте его из base64 в PEM.
  2. Декодируйте X-Webhook-Signature из base64 в байты подписи.
  3. Проверьте подпись над точными байтами тела HTTP-запроса с помощью публичного ключа.

Подпись над сырыми байтами

Подпись считается над сырыми байтами тела, как они пришли по сети. Не пересериализуйте JSON, не меняйте порядок ключей, не удаляйте/добавляйте пробелы — всё это сломает верификацию.

В большинстве веб-фреймворков для этого нужен доступ к raw body до парсинга JSON:

Фреймворк Как получить raw body
Express express.raw({ type: "application/json" })
Fastify Опция rawBody: true
Flask request.get_data() (вместо request.get_json())
FastAPI await request.body()
Go net/http io.ReadAll(r.Body) до парсинга

Примеры

import base64
import hashlib
from ecdsa import VerifyingKey, BadSignatureError

def verify_webhook(body: bytes, signature_b64: str, public_key_b64: str) -> bool:
    """Проверяет подпись webhook-уведомления."""
    try:
        public_key_pem = base64.b64decode(public_key_b64).decode("utf-8")
        vk = VerifyingKey.from_pem(public_key_pem)
        signature = base64.b64decode(signature_b64)
        vk.verify(signature, body, hashfunc=hashlib.sha256)
        return True
    except BadSignatureError:
        return False
import crypto from "node:crypto";

function verifyWebhook(rawBody, signatureB64, publicKeyB64) {
  const pem = Buffer.from(publicKeyB64, "base64").toString("utf8");
  const signature = Buffer.from(signatureB64, "base64");
  const verifier = crypto.createVerify("sha256");
  verifier.update(rawBody);
  verifier.end();
  return verifier.verify(pem, signature);
}

Express

Используйте express.raw({ type: "application/json" }) для роута webhook, чтобы получить тело как Buffer до парсинга JSON.

import (
    "crypto/ecdsa"
    "crypto/sha256"
    "crypto/x509"
    "encoding/base64"
    "encoding/pem"
)

func verifyWebhook(body []byte, signatureB64, publicKeyB64 string) bool {
    pemBytes, err := base64.StdEncoding.DecodeString(publicKeyB64)
    if err != nil {
        return false
    }
    block, _ := pem.Decode(pemBytes)
    pub, err := x509.ParsePKIXPublicKey(block.Bytes)
    if err != nil {
        return false
    }
    ecdsaKey, ok := pub.(*ecdsa.PublicKey)
    if !ok {
        return false
    }
    signature, err := base64.StdEncoding.DecodeString(signatureB64)
    if err != nil {
        return false
    }
    hash := sha256.Sum256(body)
    return ecdsa.VerifyASN1(ecdsaKey, hash[:], signature)
}
import java.security.*;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

public class WebhookVerifier {
    public static boolean verify(byte[] body, String signatureB64, String publicKeyB64)
            throws Exception {
        byte[] pemBytes = Base64.getDecoder().decode(publicKeyB64);
        String pem = new String(pemBytes)
            .replace("-----BEGIN PUBLIC KEY-----", "")
            .replace("-----END PUBLIC KEY-----", "")
            .replaceAll("\\s+", "");
        byte[] keyBytes = Base64.getDecoder().decode(pem);

        KeyFactory kf = KeyFactory.getInstance("EC");
        PublicKey publicKey = kf.generatePublic(new X509EncodedKeySpec(keyBytes));

        Signature sig = Signature.getInstance("SHA256withECDSA");
        sig.initVerify(publicKey);
        sig.update(body);
        return sig.verify(Base64.getDecoder().decode(signatureB64));
    }
}

Production-ready код

Эти примеры даны для справки. В production добавьте:

  • обработку ошибок и логирование отказов;
  • ограничение размера принимаемого тела (например, 1 МБ);
  • тайм-аут на верификацию;
  • сравнение X-Webhook-Workspace-Id с ожидаемым значением;
  • аудит-лог запросов с невалидной подписью.

Что делать при ошибке верификации

Ситуация Что делать
Подпись не совпадает Вернуть 401, логировать заголовки и факт отказа (без тела)
Заголовок X-Webhook-Signature пустой Вернуть 401. Это может быть запрос не от Pert
Не удалось получить/распарсить ключ Вернуть 503, попробовать получить ключ снова
Workspace в X-Webhook-Workspace-Id не ваш Вернуть 400, событие чужое