MFA и TOTP: второй фактор, recovery-коды и step-up

Пароль или OAuth — один фактор; второй закрывает кражу учётки. Как устроен TOTP (RFC 6238: общий секрет + время → код), почему он лучше SMS, как хранить секрет и восстанавливать доступ (recovery-коды), что такое step-up authentication (запросить фактор только на опасное действие) и куда движется мир после паролей — мостик к passkeys

Один фактор — пароль или вход через провайдера — падает вместе с утёкшей учёткой. Второй фактор ломает эту одноходовку: даже зная пароль, злоумышленник не пройдёт без кода из приложения. TOTP — самый практичный второй фактор: не требует связи, работает офлайн, стандартизован. Эта статья — про то, как он устроен, как его правильно внедрить (секрет, recovery, step-up) и почему SMS-коды — плохая замена.

Часть серии «Авторизация: токены, сессии, refresh». Развивает вход и OTP до полноценного второго фактора.

Ретрофутуристская схема второго фактора в стиле «Полдень. XXI век»: TOTP-генератор из общего секрета и часов (30 с) через HMAC печатает 6-значный код, enrollment по QR otpauth://; SMS перечёркнут (SIM-swap, перехват); турникет требует два фактора — пароль и TOTP-код; сейф с recovery-кодами на случай потери телефона; шлагбаум step-up для чувствительных действий; стрелка «фишинг-устойчивость → passkeys»

В статье

Зачем второй фактор

Вся предыдущая часть серии — про то, как сервер убеждается, что перед ним тот самый пользователь, и как это подтверждение живёт в виде сессии. Но у любого из разобранных способов входа — пароль, email-код, OAuth (статья 4) — есть общее уязвимое место: это один фактор. Утёк пароль из чужой утечки, где пользователь его переиспользовал; фишинговая страница выманила email-код; скомпрометирован OAuth-провайдер — и злоумышленник проходит аутентификацию с первой попытки, потому что предъявить нужно было ровно одну вещь.

MFA (multi-factor authentication) не устраняет компрометацию первого фактора — она делает её недостаточной. Факторы принято делить на три рода: что вы знаете (пароль, PIN), чем вы владеете (телефон с приложением-генератором, аппаратный ключ) и чем вы являетесь (биометрия). Второй фактор из другого рода закрывает ровно тот сценарий, где годами массово утекают базы паролей: даже имея верную пару логин/пароль, атакующий не владеет вторым секретом, который никогда не передавался по сети в открытом виде и не хранится ни в одной чужой базе.

TOTP (Time-based One-Time Password) — самый практичный вариант второго фактора владения: код генерируется на устройстве пользователя офлайн, без SMS-канала, без пуш-уведомлений, стандартизован RFC 6238 и поддержан любым authenticator-приложением. Дальше — как он устроен, что в нём легко сделать неправильно (анти-replay, хранение секрета) и как не потерять пользователя навсегда при утере телефона.

TOTP: RFC 6238 и как устроен код

TOTP — это HOTP (HMAC-based One-Time Password, RFC 4226) с одной заменой: вместо счётчика, который сервер и клиент синхронизируют явно, используется время, которое и так более-менее синхронно у обеих сторон. Формула:

step   = floor(unix_time / period)         # period = 30с
code   = Truncate(HMAC-SHA1(secret, step))  # усечение до N цифр

Секрет — общий случайный ключ, известный только серверу и приложению-аутентификатору пользователя (Google Authenticator, Aegis, встроенный в менеджер паролей и т.д.). Каждые period секунд текущий Unix-time делится на период и округляется вниз — получается time-step, целое число, растущее на единицу каждые 30 секунд. Step прогоняется через HMAC с секретом в качестве ключа, результат усекается динамическим алгоритмом (описан в RFC 4226) до нужного числа десятичных цифр. Ни секрет, ни какой-либо секретный материал никогда не передаются по сети при проверке — по сети едут только шесть цифр, которые бесполезны за пределами 30-секундного окна.

