Сравнение хешей без timing-атак

Сравнение хешей часто используется для проверки подлинности данных: паролей, токенов, подписи сообщений. На первый взгляд операция выглядит тривиальной — два массива байтов сравниваются на равенство. Однако на уровне реализации возникает критически важная проблема: время выполнения сравнения может зависеть от данных.

Большинство наивных реализаций используют ранний выход:

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, который:

  • принимает значение (например, токен),
  • сравнивает его с эталонным,
  • возвращает ответ,

злоумышленник может:

  1. отправлять различные варианты токена,
  2. измерять время ответа,
  3. определять, сколько первых байтов совпало.

Это особенно опасно при:

  • проверке HMAC
  • сравнении API-ключей
  • верификации подписей

Даже микросекундные различия могут быть статистически извлечены при большом количестве запросов.


Требования к безопасному сравнению

Безопасная функция сравнения должна:

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

Такой подход называется constant-time comparison (сравнение за постоянное время).


Реализация constant-time сравнения

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

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, если байты равны
  • все различия накапливаются через |
  • цикл всегда выполняется полностью
  • результат равен 0 только при полном совпадении

Особенности JavaScript и Web Crypto API

В JavaScript нет гарантии строгого constant-time выполнения на уровне движка, но:

  • минимизация ветвлений снижает риск
  • использование TypedArray (например, Uint8Array) обеспечивает более предсказуемое поведение

Web Crypto API возвращает данные именно в таких форматах, что упрощает безопасную работу.


Получение хеша через 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) { ... }

опасна по двум причинам:

  1. строки сравниваются с ранним выходом
  2. возможны различия в форматировании (регистр, padding)

Правильный подход — работа с байтовыми массивами.


Оптимизация и защита от утечек

Дополнительные меры:

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

Сравнение HMAC

Особенно критично использовать 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);
}

Ограничения браузерной среды

Несмотря на правильную реализацию:

  • JavaScript не гарантирует абсолютное constant-time
  • JIT-компиляция может оптимизировать код
  • тайминги могут зависеть от среды выполнения

Тем не менее:

  • описанный подход существенно снижает риск
  • считается практическим стандартом защиты

Альтернативные подходы

1. Использование встроенных средств (Node.js)

В Node.js существует:

crypto.timingSafeEqual(a, b);

Но в браузере аналог отсутствует, поэтому требуется ручная реализация.


2. Двойное хеширование

Иногда используется стратегия:

  • сравнивать не сами данные, а их повторно захешированные версии

Это снижает риск, но не заменяет constant-time сравнение.


Практические рекомендации

  • всегда сравнивать хеши как Uint8Array
  • избегать раннего выхода
  • использовать XOR-накопление различий
  • не сравнивать строки
  • минимизировать утечки через логирование
  • учитывать, что безопасность зависит от всей системы, а не только от функции сравнения

Типичные ошибки

1. Проверка длины с ранним выходом

if (a.length !== b.length) return false;

Допустимо, но может раскрывать информацию о длине. В критичных сценариях длину тоже маскируют.


2. Использование every

a.every((v, i) => v === b[i]);

Останавливается при первом несовпадении.


3. Преобразование в строки

buffer.toString()

Может ввести дополнительные утечки.


Расширенная версия constant-time

Для устранения утечки длины:

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 сравнение необходимо в:

  • аутентификации пользователей
  • проверке JWT и токенов
  • API-ключах
  • webhook-подписях
  • системах single sign-on

Игнорирование этого аспекта превращает криптографически надёжную систему в уязвимую.


Итоговая модель

Безопасная проверка хеша включает:

  1. Получение хеша через Web Crypto API
  2. Представление в виде Uint8Array
  3. Constant-time сравнение
  4. Минимизацию побочных утечек

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