Сравнение хешей часто используется для проверки подлинности данных: паролей, токенов, подписи сообщений. На первый взгляд операция выглядит тривиальной — два массива байтов сравниваются на равенство. Однако на уровне реализации возникает критически важная проблема: время выполнения сравнения может зависеть от данных.
Большинство наивных реализаций используют ранний выход:
function insecureCompare(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
Такой код завершает выполнение при первом несовпадении. Это означает, что:
Злоумышленник, измеряя время ответа, может побайтно восстанавливать корректное значение хеша.
При наличии удалённого API, который:
злоумышленник может:
Это особенно опасно при:
Даже микросекундные различия могут быть статистически извлечены при большом количестве запросов.
Безопасная функция сравнения должна:
Такой подход называется constant-time comparison (сравнение за постоянное время).
Простейшая стратегия — накопление различий без раннего выхода:
function constantTimeCompare(a, b) {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a[i] ^ b[i];
}
return result === 0;
}
a[i] ^ b[i] возвращает 0, если байты равны|В JavaScript нет гарантии строгого constant-time выполнения на уровне движка, но:
Uint8Array)
обеспечивает более предсказуемое поведениеWeb Crypto API возвращает данные именно в таких форматах, что упрощает безопасную работу.
async function hashData(data) {
const encoder = new TextEncoder();
const encoded = encoder.encode(data);
const hashBuffer = await crypto.subtle.digest("SHA-256", encoded);
return new Uint8Array(hashBuffer);
}
async function verifyHash(input, expectedHash) {
const hash = await hashData(input);
return constantTimeCompare(hash, expectedHash);
}
Попытка сравнивать хеши в виде строк:
if (hashHex === expectedHex) { ... }
опасна по двум причинам:
Правильный подход — работа с байтовыми массивами.
Дополнительные меры:
Особенно критично использовать constant-time при проверке HMAC:
async function verifyHMAC(message, key, expectedMac) {
const encoder = new TextEncoder();
const data = encoder.encode(message);
const cryptoKey = await crypto.subtle.importKey(
"raw",
key,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const macBuffer = await crypto.subtle.sign("HMAC", cryptoKey, data);
const mac = new Uint8Array(macBuffer);
return constantTimeCompare(mac, expectedMac);
}
Несмотря на правильную реализацию:
Тем не менее:
В Node.js существует:
crypto.timingSafeEqual(a, b);
Но в браузере аналог отсутствует, поэтому требуется ручная реализация.
Иногда используется стратегия:
Это снижает риск, но не заменяет constant-time сравнение.
Uint8Array1. Проверка длины с ранним выходом
if (a.length !== b.length) return false;
Допустимо, но может раскрывать информацию о длине. В критичных сценариях длину тоже маскируют.
2. Использование every
a.every((v, i) => v === b[i]);
Останавливается при первом несовпадении.
3. Преобразование в строки
buffer.toString()
Может ввести дополнительные утечки.
Для устранения утечки длины:
function constantTimeCompareSafe(a, b) {
let result = a.length ^ b.length;
const length = Math.max(a.length, b.length);
for (let i = 0; i < length; i++) {
const x = a[i % a.length];
const y = b[i % b.length];
result |= x ^ y;
}
return result === 0;
}
Такой подход:
Даже идеальный алгоритм хеширования (SHA-256, SHA-512) становится уязвимым, если:
Безопасность криптографии определяется не только алгоритмами, но и деталями реализации.
Constant-time сравнение необходимо в:
Игнорирование этого аспекта превращает криптографически надёжную систему в уязвимую.
Безопасная проверка хеша включает:
Uint8ArrayТакая модель устраняет один из наиболее тонких и часто недооцениваемых классов атак в веб-криптографии.