Очень легко выстрелить себе в ногу, и это не только позволяет хакерам случайно взломать механизм аутентификации, но и упрощает им определение ключа API !

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

Когда серверный разработчик пишет API, иногда ему требуется аутентифицировать инициатора запроса. Серверу необходимо выяснить, кто сделал запрос, чтобы он мог разрешить или запретить доступ. Есть много способов сделать это, и один из них - с помощью ключа API.

Как работает аутентификация на основе ключей API

По сути, механизм аутентификации на основе ключей API работает следующим образом:
Потребитель API отправляет запрос и встраивает в него ключ. Если ключ совпадает с ключом, ожидаемым сервером, то пользователь аутентифицируется (потому что у него общий секрет).
Наивно, в коде это может выглядеть примерно так:

if (req.body.apiKey == expectedApiKey) {
  // Authorize access
} else {
  response.status(401).send('unauthorized');
}

Поэтому, если ключ, отправленный потребителем (req.body.apiKey), совпадает с ключом API, который мы ожидаем (expectedApiKey), мы разрешаем доступ, в противном случае мы отправляем ошибку.
К сожалению, этот код уязвим для временной атаки.

Что такое временная атака?

Один из методов, который хакер может использовать для взлома системы, называется «временная атака». Принцип его работы заключается в измерении времени, необходимого для получения ответа от системы, и его использовании путем определения времени.
Например, предположим, что хакер пытается угадать пароль от сервера. Если серверу требуется больше времени для ответа, чем ближе хакер угадывает правильный пароль, он может использовать информацию о времени, чтобы сузить количество возможных паролей, которые нужно угадать.
Это немного похоже на «Охота на наперсток», когда сервер сообщает хакеру, приближается ли он (теплее) или дальше (холоднее).

Почему именно наивный подход уязвим?

В JavaScript сравнение строк с использованием операторов == и === небезопасно по времени. Под капотом он перебирает операнды и преждевременно завершается, если символ отличается. Это выглядело бы примерно так:

bool operator==(string op1, string op2) {
  if(op1.length != op2.length) return false;
  for(let i = 0; i < op1.length; ++i) {
    if op1[i] != op2[i] return false;
  }
  return true;
}

Здесь есть 2 проблемы:
1. Если длина входных данных не одинакова, процедура возвращается раньше времени.
2. Количество итераций, которые проходит цикл for, зависит от длины равного подстрока.
Происходит утечка полезной информации для злоумышленника: правильная ли длина? И если да, то какую часть подстроки я угадал правильно?

Как злоумышленник я могу угадать ключи API и измерить, сколько времени мне нужно, чтобы получить сообщение об отказе. Если я замечаю, что получение отклонения для одного угаданного ключа API занимает больше времени, чем для других, я могу сделать вывод, что, возможно, я правильно угадал его часть (подстроку). Затем я могу принять это предположение и попробовать различные варианты, чтобы сузить правильную подстроку или выяснить более длинную правильную подстроку.

Как исправить уязвимость?

NodeJS имеет встроенный модуль криптографии, который реализует TimingSafeEqual. Он отличается от наивной проверки на равенство тем, что основан на алгоритме с постоянным временем. Вы получите ответ по прошествии того же времени, независимо от того, равны ли строки или нет, и независимо от того, есть ли равные подстроки.

Код может выглядеть примерно так:

bool timingSafeEqual(string op1, string op2) {
  bool eq = true;
  for(let i = 0; i < Math.min(op1.length, op2.length); ++i) {
    if op1[i] != op2[i] eq = false;
  }
  if op1.length != op2.length eq = false;
  return eq;
}

Передавая req.body.apiKey и expectedApiKey в качестве параметров timingSafeEqual, вы узнаете, равны ли они, без утечки информации о времени.

Однако не все так просто! В документации для timingSafeEqual указано, что оба параметра должны быть Buffer s, TypedArray s или DataView s. Это не проблема. Проблема в том, что они должны иметь одинаковую длину байта.

Итак, мы попали в ловушку-22. Чтобы исправить уязвимость, нам нужно использовать timingSafeEqual, но чтобы использовать timingSafeEqual, нам нужно сделать что-то особенное, если длины не совпадают. Как это сделать, не допуская утечки информации о времени?

Давайте посмотрим на наши варианты

Если мы сделаем что-то вроде этого:

if (crypto.timingSafeEqual(Buffer.from(req.body.apiKey), Buffer.from(expectedApiKey)) {
  // Authorize access
} else {
  response.status(401).send('unauthorized');
}

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

Если мы сделаем что-то вроде этого:

if (req.body.apiKey.length === expectedApiKey.length && crypto.timingSafeEqual(Buffer.from(req.body.apiKey), Buffer.from(expectedApiKey)) {
  // Authorize access
} else {
  response.status(401).send('unauthorized');
}

мы не дойдем до timingSafeEqual части оператора if, если длины не совпадают (из-за ленивой оценки JavaScript), поэтому мы снова теряем информацию о времени.

Мы могли бы сделать примерно следующее:

if (crypto.timingSafeEqual(Buffer.from(req.body.apiKey.padEnd(expectedApiKey.length).slice(0, expectedApiKey.length)), Buffer.from(expectedApiKey)) {
  // Authorize access
} else {
  response.status(401).send('unauthorized');
}

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

  1. Это выглядит нечисто. Это может вызвать недоумение при проверке кода и, возможно, оправдать «WTF».
  2. Меня беспокоит реализация String.slice. Это алгоритм постоянного времени? Или это зависит от размера ввода?
    Я не заглядывал внутрь NodeJS, чтобы проверить.

Мое предлагаемое решение

Хешируйте оба и timingSafeEqual шифры.

const hash = crypto.createHash('sha512');
if (crypto.timingSafeEqual(
  hash.copy().update(req.body.apiKey).digest(),
  hash.copy().update(expectedApiKey).digest()
)) {
  // Authorize access
} else {
  response.status(401).send('unauthorized');
}

Вот некоторые свойства хороших хеш-функций, которые мы используем:

  • Для каждой пары вызовов функции, если простой текст один и тот же, то и зашифрованный текст такой же.
    Таким образом, мы можем сравнивать шифры и знать, что открытый текст тот же, если и только те же шифры. .
  • Выходные данные функции всегда имеют одинаковый размер (в битах).
    Таким образом, оба входа timingSafeEqual имеют одинаковый размер.
  • Функция выполняет один и тот же объем работы независимо от ввода.
    Это означает, что она не передает информацию о времени.

Вот дополнительное преимущество: вместо того, чтобы хранить ключ API в виде обычного текста в исходном коде или в переменных среды, вы можете хранить его хэш. Даже если у кого-то есть доступ к вашему исходному коду или к переменным среды, ему будет сложно определить ключ API.
Это связано с еще одним свойством хороших хеш-функций:

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