В стенде (security/auth/totp) эти параметры зафиксированы явно, через github.com/pquerna/otp (v1.5.0):

const (
    totpPeriod = 30
    totpSkew   = 1
    totpDigits = otp.DigitsSix
)

Алгоритм хэширования — SHA1. Это не оговорка и не упрощение стенда: RFC 6238 определил TOTP именно на SHA1, и подавляющее большинство authenticator-приложений в мире (включая Google Authenticator) умеют работать только с ним — SHA256/SHA512 в спецификации присутствуют как опция, но экосистема клиентов их почти не поддерживает, поэтому взаимная совместимость на практике означает SHA1. Шесть цифр — тоже стандарт де-факто: пространство кода 10⁶, и в паре с 30-секундным окном и анти-брутфорсом (ниже) этого достаточно.

Окно допуска (skew). Часы сервера и часы телефона не идеально синхронны — минутный дрейф не редкость. Если проверять код строго на текущий step, легитимный пользователь с чуть отстающими или спешащими часами будет получать «неверный код» без всякой атаки. totpSkew = 1 расширяет проверку на соседние периоды: сервер пересчитывает ожидаемый код не только для текущего step, но и для step-1 и step+1, то есть фактически принимает код в окне около 60–90 секунд вокруг реального времени сервера:

nowStep := time.Now().Unix() / totpPeriod
for step := nowStep - totpSkew; step <= nowStep+totpSkew; step++ {
    expected, _ := totp.GenerateCodeCustom(secret, time.Unix(step*totpPeriod, 0), totp.ValidateOpts{
        Period: totpPeriod, Digits: totpDigits, Algorithm: otp.AlgorithmSHA1,
    })
    if token.SafeEqual(expected, code) {
        matched, matchedStep = true, step
        break
    }
}

Сравнение — константное по времени (token.SafeEqual), а не ==: код, пусть и производный от секрета, всё равно секретный материал на 30 секунд, и сравнение строк с ранним выходом на первом несовпадающем байте теоретически позволяет замерять расхождение по времени ответа. Более широкий skew (±2, ±3) звучит как более щедрая толерантность к рассинхрону, но и удлиняет окно, в котором подсмотренный код остаётся действителен — ±1 — обычный практический баланс.

Enrollment: секрет и QR

