Верификация подписи
Все уведомления подписываются асимметричным ключом через 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.
Кешируйте публичный ключ
Публичный ключ редко меняется. Получите его один раз при старте приложения и кешируйте — нет смысла запрашивать его при каждой доставке.
Алгоритм проверки
- Получите публичный ключ с
/api/v1/webhooks/signing-keyи декодируйте его из base64 в PEM. - Декодируйте
X-Webhook-Signatureиз base64 в байты подписи. - Проверьте подпись над точными байтами тела 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, событие чужое |