Подпись документов и произвольных данных

Криптографическая подпись в Web Crypto API строится вокруг интерфейса SubtleCrypto, доступного через crypto.subtle. Основная идея заключается в том, что данные преобразуются в фиксированную хэш-сумму, а затем эта сумма подписывается закрытым ключом. Проверка выполняется с использованием открытого ключа или общего секретного ключа в зависимости от выбранного алгоритма.

Поддерживаются три основных подхода:

  • HMAC (симметричная подпись)
  • RSA-PSS (асимметричная подпись на основе RSA)
  • ECDSA (асимметричная подпись на эллиптических кривых)

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


Базовый поток работы с подписью

Любая операция подписи в Web Crypto API сводится к следующей последовательности:

  1. Преобразование данных в бинарный формат (ArrayBuffer)
  2. Генерация или импорт криптографического ключа
  3. Подпись данных методом sign
  4. Проверка подписи методом verify

Ключевой особенностью API является работа исключительно с бинарными данными. Строки и объекты должны быть явно сериализованы.

Пример преобразования строки:

const encoder = new TextEncoder();
const data = encoder.encode("message");

HMAC: симметричная подпись данных

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

Генерация ключа

const key = await crypto.subtle.generateKey(
  {
    name: "HMAC",
    hash: "SHA-256"
  },
  true,
  ["sign", "verify"]
);
  • hash определяет алгоритм хэширования
  • extractable: true позволяет экспортировать ключ
  • keyUsages определяет допустимые операции

Подпись данных

const signature = await crypto.subtle.sign(
  "HMAC",
  key,
  data
);

Результат — ArrayBuffer, содержащий подпись.


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

const isValid = await crypto.subtle.verify(
  "HMAC",
  key,
  signature,
  data
);

Если данные были изменены хотя бы на один байт, проверка вернёт false.


RSA-PSS: асимметричная подпись с усиленной стойкостью

RSA-PSS (Probabilistic Signature Scheme) считается более безопасной версией классической RSA-подписи за счёт использования случайной соли.

Генерация ключевой пары

const keyPair = await crypto.subtle.generateKey(
  {
    name: "RSA-PSS",
    modulusLength: 2048,
    publicExponent: new Uint8Array([1, 0, 1]),
    hash: "SHA-256"
  },
  true,
  ["sign", "verify"]
);
  • modulusLength определяет длину ключа в битах
  • publicExponent обычно фиксирован как 65537
  • hash задаёт алгоритм хэширования перед подписью

Подпись приватным ключом

const signature = await crypto.subtle.sign(
  {
    name: "RSA-PSS",
    saltLength: 32
  },
  keyPair.privateKey,
  data
);

Параметр saltLength влияет на криптографическую стойкость.


Проверка публичным ключом

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

Важно, чтобы параметры подписи и проверки совпадали, иначе результат будет некорректным.


ECDSA: подпись на эллиптических кривых

ECDSA обеспечивает сопоставимую с RSA стойкость при меньшем размере ключей, что делает его предпочтительным в браузерной криптографии.

Генерация ключей

const keyPair = await crypto.subtle.generateKey(
  {
    name: "ECDSA",
    namedCurve: "P-256"
  },
  true,
  ["sign", "verify"]
);

Наиболее распространённые кривые:

  • P-256 — баланс безопасности и производительности
  • P-384 — повышенная стойкость
  • P-521 — максимальная стойкость

Подпись данных

const signature = await crypto.subtle.sign(
  {
    name: "ECDSA",
    hash: "SHA-256"
  },
  keyPair.privateKey,
  data
);

ECDSA требует обязательного указания хэш-функции.


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

const isValid = await crypto.subtle.verify(
  {
    name: "ECDSA",
    hash: "SHA-256"
  },
  keyPair.publicKey,
  signature,
  data
);

Подпись произвольных структур данных

Поскольку Web Crypto API работает только с бинарными буферами, сложные структуры необходимо сериализовать.

Типичный подход:

const obj = {
  user: "alice",
  role: "admin",
  timestamp: 1710000000
};

const encoder = new TextEncoder();
const data = encoder.encode(JSON.stringify(obj));

Важно учитывать:

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

Для стабильности часто используют детерминированную сериализацию.


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

Во всех асимметричных схемах (RSA-PSS, ECDSA) Web Crypto API выполняет хэширование автоматически, но иногда требуется явное вычисление хэша:

const hashBuffer = await crypto.subtle.digest("SHA-256", data);

Это полезно в сценариях:

  • предварительной проверки данных
  • хранения хэша вместо полного сообщения
  • построения цепочек подписей

Импорт и экспорт ключей

Ключи можно сохранять и восстанавливать:

Экспорт

const exported = await crypto.subtle.exportKey(
  "pkcs8",
  privateKey
);

или

const exportedPublic = await crypto.subtle.exportKey(
  "spki",
  publicKey
);

Импорт

const key = await crypto.subtle.importKey(
  "pkcs8",
  exported,
  {
    name: "RSA-PSS",
    hash: "SHA-256"
  },
  true,
  ["sign"]
);

Форматы:

  • pkcs8 — приватные ключи
  • spki — публичные ключи
  • raw — для симметричных ключей (HMAC)

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

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

  1. Клиент формирует сообщение
  2. Сериализует данные в ArrayBuffer
  3. Подписывает приватным ключом
  4. Отправляет {data, signature}
  5. Сервер проверяет подпись публичным ключом

Пример структуры:

{
  data: "eyJ1c2VyIjoiYWxpY2UifQ==",
  signature: "base64..."
}

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

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

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

  • hash
  • saltLength (RSA-PSS)
  • namedCurve (ECDSA)

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

Любое изменение байтового представления делает подпись недействительной:

  • перенос строки
  • пробел
  • изменение кодировки

Использование неподходящего ключа

  • приватный ключ только для sign
  • публичный только для verify

Неправильная сериализация

JSON без детерминированного порядка ключей приводит к невозможности проверки подписи.


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

HMAC

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

RSA-PSS

  • высокая совместимость
  • большие ключи
  • устойчивость к атакам на структуру RSA

ECDSA

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

Особенности браузерной реализации Web Crypto API

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

Работа с кодировками и бинарными данными

Web Crypto API не работает со строками напрямую. Используются:

  • TextEncoder для преобразования строк → Uint8Array
  • TextDecoder для обратного преобразования
  • ArrayBuffer как основной формат передачи данных

Пример:

const encoder = new TextEncoder();
const data = encoder.encode("secure message");

Безопасное хранение подписанных данных

Подписанные данные часто хранятся или передаются в следующих форматах:

  • Base64
  • Hex
  • ArrayBuffer (внутреннее использование)

Конвертация:

function toBase64(buffer) {
  return btoa(String.fromCharCode(...new Uint8Array(buffer)));
}

Типовой шаблон подписания и проверки

// Подпись
const signature = await crypto.subtle.sign(
  { name: "ECDSA", hash: "SHA-256" },
  privateKey,
  data
);

// Проверка
const valid = await crypto.subtle.verify(
  { name: "ECDSA", hash: "SHA-256" },
  publicKey,
  signature,
  data
);

Модель доверия в системах подписи

Криптографическая подпись не защищает данные сама по себе, она лишь обеспечивает:

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

Реальная безопасность зависит от:

  • защиты приватного ключа
  • корректной валидации публичных ключей
  • устойчивой схемы передачи данных