Подключение TOTP («enrollment») — единственный момент во всём жизненном цикле, когда секрет и recovery-коды видны в открытом виде. Сервер генерирует секрет, отдаёт его клиенту (обычно — в виде QR-кода со ссылкой otpauth://, которую распознаёт приложение-аутентификатор) и сохраняет копию у себя:

func Enroll(ctx context.Context, rdb *redis.Client, userID string) (secret, otpauthURI string, recovery []string, err error) {
    key, err := totp.Generate(totp.GenerateOpts{
        Issuer: issuer, AccountName: userID,
        Period: totpPeriod, Digits: totpDigits, Algorithm: otp.AlgorithmSHA1,
    })
    // ...
    return key.Secret(), key.URL(), recovery, nil
}

key.URL() — это и есть otpauth://totp/khorost-auth-demo:u1?secret=...&issuer=khorost-auth-demo&period=30&digits=6: стандартная схема URI, которую понимает любое authenticator-приложение — достаточно превратить строку в QR-код на экране (сам стенд отдаёт otpauth_uri в JSON, рендер QR — забота фронтенда, не входит в объём демо). После этого секрет живёт в Redis (totp:{userID}, поле secret) и с этого момента больше никогда не отдаётся клиенту целиком: последующие операции — только «проверить код против секрета», а не «показать секрет ещё раз».

Важная деталь для повторной регистрации: Enroll перед записью делает Del по всему ключу totp:{userID} — без этого при re-enrollment (пользователь сбросил TOTP и настраивает заново) старые recovery-коды и last_step анти-replая пережили бы новую настройку, и в системе одновременно были бы валидны и старые, и новые recovery-коды. Тест TestReEnrollInvalidatesOldRecovery в стенде это фиксирует явно.

TOTP vs SMS

SMS-код как второй фактор долго был массовым выбором — он не требует от пользователя ставить отдельное приложение. Но у него системная слабость: код едет через телефонную сеть, а телефонная сеть — не то место, где стоит держать секрет.

  • SIM-swap. Атакующий убеждает оператора связи (социальной инженерией или через инсайдера) перевыпустить SIM-карту жертвы на своё устройство. Дальше все SMS, включая коды MFA и коды сброса пароля, приходят ему. Это не гипотетика — массовый вектор атак на криптокошельки и банковские аккаунты именно через операторов, а не через взлом самого сервиса.
  • Перехват на уровне сети. SS7 (сигнальный протокол между операторами) спроектирован в эпоху доверенных телефонных сетей и не аутентифицирует запросы между операторами должным образом — SMS можно перенаправить, зная номер жертвы, без физического доступа к SIM. Это известная и неоднократно продемонстрированная атака, доступная не только спецслужбам.
  • Зависимость от третьей стороны. Доставка SMS — не гарантирована и не мгновенна: задержки, блокировки роумингом, стоимость на международных номерах. Каждая доставка — ещё один внешний сервис (SMS-шлюз) в цепочке доверия.

TOTP не имеет ни одной из этих проблем структурно: код не передаётся ни по какому каналу связи вообще — он вычисляется локально на устройстве из уже имеющегося секрета. Скомпрометировать SIM бесполезно: секрет живёт не в SIM, а в приложении-аутентификаторе. NIST SP 800-63B ещё в редакции 2017 года понизил статус SMS-OTP до «restricted authenticator» именно по этим причинам — это не эстетическое предпочтение TOTP, а зафиксированная в стандарте позиция. Практический недостаток TOTP ровно один: пользователю нужно ставить и не терять приложение-аутентификатор — отсюда важность recovery-кодов (ниже).

Анти-replay и анти-брутфорс

Skew-окно в 60–90 секунд — это подарок и атакующему тоже: если код был подсмотрен (через плечо, в логе, в истории браузера при автозаполнении), в пределах этого окна он формально всё ещё «правильный» с точки зрения формулы HOTP. Наивная реализация TOTP-проверки (сравнить с ожидаемым — и всё) уязвима к replay: тот же код можно предъявить дважды в пределах окна, и оба раза он пройдёт проверку.

Стенд закрывает это не эвристикой «код похож на использованный», а маленьким, но принципиальным механизмом: для каждого пользователя хранится last_step — номер последнего принятого time-step. Условие приёма — строго «новый step больше последнего принятого», а не «код совпал». Продвижение last_step и проверка делаются одной атомарной операцией — Lua-скриптом, который Redis исполняет однопоточно, без гонки между конкурентными запросами Verify:

local last = redis.call('HGET', KEYS[1], 'last_step')
if last and tonumber(last) >= tonumber(ARGV[1]) then
    return 0
end
redis.call('HSET', KEYS[1], 'last_step', ARGV[1])
return 1

Если бы это было «прочитать last_step, сравнить в Go, записать новый» тремя отдельными командами, между чтением и записью мог бы вклиниться параллельный запрос с тем же кодом — классический TOCTOU. Lua-скрипт в Redis — простой и надёжный способ получить атомарность без отдельного распределённого лока. TestVerifyRejectsReplay в стенде прогоняет ровно этот сценарий: первый Verify с валидным кодом проходит, второй Verify с тем же самым кодом (тот же step) — отклоняется, хотя формально код всё ещё «в пределах окна допуска».

У этой строгости есть цена, и её стоит проговорить честно: last_step продвигается только вперёд и необратимо. Если пользователь дважды кликнул «подтвердить» с одним и тем же кодом в форме (двойной сабмит) — второй запрос будет отклонён точно так же, как настоящий replay-атакующий, потому что для сервера это неотличимые ситуации: один и тот же step, предъявленный второй раз. Разработчику клиента приходится закрывать двойной клик на своей стороне (disable кнопки после первого сабмита), а не полагаться на сервер — сервер сознательно не умеет отличать «случайный дубль легитимного клиента» от «атакующий предъявляет подсмотренный код».

Отдельно, но по соседству — анти-брутфорс. Шестизначный код — это пространство всего 10⁶ вариантов; при окне допуска в 60–90 секунд и отсутствии лимита попыток код реально перебираем. /totp/verify считает неудачные попытки per-userID (totp:rl:{userID}, лимит 5 попыток за 60 секунд) и при превышении лимита отклоняет любой код — включая правильный — до истечения окна:

const totpRLLimit, totpRLWindow = 5, 60 * time.Second

Отказ ради корректного кода после исчерпания лимита — не баг, а намеренное решение: если бы лимит переставал действовать в момент правильного ответа, сам факт «лимит вдруг снялся» становился бы оракулом для атакующего, перебирающего код. Успешная проверка сбрасывает счётчик (TestVerifyRateLimitResetsOnSuccess) — пользователь, один раз ошибившийся пальцем и затем введший верный код, не остаётся заблокированным до конца минуты.

Recovery-коды: доступ без телефона

Второй фактор владения ломается ровно тогда, когда владение теряется: телефон разбит, потерян, украден, приложение снесено при переустановке без бэкапа. Без запасного пути пользователь с MFA, но без телефона, не может войти вообще — это реальный кейс службы поддержки, а не теория.

Recovery-коды — заранее сгенерированный при enrollment набор одноразовых кодов, которые пользователь сохраняет отдельно от телефона (менеджер паролей, распечатка, сейф). Стенд генерирует 10 кодов на каждый enrollment:

for i := 0; i < recoveryCount; i++ {
    plain, _ := token.OpaqueHex(8)
    recovery[i] = plain
    fields["recovery:"+hashRecoveryCode(plain)] = "1"
}

Ключевая деталь хранения: в Redis попадает не сам код, а sha256(code), в качестве поля хэша totp:{userID} (recovery:{sha256}). Это то же рассуждение, что и для паролей — компрометация хранилища не должна означать компрометацию всех recovery-кодов в открытом виде. Открытым текстом код виден ровно один раз — в ответе POST /totp/enroll в момент генерации; после этого сервер знает только хэш и не может показать код повторно, только перевыпустить весь набор через re-enrollment.

Использование — одноразовое по конструкции, не по соглашению:

func UseRecovery(ctx context.Context, rdb *redis.Client, userID, code string) (bool, error) {
    field := "recovery:" + hashRecoveryCode(code)
    exists, err := rdb.HExists(ctx, totpKey(userID), field).Result()
    if !exists { return false, nil }
    if err := rdb.HDel(ctx, totpKey(userID), field).Err(); err != nil { ... }
    return true, nil
}

HDel сразу после успешной проверки — код физически исчезает из хранилища, повторное предъявление того же значения не найдёт поля и вернёт false (TestRecoverySingleUse). Успешный вход по recovery-коду в стенде поднимает step-up точно так же, как успешный TOTP (см. ниже) — с точки зрения «свежести MFA-подтверждения» recovery-код равносилен коду из приложения, потому что доказывает владение тем же секретом восстановления, который выдавался только легитимному владельцу аккаунта при enrollment.

Step-up: свежий MFA только на опасное действие

Требовать второй фактор на каждый запрос неоправданно — пользователь и так вошёл, сессия жива (модели сессий разобраны в первой статье). Но у части действий цена ошибки выше, чем у обычного чтения: смена email или пароля, привязка новой платёжной карты, вывод средств. Здесь применяется отдельный паттерн — step-up authentication: обычной, уже установленной сессии недостаточно, требуется свежее подтверждение MFA, ограниченное по времени.

В стенде это отдельный TTL-флаг поверх session ID, не относящийся к самой сессии:

func MarkStepUp(ctx context.Context, rdb *redis.Client, sessionID string) error {
    return rdb.Set(ctx, stepupKey(sessionID), "1", stepupTTL).Err() // stepupTTL = 5 * time.Minute
}

func RequireStepUp(ctx context.Context, rdb *redis.Client, sessionID string) (bool, error) {
    n, err := rdb.Exists(ctx, stepupKey(sessionID)).Result()
    return n > 0, err
}

Успешная проверка TOTP-кода или recovery-кода вызывает MarkStepUp для текущей сессии — stepup:{sessionID} в Redis с TTL 5 минут. Защищённый эндпоинт (POST /sensitive) перед выполнением действия проверяет RequireStepUp: если флага нет или он истёк, ответ — 403 step_up_required, даже если основная сессия пользователя абсолютно валидна:

func (h *totpHandler) sensitive(w http.ResponseWriter, r *http.Request) {
    sid := sessionID(r)
    ok, _ := RequireStepUp(r.Context(), h.rdb, sid)
    if !ok {
        writeError(w, http.StatusForbidden, "step_up_required")
        return
    }
    // выполнить опасное действие
}

TestStepUpExpires в стенде промораживает время через miniredis.FastForward(6 * time.Minute) и проверяет, что подтверждение, которое было живым сразу после MarkStepUp, протухает по истечении TTL — пятиминутное окно не продлевается активностью, это именно «свежесть», а не «сессия с MFA», и пользователю, который подтвердил MFA десять минут назад и теперь хочет сменить email, придётся ввести код заново.

Важная граница демо, которую стоит проговорить честно: стенд totp/ не создаёт саму первичную сессию — он принимает готовый sessionID через cookie session_id или заголовок X-Session-Id и работает поверх неё. В реальной системе session ID приходит из того же механизма, что разобран в статье про opaque-сессии или статье про JWT+refresh; step-up здесь — надстройка над уже существующей моделью сессий, а не альтернатива ей.

И ещё один компромисс демо, важный для переноса в прод: TOTP-секрет в totp:{userID} хранится открытым текстом, не шифрованным at-rest. Компрометация Redis раскрывает секреты всех зарегистрированных пользователей разом — в отличие от recovery-кодов, которые хранятся хэшированными не просто так. В проде секрет шифруется симметричным ключом вне Redis (управление ключом — через KMS или HSM) перед записью и расшифровывается только на момент вычисления кода; это не деталь реализации, а отдельная граница ответственности, которую demo-стенд сознательно не закрывает — README прямо перечисляет это в списке честных компромиссов.

Куда дальше: TOTP всё ещё фишится

TOTP закрывает кражу пароля и подмену SIM, но не закрывает фишинг целиком. Поддельная страница логина, визуально неотличимая от настоящей, точно так же попросит ввести шестизначный код — и пользователь, уже привыкший его вводить, введёт. Атакующий в реальном времени пересылает украденный код на настоящий сайт (adversary-in-the-middle, «реальном времени фишинг») и получает сессию за те же 30–90 секунд, пока код ещё действителен. TOTP не проверяет, с каким сайтом разговаривает пользователь — он лишь доказывает владение секретом, а секрет вводится вручную, куда угодно.

Это структурное ограничение любого фактора, основанного на коде, который человек читает и перепечатывает. Следующая, восьмая статья серии — «Passkeys и WebAuthn» — разбирает подход, который закрывает именно этот случай: криптографическая привязка к origin на уровне протокола, из-за которой поддельный сайт физически не может получить рабочий ответ, даже если пользователь на нём ничего не заподозрит. Это не замена TOTP «потому что он устарел» — TOTP остаётся практичным вторым фактором там, где WebAuthn пока недоступен, — а следующий шаг для тех случаев, где фишинг остаётся реальной угрозой.

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии