Двухфакторная аутентификация сегодня повсюду. Благодаря ей, чтобы украсть аккаунт, недостаточно одного лишь пароля. И хотя ее наличие не гарантирует, что ваш аккаунт не уведут, чтобы ее обойти, потребуется более сложная и многоуровневая атака. Как известно, чем сложнее что-либо в этом мире, тем больше вероятность, что работать оно не будет. Уверен, все, кто читают эту статью, хотя бы раз использовали двухфакторную аутентификацию (далее — 2FA, уж больное длинное словосочетание) в своей жизни. Сегодня я приглашаю вас разобраться, как устроена эта технология, ежедневно защищающая бесчисленное количество аккаунтов. Но для начала, можете взглянуть на демо того, чем мы сегодня займемся. Первое, что стоит упомянуть про одноразовые пароли, — это то, что они бывают двух типов: HOTP и TOTP. А именно, HMAC-based One Time Password и Time-based OTP. TOTP — это лишь надстройка над HOTP, поэтому сначала поговорим о более простом алгоритме. HOTP описан спецификацией RFC4226. Она небольшая, всего 35 страниц, и содержит все необходимое: формальное описание, пример реализации и тестовые данные. Давайте рассмотрим основные понятия. Прежде всего, что такое HMAC? HMAC расшифровывается как Hash-based Message Authentication Code, или по-русски "код аутентификации сообщений, использующий хэш-функции". MAC — это механизм, позволяющий верифицировать отправителя сообщения. Алгоритм MAC генерирует MAC-тэг, используя секретный ключ, известный только отправителю и получателю. Получив сообщение, вы можете сгенерировать MAC-тэг самостоятельно и сравнить два тэга. Если они совпадают — все в порядке, вмешательства в процесс коммуникации не было. В качестве бонуса этим же способом можно проверить, не повредилось ли сообщение при передаче. Конечно, отличить вмешательство от повреждения не выйдет, но достаточно самого факта повреждения информации.  Что же такое хэш? Хэш — это результат применения к сообщению Хэш-функции. Хэш-функции берут ваши данные и делают из него строку фиксированной длины. Хороший пример — всем известная функция MD5, которая широко использовалась для проверки целостности файлов. Сам по себе MAC — это не конкретный алгоритм, а лишь общий термин. HMAC в свою очередь — уже конкретная реализация. Если точнее, то HMAC-X, где X — одна из криптографических хэш-функций. HMAC принимает два аргумента: секретный ключ и сообщение, смешивает их определенным образом, применяет выбранную хэш-функцию два раза и возвращает MAC-тэг. Если вы сейчас думаете про себя, какое отношение все это имеет к одноразовым паролям — не волнуйтесь, мы почти дошли до самого главного. Согласно спецификации, HOTP вычисляется на основе двух значений: Счетчик — это 8-байтовое значение, синхронизированное между клиентом и сервером. Оно обновляется, по мере того как вы генерируете новые пароли. В схеме HOTP счетчик на стороне клиента инкрементируется каждый раз, когда вы генерируете новый пароль. На стороне сервера — каждый раз, когда пароль успешно проходит валидацию. Так как можно сгенерировать пароль, но не воспользоваться им, сервер позволяет значению счетчика немного забегать вперед в пределах установленного окна. Однако если вы слишком заигрались с генератором паролей в схеме HOTP, придется синхронизировать его повторно. Итак. Как вы, наверное, заметили, HMAC тоже принимает два аргумента. RFC4226 определяет функцию генерации HOTP следующим образом:  Вполне ожидаемо, K используется в качестве секретного ключа. Счетчик, в свою очередь, используется в качестве сообщения. После того, как HMAC функция сгенерирует MAC-тэг, загадочная функция Truncate вытаскивает из результата уже знакомый нам одноразовый пароль, который вы видите в своем приложении-генераторе или на токене.  Давайте начнем писать код и разберемся с остальным по ходу дела. Чтобы заполучить одноразовые пароли, нам понадобится выполнить следующие шаги.  Звучит не слишком сложно, правда? Начнем с генерации хэша. Это, пожалуй, самый простой из перечисленных выше шагов. Мы не будем пытаться воссоздать алгоритм самостоятельно (никогда не надо самостоятельно пытаться реализовать что-то из криптографии). Вместо этого мы воспользуемся Web Crypto API. Небольшая проблема заключается в том, что это API по спецификации доступно только под Secure Context (HTTPS). Для нас это чревато тем, что мы не сможем пользоваться им, не настроив HTTPS на сервере разработки. Немного истории и дискуссии на тему того, насколько это правильное решение, вы можете найти здесь. К счастью, в Firefox вы можете использовать Web Crypto в незащищенном контексте, и изобретать колесо или приплетать сторонние библиотеки не придется. Поэтому для целей разработки демо предлагаю воспользоваться FF. Само по себе Crypto API определено в window.crypto.subtle. Если вас удивляет название, привожу цитату из спецификации: Быстро пройдемся по методам, которые нам понадобятся. На заметку: все упомянутые здесь методы асинхронны и возвращают Promise. Для начала нам понадобится метод importKey, поскольку мы будем использовать свой секретный ключ, а не генерировать его в браузере. importKey принимает 5 аргументов: В нашем случае: Наш секретный ключ будет длинной случайной строкой. В реальном мире это может быть последовательность байтов, которые могут быть непечатными, однако для удобства в этой статье мы будем рассматривать строку. Для конвертации ее в ArrayBuffer мы воспользуемся интерфейсом TextEncoder. С его помощью ключ подготавливается в две строчки кода: Теперь соберем все вместе: Отлично! Криптографию заготовили. Теперь разберемся со счетчиком и, наконец, подпишем сообщение. Согласно спецификации, наш счетчик должен иметь длину 8 байт. Мы будем работать с ним опять же, как с ArrayBuffer. Для перевода его в эту форму мы воспользуемся трюком, который обычно используется в JS, чтобы сохранить нули в старших разрядах числа. После этого каждый байт мы поместим в ArrayBuffer, используя DataView. Имейте в виду, что по спецификации для всех бинарных данных подразумевается формат big endian.  Наконец, подготовив ключ и счетчик, можно генерировать хэш! Для этого мы воспользуемся функцией sign из SubtleCrypto. И на этом мы завершили первый шаг. На выходе мы получаем немного загадочно называющееся значение HS. И хотя это не самое лучшее название для переменной, именно так она (и некоторые последующие) называются в спецификации. Оставим эти названия, чтобы проще было сравнивать с ней код. Что дальше? DT расшифровывается как Dynamic Truncation. И вот как она работает:  Заметьте, как мы применили побитовое И к первому байту HS. 0x7f в двоичной системе — 0b01111111, поэтому по сути мы просто отбрасываем первый бит. В JS на этом смысл данного выражения и заканчивается, но в других языках это также обеспечит отрезание знакового бита, чтобы убрать путаницу между положительными/отрицательными числами и представлять это число как беззнаковое. Почти закончили! Осталось лишь сконвертировать полученное от DT значение в число и вперед, к третьему шагу. Третий шаг также довольно небольшой. Все, что нужно сделать, — это разделить получившееся число на 10 ** (количество цифр в пароле), после чего взять остаток от этого деления. Таким образом, мы отрезаем последние N цифр от этого числа. По спецификации наш код должен уметь вытаскивать минимум шестизначные пароли и потенциально 7 и 8-значные. Теоретически, раз это 31-битное число, мы могли бы вытащить и 9 знаков, но в реальности я лично никогда не видел больше 6 знаков. А вы? Код для заключительной функции, которая объединяет все предыдущие, в таком случае будет выглядеть как-то так: Ура! Но как теперь проверить, что наш код верный? Для того чтобы протестировать реализацию, воспользуемся примерами из RFC. Приложение D включает в себя тестовые значения для секретного ключа "12345678901234567890" и значений счетчика от 0 до 9. Также там есть подсчитанные хэши HMAC и промежуточные результаты функции Truncate. Довольно полезно для отладки всех шагов алгоритма. Вот небольшой пример этой таблицы (оставлены только счетчик и HOTP): Если вы еще не смотрели демо, сейчас самое время. Можете повбивать в нем значения из RFC. И возвращайтесь, потому что мы приступаем к TOTP. Вот мы, наконец, и добрались до более современной части 2FA. Когда вы открываете свой генератор одноразовых паролей и видите маленький таймер, отсчитывающий, сколько еще будет валиден код, — это TOTP. В чем разница? Time-based означает, что вместо статического значения, в качестве счетчика используется текущее время. Или, если точнее, "интервал" (time step). Или даже номер текущего интервала. Чтобы вычислить его, мы берем Unix-время (количество миллисекунд с полуночи 1 января 1970 года по UTC) и делим на окно валидности пароля (обычно 30 секунд). Сервер обычно допускает небольшие отклонения ввиду несовершенства синхронизации часов. Обычно на 1 интервал вперед и назад в зависимости от конфигурации. Очевидно, это гораздо безопаснее, чем схема HOTP. В схеме, завязанной на время, валидный код меняется каждые 30 секунд, даже если он не был использован. В оригинальном алгоритме валидный пароль определен текущим значением счетчика на сервере + окном допуска. Если вы не аутентифицируетесь, пароль не будет изменен бесконечно долго. Больше про TOTP можно почитать в RFC6238. Поскольку схема с использованием времени — это дополнение к оригинальному алгоритму, нам не потребуется вносить изменения к изначальной реализации. Мы воспользуемся requestAnimationFrame и будем на каждый фрейм проверять, до сих пор ли мы находимся внутри временного интервала. Если нет — генерируем новый счетчик и вычисляем HOTP заново. Опуская весь управляющий код, решение выглядит как-то так: Обычно, когда мы настраиваем 2FA, мы сканируем начальные параметры с QR-кода. Он содержит всю необходимую информацию: выбранную схему, секретный ключ, имя аккаунта, имя провайдера, количество цифр в пароле. В предыдущей статье я рассказывал, как можно сканировать QR-коды прямо с экрана, используя API getDisplayMedia. На основе того материала я сделал маленькую библиотеку, которой мы сейчас и воспользуемся. Библиотека называется stream-display, и в дополнение к ней мы используем замечательный пакет jsQR. Закодированная в QR-код ссылка имеет следующий формат: Например: Я опущу код, который настраивает процесс запуска захвата экрана и распознавания, так как все это можно найти в документации. Вместо этого, вот как можно распарсить эту ссылку: В реальном мире секретный ключ будет закодированной в base-32 (!) строкой, поскольку некоторые байты могут быть непечатными. Но для простоты демонстрации мы опустим этот момент. К сожалению, я не смог найти информации, почему именно base-32 или именно такой формат. По всей видимости, никакой официальной спецификации к этому формату URL не существует, а сам формат был придуман Google. Немного о нем вы можете почитать здесь Для генерации тестовых QR-кодов рекомендую воспользоваться FreeOTP. И на этом все! Еще раз, не забывайте посмотреть демо. Там же есть ссылка на репозиторий с кодом, который за этим всем стоит. Сегодня мы разобрали достаточно важную технологию, которой пользуемся на ежедневной основе. Надеюсь, вы узнали для себя что-то новое. Эта статья заняла гораздо больше времени, чем я думал. Однако довольно интересно превратить бумажную спецификацию в нечто работающее и такое знакомое. До новых встреч! Fullstack Developer