Проверка подписи вебхуков
Подпись помогает вашей системе отличить настоящий вебхук от поддельного и проверить, что его содержимое не изменили по дороге. Например, прежде чем создать клиента в CRM, система сможет убедиться, что запрос пришёл от нужного отправителя.
Получатель проверяет подпись с помощью публичного ключа — строки из настроек аккаунта. Скопируйте его и передайте разработчику вашей интеграции: он добавит проверку на стороне получателя.
Как включить#
- Войдите как администратор. Откройте настройки канала «Для разработчика» или раздел вебхуков CRM.
- Включите подпись и скопируйте публичный ключ.
- Добавьте ключ в настройки системы, которая принимает вебхуки, и включите проверку подписи.
Один ключ подходит для всех вебхуков аккаунта: каналов, CRM и API-подписок. Если подпись не включена, вебхуки работают как прежде.
Что должен проверять получатель#
Перед выполнением действий ваша система должна:
- Проверить подпись ключом из настроек аккаунта. Если подписи нет или она неверна, отклонить запрос.
- Проверить время отправки. Допустимая разница с часами вашего сервера — не больше 5 минут в любую сторону.
- Проверить, не обрабатывался ли этот вебхук раньше. Повторный запрос не должен, например, создавать ещё одного клиента в CRM.
Ключ нужно брать из настроек аккаунта, а не из входящего запроса. Для передачи вебхуков используйте HTTPS.
Когда нужно обновить ключ#
Если нужно заменить ключ, перевыпустите его в том же разделе настроек. Новый ключ будет общим для всех интеграций аккаунта.
После перевыпуска у вас есть 24 часа, чтобы обновить ключ во всех интеграциях. В это время запросы можно проверить как новым, так и прежним ключом. Затем прежний перестанет работать. Повторный перевыпуск доступен после окончания этих 24 часов.
Если проверка перестала проходить после смены ключа, скопируйте актуальный ключ из настроек и обновите его у получателя.
Примеры для разработчика#
Функции ниже возвращают true, если подпись и время запроса верны. Вызывать их нужно до обработки события. Ключи передаются массивом: обычно это один ключ, а при смене можно временно использовать два.
Передавайте исходное тело запроса, до разбора JSON. Даже изменение пробелов приведёт к ошибке проверки.
| Заголовок | Что передавать в функцию |
|---|---|
Webhook-Timestamp | Время отправки в секундах Unix |
Webhook-Id | ID вебхука |
Webhook-Signature | Подпись запроса |
Для защиты от повторов сохраняйте ID вместе с аккаунтом в общей базе и исключайте одновременную обработку одного ID. Храните ID не меньше 15 минут. Возвращайте 2xx, когда событие надёжно принято; на уже принятый дубль тоже отвечайте 2xx. Примеры ниже эту часть не реализуют. При повторной отправке события с другим ID также нужна защита от повторения самого бизнес-действия.
TypeScript (Node.js 20+)#
Передайте тело как Buffer, значения трёх заголовков как строки и публичные ключи из настроек. В request.headers имена заголовков записаны в нижнем регистре. В Express получите тело через express.raw({type: 'application/json'}) до express.json() на этом маршруте.
import {createPublicKey, verify} from 'node:crypto';
export function verifyWebhook(
rawBody: Buffer,
timestamp: string,
webhookId: string,
signatureHeader: string,
trustedPublicKeys: string[],
now = Math.floor(Date.now() / 1000)
): boolean {
if (!/^[0-9]+$/.test(timestamp)) {
return false;
}
if (!/^[0-9]+$/.test(webhookId)) {
return false;
}
const sentAt = Number(timestamp);
if (!Number.isSafeInteger(sentAt)) {
return false;
}
const timeToleranceSeconds = 300;
if (Math.abs(now - sentAt) > timeToleranceSeconds) {
return false;
}
const messagePrefix = Buffer.from(`${webhookId}.${timestamp}.`);
const message = Buffer.concat([messagePrefix, rawBody]);
for (const encodedPublicKey of trustedPublicKeys) {
if (!encodedPublicKey.startsWith('whpk_')) {
throw new Error('Invalid configured public key');
}
const decodedPublicKey = Buffer.from(encodedPublicKey.slice(5), 'base64');
if (decodedPublicKey.length !== 32) {
throw new Error('Invalid configured public key');
}
// Node.js needs a key container rather than the raw public key bytes.
const verificationKey = createPublicKey({
key: {kty: 'OKP', crv: 'Ed25519', x: decodedPublicKey.toString('base64url')},
format: 'jwk'
});
for (const signatureEntry of signatureHeader.split(' ')) {
const parts = signatureEntry.split(',');
if (parts.length !== 2) {
continue;
}
const [version, encodedSignature] = parts;
if (version !== 'v1a') {
continue;
}
const decodedSignature = Buffer.from(encodedSignature, 'base64');
if (decodedSignature.length !== 64) {
continue;
}
// Buffer.from tolerates malformed Base64, so check the round trip too.
if (decodedSignature.toString('base64') !== encodedSignature) {
continue;
}
if (verify(null, message, verificationKey, decodedSignature)) {
return true;
}
}
}
return false;
}
Go#
Прочитайте исходное тело через io.ReadAll(r.Body). Передайте его, значения заголовков через r.Header.Get(...), публичные ключи и time.Now().
package webhooks
import (
"crypto/ed25519"
"encoding/base64"
"regexp"
"strconv"
"strings"
"time"
)
var decimalDigits = regexp.MustCompile(`^[0-9]+$`)
func VerifyWebhook(
rawBody []byte,
timestamp string,
webhookID string,
signatureHeader string,
trustedPublicKeys []string,
now time.Time,
) bool {
if !decimalDigits.MatchString(timestamp) {
return false
}
if !decimalDigits.MatchString(webhookID) {
return false
}
sentAt, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
const timeToleranceSeconds = 300
if sentAt < now.Unix()-timeToleranceSeconds {
return false
}
if sentAt > now.Unix()+timeToleranceSeconds {
return false
}
messagePrefix := []byte(webhookID + "." + timestamp + ".")
message := append(messagePrefix, rawBody...)
for _, encodedPublicKey := range trustedPublicKeys {
if !strings.HasPrefix(encodedPublicKey, "whpk_") {
panic("invalid configured public key")
}
decodedPublicKey, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(encodedPublicKey, "whpk_"))
if err != nil {
panic("invalid configured public key")
}
if len(decodedPublicKey) != ed25519.PublicKeySize {
panic("invalid configured public key")
}
for _, signatureEntry := range strings.Split(signatureHeader, " ") {
parts := strings.Split(signatureEntry, ",")
if len(parts) != 2 {
continue
}
version, encodedSignature := parts[0], parts[1]
if version != "v1a" {
continue
}
decodedSignature, err := base64.StdEncoding.DecodeString(encodedSignature)
if err != nil {
continue
}
if len(decodedSignature) != ed25519.SignatureSize {
continue
}
if ed25519.Verify(ed25519.PublicKey(decodedPublicKey), message, decodedSignature) {
return true
}
}
}
return false
}
PHP 8+ (Sodium)#
Нужно расширение Sodium. Тело запроса доступно через file_get_contents('php://input'). Заголовки — в $_SERVER['HTTP_WEBHOOK_TIMESTAMP'], $_SERVER['HTTP_WEBHOOK_ID'] и $_SERVER['HTTP_WEBHOOK_SIGNATURE']. Вместо отсутствующего заголовка передайте пустую строку.
<?php
declare(strict_types=1);
function verifyWebhook(
string $rawBody,
string $timestamp,
string $webhookId,
string $signatureHeader,
array $trustedPublicKeys,
?int $now = null,
): bool {
$now ??= time();
if (!preg_match('/^[0-9]+$/D', $timestamp)) {
return false;
}
if (!preg_match('/^[0-9]+$/D', $webhookId)) {
return false;
}
$timeToleranceSeconds = 300;
if (abs($now - (float) $timestamp) > $timeToleranceSeconds) {
return false;
}
$message = $webhookId . '.' . $timestamp . '.' . $rawBody;
foreach ($trustedPublicKeys as $encodedPublicKey) {
if (!str_starts_with($encodedPublicKey, 'whpk_')) {
throw new RuntimeException('Invalid configured public key');
}
$decodedPublicKey = base64_decode(substr($encodedPublicKey, 5), true);
if ($decodedPublicKey === false) {
throw new RuntimeException('Invalid configured public key');
}
if (strlen($decodedPublicKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
throw new RuntimeException('Invalid configured public key');
}
foreach (explode(' ', $signatureHeader) as $signatureEntry) {
$parts = explode(',', $signatureEntry);
if (count($parts) !== 2) {
continue;
}
[$version, $encodedSignature] = $parts;
if ($version !== 'v1a') {
continue;
}
$decodedSignature = base64_decode($encodedSignature, true);
if ($decodedSignature === false) {
continue;
}
if (strlen($decodedSignature) !== SODIUM_CRYPTO_SIGN_BYTES) {
continue;
}
if (sodium_crypto_sign_verify_detached($decodedSignature, $message, $decodedPublicKey)) {
return true;
}
}
}
return false;
}
Формат подписи#
Этот раздел нужен, если вы пишете свою проверку. Публичный ключ имеет вид whpk_<Base64>: перед декодированием 32 байтов Ed25519 удалите префикс whpk_. Подписывается строка из значений заголовков и исходного тела:
Webhook-Id + "." + Webhook-Timestamp + "." + rawBody
В Webhook-Signature передаётся запись v1a,<подпись в Base64>. При смене ключа таких записей две, через пробел; достаточно проверить одну из них доверенным ключом. Дополнительно хешировать сообщение не нужно.
Подписываемые байты, заголовок подписи и формат публичного ключа соответствуют Standard Webhooks.
Если проверка не проходит, проверьте актуальность ключа, часы сервера и то, что тело и заголовки запроса доходят без изменений. Не отключайте проверку, чтобы пропустить такой запрос.
Список событий и их содержимое: Webhooks API.