Подпись HTTP-запроса в браузере с использованием Web Crypto API
строится вокруг формирования канонической строки запроса и
криптографического вычисления подписи через crypto.subtle.
Основная цель такого подхода — обеспечить целостность данных, защиту от
подмены параметров и возможность серверной проверки подлинности клиента
без передачи секретов в открытом виде.
Web Crypto API доступен только в контексте защищённого происхождения
(HTTPS или localhost) и предоставляет низкоуровневые криптографические
операции: хэширование, генерацию ключей, шифрование и создание цифровых
подписей. Центральный объект — SubtleCrypto, доступный
через window.crypto.subtle.
Перед подписью HTTP-запросов определяется тип криптографического механизма. В браузерной практике чаще используются два подхода:
Для клиентских веб-приложений HMAC применяется чаще из-за простоты, однако требует хранения общего секрета, что накладывает ограничения по безопасности. Асимметричная схема позволяет хранить приватный ключ только на клиенте, но усложняет инфраструктуру.
Ключ для 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.
Ключевая часть подписи — формирование канонической строки. Она должна быть детерминированной: одинаковый запрос всегда приводит к одинаковому результату.
Обычно включаются:
Пример канонической структуры:
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(/=+$/, "");
}
Подпись обычно передаётся в заголовках:
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
});
}
Серверная сторона повторяет ту же процедуру:
Критически важным моментом является строгое совпадение алгоритма канонизации. Любое расхождение в кодировке, порядке параметров или обработке тела запроса приводит к невалидной подписи.
Подпись без временного контекста уязвима для replay-атак. Поэтому используется timestamp и иногда nonce:
Проверка на сервере включает сравнение времени и хранение использованных nonce.
При использовании 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Любое различие в сериализации между клиентом и сервером приводит к разным хэшам.
Для больших payload тело не включается напрямую, а хэшируется:
const bodyHashBuffer = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(body)
);
Это уменьшает размер канонической строки и стабилизирует подпись независимо от передачи тела.
Часто создаётся обёртка над fetch, внедряющая подпись
автоматически. Такой слой выполняет:
При этом важно избегать изменения запроса между этапом подписи и отправкой, иначе подпись станет недействительной.
На практике нарушения целостности подписи возникают из-за:
/api vs
/api/)Date.now() без синхронизации времениМодель безопасности браузера накладывает ограничения:
extractable: false нельзя
экспортироватьЭти ограничения уменьшают риск утечки ключей при компрометации JavaScript-контекста.
Типовая схема взаимодействия:
Такая модель делает запросы криптографически проверяемыми без передачи паролей или токенов в открытом виде.