Практика: подпись HTTP-запросов в браузере

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

Web Crypto API доступен только в контексте защищённого происхождения (HTTPS или localhost) и предоставляет низкоуровневые криптографические операции: хэширование, генерацию ключей, шифрование и создание цифровых подписей. Центральный объект — SubtleCrypto, доступный через window.crypto.subtle.


Перед подписью HTTP-запросов определяется тип криптографического механизма. В браузерной практике чаще используются два подхода:

  • симметричная подпись (HMAC, чаще SHA-256)
  • асимметричная подпись (RSA-PSS или ECDSA)

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


Подготовка ключа HMAC через Web Crypto API

Ключ для HMAC не хранится в виде строки. Он импортируется в формате CryptoKey:

const encoder = new TextEncoder();

const secret = encoder.encode("super-secret-key");

const cryptoKey = await crypto.subtle.importKey(
  "raw",
  secret,
  {
    name: "HMAC",
    hash: "SHA-256"
  },
  false,
  ["sign", "verify"]
);

Ключ становится непрямо доступным объектом, с которым работает только Web Crypto API.


Канонизация HTTP-запроса

Ключевая часть подписи — формирование канонической строки. Она должна быть детерминированной: одинаковый запрос всегда приводит к одинаковому результату.

Обычно включаются:

  • HTTP-метод
  • путь запроса
  • строка параметров
  • тело запроса (или его хэш)
  • временная метка
  • дополнительные заголовки (например, content-type)

Пример канонической структуры:

METHOD\n
PATH\n
QUERY_STRING\n
BODY_HASH\n
TIMESTAMP

Формирование на Jav * aScript:

async function createCanonicalRequest(method, url, body, timestamp) {
  const parsedUrl = new URL(url);

  const encoder = new TextEncoder();

  const bodyBuffer = encoder.encode(body || "");
  const bodyHashBuffer = await crypto.subtle.digest("SHA-256", bodyBuffer);
  const bodyHash = Array.from(new Uint8Array(bodyHashBuffer))
    .map(b => b.toString(16).padStart(2, "0"))
    .join("");

  return [
    method.toUpperCase(),
    parsedUrl.pathname,
    parsedUrl.searchParams.toString(),
    bodyHash,
    timestamp
  ].join("\n");
}

Преобразование данных в подпись

После формирования строки она подписывается через crypto.subtle.sign.

async function signRequest(key, canonicalString) {
  const encoder = new TextEncoder();
  const data = encoder.encode(canonicalString);

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

  return btoa(String.fromCharCode(...new Uint8Array(signatureBuffer)));
}

На практике Base64 часто заменяется на Base64URL для безопасной передачи в HTTP-заголовках:

function toBase64Url(buffer) {
  return btoa(String.fromCharCode(...new Uint8Array(buffer)))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

Добавление подписи в HTTP-запрос

Подпись обычно передаётся в заголовках:

async function signedFetch(url, options = {}, key) {
  const method = options.method || "GET";
  const body = options.body || "";
  const timestamp = Date.now().toString();

  const canonical = await createCanonicalRequest(method, url, body, timestamp);
  const signature = await signRequest(key, canonical);

  const headers = new Headers(options.headers || {});
  headers.set("X-Signature", signature);
  headers.set("X-Timestamp", timestamp);

  return fetch(url, {
    ...options,
    headers
  });
}

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

Серверная сторона повторяет ту же процедуру:

  1. Извлекает метод, путь, параметры, тело и timestamp
  2. Формирует каноническую строку
  3. Вычисляет HMAC с тем же секретом
  4. Сравнивает результат с подписью из заголовка

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


Защита от повторной отправки запросов

Подпись без временного контекста уязвима для replay-атак. Поэтому используется timestamp и иногда nonce:

  • timestamp ограничивает срок действия запроса
  • nonce предотвращает повтор одного и того же запроса в пределах окна времени

Проверка на сервере включает сравнение времени и хранение использованных nonce.


Работа с асимметричной подписью (RSA-PSS)

При использовании RSA ключ импортируется иначе:

const publicKey = await crypto.subtle.importKey(
  "spki",
  publicKeyBuffer,
  {
    name: "RSA-PSS",
    hash: "SHA-256"
  },
  true,
  ["verify"]
);

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

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

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


Особенности сериализации данных

Web Crypto API работает с ArrayBuffer, поэтому любая строка или JSON проходит этап кодирования:

  • TextEncoder для UTF-8
  • ручное преобразование бинарных данных в Base64URL
  • аккуратная обработка JSON без лишних пробелов

Любое различие в сериализации между клиентом и сервером приводит к разным хэшам.


Хэширование тела запроса

Для больших payload тело не включается напрямую, а хэшируется:

const bodyHashBuffer = await crypto.subtle.digest(
  "SHA-256",
  new TextEncoder().encode(body)
);

Это уменьшает размер канонической строки и стабилизирует подпись независимо от передачи тела.


Интеграция с fetch и промежуточные слои

Часто создаётся обёртка над fetch, внедряющая подпись автоматически. Такой слой выполняет:

  • нормализацию URL
  • сериализацию тела
  • добавление заголовков подписи
  • управление временем жизни токена

При этом важно избегать изменения запроса между этапом подписи и отправкой, иначе подпись станет недействительной.


Типичные ошибки реализации

На практике нарушения целостности подписи возникают из-за:

  • различий в порядке query-параметров
  • отсутствия нормализации пути (/api vs /api/)
  • неучтённого encoding (UTF-8 vs Latin-1)
  • различий в сериализации JSON
  • изменения тела после вычисления подписи
  • использования Date.now() без синхронизации времени

Безопасные ограничения Web Crypto API

Модель безопасности браузера накладывает ограничения:

  • ключи с флагом extractable: false нельзя экспортировать
  • доступ только в secure context
  • отсутствует синхронный API
  • невозможность доступа к raw memory ключей

Эти ограничения уменьшают риск утечки ключей при компрометации JavaScript-контекста.


Архитектурная модель подписи HTTP-запросов

Типовая схема взаимодействия:

  1. Формирование запроса
  2. Канонизация
  3. Вычисление хэша тела
  4. Подпись строки через Web Crypto API
  5. Добавление подписи в заголовки
  6. Отправка запроса
  7. Повторная канонизация на сервере
  8. Проверка подписи и временных параметров

Такая модель делает запросы криптографически проверяемыми без передачи паролей или токенов в открытом виде.