Постоянное время сравнения строк в Node.js

В системах аутентификации одной из наиболее недооценённых угроз остаются атаки по времени выполнения (timing attacks). Они основаны на том, что различия во времени сравнения строк могут раскрывать информацию о секретных данных, включая хеши паролей, токены и подписи.

Стандартное сравнение строк в JavaScript выполняется посимвольно и завершается сразу при обнаружении первого различия. Это означает, что время выполнения операции зависит от совпадающего префикса строк.

Пример:

  • "abcdef" и "abcxyz" сравниваются быстрее, чем
  • "abcdef" и "abcdex"

Такое поведение создаёт утечку информации: атакующий может измерять время ответа сервера и постепенно восстанавливать секрет.

В Node.js это особенно критично в контексте:

  • проверки паролей
  • сравнения HMAC-подписей
  • проверки JWT-токенов
  • сверки хешей из библиотеки password-hash и аналогичных решений

Почему криптографические сравнения должны быть постоянного времени

Постоянное время (constant-time) означает, что операция выполняется за одинаковое время независимо от входных данных.

Ключевая идея:

  • время сравнения не зависит от совпадения символов
  • отсутствуют ранние выходы из функции
  • все байты обрабатываются полностью

Это исключает возможность статистического анализа времени ответа.

Уязвимость на примере проверки пароля

При использовании библиотек хеширования паролей, например password-hash, типичный поток выглядит так:

  • пользователь вводит пароль
  • пароль хешируется
  • результат сравнивается с сохранённым хешем

Ошибочная реализация может выглядеть так:

if (hash === storedHash) {
  // доступ разрешён
}

Хотя оператор === в JavaScript кажется безопасным, на уровне движка сравнение строк может завершаться раньше при несовпадении, что делает поведение потенциально измеримым.

Решение в Node.js: crypto.timingSafeEqual

Node.js предоставляет встроенный механизм для безопасного сравнения буферов:

const crypto = require('crypto');

const a = Buffer.from('secret1');
const b = Buffer.from('secret2');

crypto.timingSafeEqual(a, b);

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

  • сравниваются только Buffer или Uint8Array
  • длины должны совпадать, иначе выбрасывается исключение
  • операция выполняется за постоянное время

Важно учитывать проверку длины отдельно:

function safeEqual(a, b) {
  const aBuf = Buffer.from(a);
  const bBuf = Buffer.from(b);

  if (aBuf.length !== bBuf.length) {
    return false;
  }

  return crypto.timingSafeEqual(aBuf, bBuf);
}

Применение в системах хеширования паролей

При использовании password-hash и подобных библиотек важно понимать, что сами хеши не сравниваются напрямую как строки в критических местах.

Правильный подход:

  • хранить результат хеширования (например, bcrypt/scrypt/argon2)
  • извлекать и сравнивать через криптографически безопасные методы
  • избегать прямого === для чувствительных данных

Пример интеграционного слоя:

const crypto = require('crypto');

function verifyPassword(inputPassword, storedHash) {
  const inputHash = hashPassword(inputPassword); // условная функция

  const a = Buffer.from(inputHash);
  const b = Buffer.from(storedHash);

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

  return crypto.timingSafeEqual(a, b);
}

Типичные ошибки разработчиков

1. Сравнение строк через ===

Самая распространённая ошибка — доверие оператору строгого равенства.

2. Преждевременный выход

for (let i = 0; i < a.length; i++) {
  if (a[i] !== b[i]) return false;
}

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

3. Сравнение разных типов данных

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

Связь с криптографическими хеш-функциями

Даже если используется сильный алгоритм хеширования (bcrypt, scrypt, argon2), проблема timing attack остаётся на этапе сравнения результатов.

Хеширование защищает данные математически, но не защищает от утечек через реализацию.

Особенности работы с Buffer

Node.js работает с бинарными данными через Buffer, и это основной формат для безопасных сравнений.

Ключевые правила:

  • всегда приводить строки к Buffer
  • избегать Unicode-несогласованности без нормализации
  • учитывать кодировки (utf8, hex, base64)

Пример:

Buffer.from(hash, 'hex');

Защитная архитектура проверки паролей

В реальных системах безопасная схема включает:

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

Пример:

const crypto = require('crypto');

function verify(stored, input) {
  const inputBuf = Buffer.from(input);
  const storedBuf = Buffer.from(stored);

  if (storedBuf.length !== inputBuf.length) {
    return false;
  }

  return crypto.timingSafeEqual(storedBuf, inputBuf);
}

Производительность и побочные эффекты

Постоянное время сравнения иногда воспринимается как более медленное решение. На практике:

  • разница минимальна для типичных размеров хешей
  • стоимость операции линейная и фиксированная
  • исключается риск утечки через микроразличия времени

В криптографических системах безопасность важнее микропроизводительности.

Интеграция с password-hash

При использовании библиотеки password-hash важно разделять:

  • генерацию хеша
  • проверку подлинности
  • сравнение результатов

password-hash отвечает за создание хеша, но безопасное сравнение должно выполняться на уровне приложения через криптографические инструменты Node.js.

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

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

Обобщённая схема:

  1. Нормализация входных данных
  2. Преобразование в бинарный формат
  3. Проверка длины
  4. Постоянное по времени сравнение

Эта модель применяется не только к паролям, но и к API-ключам, токенам сессий и цифровым подписям.