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

JavaScript-оператор строгого равенства === выполняет сравнение с оптимизациями движка V8 и других JS-движков, ориентированными на скорость, а не на безопасность. В криптографическом контексте это приводит к фундаментальной проблеме: время выполнения сравнения может зависеть от содержимого сравниваемых данных.

При обычной логике приложения важно лишь, совпадают ли значения. В криптографии важно ещё и то, как именно происходит это сравнение.

Оператор === в типичном движке Jav * aScript:

  • завершает сравнение при первом несовпадении байтов;
  • может выполнять разные ветвления в зависимости от входных данных;
  • позволяет JIT-компилятору оптимизировать «частые случаи»;
  • не гарантирует одинаковое число операций для любых входов.

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

Побочные каналы через время выполнения

Если сравнивается секрет (ключ, подпись, HMAC, токен), то злоумышленник может:

  • многократно отправлять слегка изменённые значения;
  • измерять время ответа;
  • восстанавливать секрет по различиям в миллисекундах и даже наносекундах.

Даже если различия кажутся ничтожными, накопление статистики делает атаку реальной.

Классическая проблема выглядит так:

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

Такой код раскрывает информацию о secretToken через время отклика, потому что сравнение прекращается на первом несовпадении.

Почему это критично именно для криптографии

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

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

Любая утечка даже одного байта может резко сузить пространство поиска.

Проблема раннего выхода

Большинство реализаций стандартного сравнения устроено как:

для каждого байта:
    если байты различаются:
        вернуть false
вернуть true

Ранний выход делает время выполнения переменным.

В оптимизированном JS это усиливается тем, что:

  • ветвления предсказываются процессором;
  • кэширование влияет на скорость;
  • разные строки/буферы могут обрабатываться по-разному;
  • оптимизации JIT делают поведение ещё менее предсказуемым, но всё равно зависимым.

Почему криптографические библиотеки запрещают ===

Библиотеки уровня TweetNaCl.js и nacl.js исходят из принципа: любые операции с секретами должны выполняться в константное время.

Поэтому там используется сравнение без ветвлений, зависящих от данных.

В TweetNaCl.js применяется функция вида «verify», которая реализует побайтовое сравнение через накопление результата, а не через ранний выход:

import nacl from "tweetnacl";

const a = new Uint8Array([1,2,3]);
const b = new Uint8Array([1,2,3]);

const ok = nacl.verify(a, b);

Идея такого подхода:

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

Как выглядит безопасное сравнение

Безопасное сравнение строится на XOR-аккумуляции:

function constantTimeEqual(a, b) {
  if (a.length !== b.length) return false;

  let diff = 0;

  for (let i = 0; i < a.length; i++) {
    diff |= a[i] ^ b[i];
  }

  return diff === 0;
}

Особенность:

  • цикл всегда проходит по всей длине;
  • отсутствует ранний выход;
  • операция XOR не ветвится;
  • итоговое значение зависит от всех байтов сразу.

Node.js и встроенная защита

В Node.js проблема признана на уровне платформы:

import crypto from "crypto";

crypto.timingSafeEqual(buf1, buf2);

Эта функция специально реализована на уровне C++ так, чтобы:

  • не зависеть от содержимого буферов;
  • всегда выполнять одинаковый набор операций;
  • предотвращать тайминговые атаки.

Почему даже «почти одинаковые» оптимизации опасны

Распространённая ошибка — считать, что небольшие различия не важны. Однако в реальности:

  • JIT может оптимизировать сравнение строк;
  • CPU branch prediction делает поведение измеримым;
  • кэш строк ускоряет одинаковые префиксы;
  • garbage collector может вносить шум, но не устраняет утечку.

Даже если разница в 1–2 байта, статистически она извлекается.

Особенность JavaScript-строк

Строки в JS особенно опасны:

  • кодируются в UTF-16;
  • сравнение может происходить по 16-битным блокам;
  • движок может использовать быстрые пути для ASCII;
  • возможно раннее прекращение сравнения на уровне реализации.

Поэтому криптографические операции всегда переводят данные в Uint8Array, избегая строк.

Типичный анти-паттерн

if (signature === expectedSignature) {
  // никогда так не делать
}

Проблема не в синтаксисе, а в модели выполнения: сравнение не контролируется разработчиком на уровне таймингов.

Корректная модель мышления

Для криптографии сравнение должно рассматриваться не как логическая операция, а как функция:

  • вход: два массива байтов
  • выход: равенство
  • ограничение: одинаковое время выполнения для любых входов

Любое отклонение от этой модели создаёт побочный канал.

Связь с TweetNaCl.js / nacl.js

В библиотеках семейства NaCl принцип постоянного времени встроен в архитектуру:

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

Именно поэтому использование === внутри криптографических проверок полностью противоречит модели безопасности этих библиотек.