Документация

Проверка подписи вебхуков

Подпись помогает вашей системе отличить настоящий вебхук от поддельного и проверить, что его содержимое не изменили по дороге. Например, прежде чем создать клиента в CRM, система сможет убедиться, что запрос пришёл от нужного отправителя.

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

Как включить#

  1. Войдите как администратор. Откройте настройки канала «Для разработчика» или раздел вебхуков CRM.
  2. Включите подпись и скопируйте публичный ключ.
  3. Добавьте ключ в настройки системы, которая принимает вебхуки, и включите проверку подписи.

Один ключ подходит для всех вебхуков аккаунта: каналов, CRM и API-подписок. Если подпись не включена, вебхуки работают как прежде.

Что должен проверять получатель#

Перед выполнением действий ваша система должна:

  • Проверить подпись ключом из настроек аккаунта. Если подписи нет или она неверна, отклонить запрос.
  • Проверить время отправки. Допустимая разница с часами вашего сервера — не больше 5 минут в любую сторону.
  • Проверить, не обрабатывался ли этот вебхук раньше. Повторный запрос не должен, например, создавать ещё одного клиента в CRM.

Ключ нужно брать из настроек аккаунта, а не из входящего запроса. Для передачи вебхуков используйте HTTPS.

Когда нужно обновить ключ#

Если нужно заменить ключ, перевыпустите его в том же разделе настроек. Новый ключ будет общим для всех интеграций аккаунта.

После перевыпуска у вас есть 24 часа, чтобы обновить ключ во всех интеграциях. В это время запросы можно проверить как новым, так и прежним ключом. Затем прежний перестанет работать. Повторный перевыпуск доступен после окончания этих 24 часов.

Если проверка перестала проходить после смены ключа, скопируйте актуальный ключ из настроек и обновите его у получателя.

Примеры для разработчика#

Функции ниже возвращают true, если подпись и время запроса верны. Вызывать их нужно до обработки события. Ключи передаются массивом: обычно это один ключ, а при смене можно временно использовать два.

Передавайте исходное тело запроса, до разбора JSON. Даже изменение пробелов приведёт к ошибке проверки.

ЗаголовокЧто передавать в функцию
Webhook-TimestampВремя отправки в секундах Unix
Webhook-IdID вебхука
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.