Проверка цифровой подписи строится вокруг асимметричной криптографии:
отправитель формирует подпись с помощью приватного ключа, а получатель
проверяет её с использованием публичного ключа. Web Crypto API
предоставляет для этого низкоуровневый интерфейс через
crypto.subtle.verify.
Ключевой принцип заключается в том, что сервер не доверяет входящим данным, пока не подтверждена их целостность и подлинность через криптографическую проверку.
Типовая схема включает следующие элементы:
WebCrypto работает исключительно с ArrayBuffer, поэтому
любые строки, JSON или бинарные данные должны быть преобразованы.
function encodeText(text) {
return new TextEncoder().encode(text);
}
Часто подпись передаётся в 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) или другом подходящем формате.
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 и ECDSA.
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 требует особого внимания к формату подписи: 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
);
}
Типичный серверный поток включает следующие этапы:
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);
}
Одной из ключевых проблем проверки подписи является несовпадение входных данных между сторонами.
Подпись считается действительной только при полном совпадении байтовой последовательности.
Типичные источники ошибок:
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);
}
Подпись может быть создана с одним алгоритмом, а проверка выполняется с другим:
Подпись может быть:
Любое несоответствие приводит к неверной проверке.
Даже минимальные изменения:
полностью ломают проверку.
Проверка подписи должна выполняться до любой бизнес-логики:
if (!(await verifySignature(...))) {
return { status: 401 };
}
Одной проверки подписи недостаточно. Дополнительно используются:
Пример проверки времени:
function checkTimestamp(ts) {
const now = Date.now();
const diff = Math.abs(now - ts);
return diff < 5 * 60 * 1000;
}
Публичные ключи должны:
Часто подпись проверяется не для “сырого тела”, а для токена формата:
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)
);
В серверных системах проверка подписи становится узким местом при высокой нагрузке.
Оптимизации:
В Node.js WebCrypto доступен через:
globalThis.crypto.subtle
или через:
import { webcrypto } from "crypto";
const { subtle } = webcrypto;
Различия между реализациями могут проявляться в:
При отладке важно сравнивать:
Полезная практика — логирование длины данных:
console.log(encodedData.byteLength);
console.log(signatureBuffer.byteLength);
Безопасность зависит не только от алгоритма, но и от корректности его применения:
crypto.subtle.verify без
кастомной логики сравнения подписи