Проверка подписи на стороне сервера

Проверка цифровой подписи строится вокруг асимметричной криптографии: отправитель формирует подпись с помощью приватного ключа, а получатель проверяет её с использованием публичного ключа. Web Crypto API предоставляет для этого низкоуровневый интерфейс через crypto.subtle.verify.

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

Типовая схема включает следующие элементы:

  • исходное сообщение (payload)
  • цифровая подпись
  • публичный ключ отправителя
  • алгоритм подписи

Форматы данных и предварительная подготовка

WebCrypto работает исключительно с ArrayBuffer, поэтому любые строки, JSON или бинарные данные должны быть преобразованы.

Кодирование строки в ArrayBuffer

function encodeText(text) {
  return new TextEncoder().encode(text);
}

Декодирование base64 подписи

Часто подпись передаётся в base64, поэтому требуется преобразование:

function base64ToBuffer(base64) {
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);

  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }

  return bytes.buffer;
}

Импорт публичного ключа

Перед проверкой подписи ключ необходимо импортировать в формате spki (для RSA/ECDSA) или другом подходящем формате.

Пример импорта RSA-PSS ключа

async function importPublicKey(spkiBase64) {
  const binaryDer = base64ToBuffer(spkiBase64);

  return await crypto.subtle.importKey(
    "spki",
    binaryDer,
    {
      name: "RSA-PSS",
      hash: { name: "SHA-256" }
    },
    false,
    ["verify"]
  );
}

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

WebCrypto поддерживает несколько основных схем:

  • RSA-PSS
  • RSASSA-PKCS1-v1_5
  • ECDSA
  • HMAC (симметричный вариант)

Наиболее часто в серверной проверке используются RSA-PSS и ECDSA.


Проверка подписи RSA-PSS

Структура проверки

async function verifySignature({ publicKey, data, signature }) {
  const encodedData = encodeText(data);
  const signatureBuffer = base64ToBuffer(signature);

  return await crypto.subtle.verify(
    {
      name: "RSA-PSS",
      saltLength: 32
    },
    publicKey,
    signatureBuffer,
    encodedData
  );
}

Важные параметры

  • saltLength должен совпадать с параметрами подписи на стороне отправителя
  • хеш-алгоритм определяется при импорте ключа
  • любые расхождения приводят к провалу проверки

Проверка подписи ECDSA

ECDSA требует особого внимания к формату подписи: WebCrypto ожидает DER-кодировку.

async function verifyECDSA({ publicKey, data, signature }) {
  const encodedData = encodeText(data);
  const signatureBuffer = base64ToBuffer(signature);

  return await crypto.subtle.verify(
    {
      name: "ECDSA",
      hash: { name: "SHA-256" }
    },
    publicKey,
    signatureBuffer,
    encodedData
  );
}

Полный серверный сценарий проверки

Типичный серверный поток включает следующие этапы:

  1. получение payload
  2. получение подписи из заголовков или тела запроса
  3. загрузка публичного ключа отправителя
  4. нормализация данных
  5. проверка подписи
async function handleRequest(req) {
  const body = req.body; // строка или сериализованный JSON
  const signature = req.headers["x-signature"];
  const publicKey = await importPublicKey(req.senderPublicKey);

  const isValid = await crypto.subtle.verify(
    {
      name: "RSA-PSS",
      saltLength: 32
    },
    publicKey,
    base64ToBuffer(signature),
    encodeText(body)
  );

  if (!isValid) {
    throw new Error("Invalid signature");
  }

  return JSON.parse(body);
}

Каноникализация данных

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

Подпись считается действительной только при полном совпадении байтовой последовательности.

Типичные источники ошибок:

  • различия в пробелах JSON
  • изменение порядка ключей
  • разные кодировки
  • добавление/удаление переносов строк

Стабильная сериализация JSON

function stableStringify(obj) {
  if (obj === null || typeof obj !== "object") {
    return JSON.stringify(obj);
  }

  if (Array.isArray(obj)) {
    return `[${obj.map(stableStringify).join(",")}]`;
  }

  const keys = Object.keys(obj).sort();
  const pairs = keys.map(
    key => `${JSON.stringify(key)}:${stableStringify(obj[key])}`
  );

  return `{${pairs.join(",")}}`;
}

Хеширование перед подписью

В некоторых схемах подпись создаётся не от исходного сообщения, а от его хеша.

WebCrypto позволяет явно вычислять digest:

async function hashMessage(data) {
  const encoded = encodeText(data);

  return await crypto.subtle.digest("SHA-256", encoded);
}

Частые ошибки при проверке подписи

Несовпадение алгоритма

Подпись может быть создана с одним алгоритмом, а проверка выполняется с другим:

  • SHA-1 vs SHA-256
  • RSA-PSS vs PKCS1-v1_5

Ошибки кодирования

Подпись может быть:

  • base64
  • base64url
  • hex

Любое несоответствие приводит к неверной проверке.


Изменение данных после подписи

Даже минимальные изменения:

  • пробел
  • перенос строки
  • изменение порядка JSON-ключей

полностью ломают проверку.


Безопасные практики обработки подписи

Изоляция проверки

Проверка подписи должна выполняться до любой бизнес-логики:

if (!(await verifySignature(...))) {
  return { status: 401 };
}

Защита от replay-атак

Одной проверки подписи недостаточно. Дополнительно используются:

  • timestamp
  • nonce
  • одноразовые токены

Пример проверки времени:

function checkTimestamp(ts) {
  const now = Date.now();
  const diff = Math.abs(now - ts);

  return diff < 5 * 60 * 1000;
}

Использование постоянных ключей

Публичные ключи должны:

  • храниться в безопасном хранилище
  • регулярно ротироваться
  • иметь идентификаторы (key id)

Интеграция с JWT-подобными структурами

Часто подпись проверяется не для “сырого тела”, а для токена формата:

header.payload.signature

Проверка выполняется только над header.payload:

const [header, payload, signature] = token.split(".");

const data = `${header}.${payload}`;

const valid = await crypto.subtle.verify(
  { name: "RSA-PSS", saltLength: 32 },
  publicKey,
  base64ToBuffer(signature),
  encodeText(data)
);

Производительность проверки

В серверных системах проверка подписи становится узким местом при высокой нагрузке.

Оптимизации:

  • кэширование публичных ключей
  • переиспользование импортированных CryptoKey
  • минимизация преобразований данных
  • отказ от лишнего JSON parsing до проверки

Особенности окружения Node.js

В Node.js WebCrypto доступен через:

globalThis.crypto.subtle

или через:

import { webcrypto } from "crypto";
const { subtle } = webcrypto;

Различия между реализациями могут проявляться в:

  • поддерживаемых алгоритмах
  • форматах ключей
  • производительности

Диагностика ошибок проверки

При отладке важно сравнивать:

  • исходные байты сообщения
  • точный base64 подписи
  • параметры алгоритма
  • импорт ключа

Полезная практика — логирование длины данных:

console.log(encodedData.byteLength);
console.log(signatureBuffer.byteLength);

Криптографическая устойчивость реализации

Безопасность зависит не только от алгоритма, но и от корректности его применения:

  • запрет ручного преобразования криптоданных без необходимости
  • строгая типизация входных данных
  • отсутствие “мягких” сравнений результата
  • использование встроенного crypto.subtle.verify без кастомной логики сравнения подписи