Как работает TOTP: алгоритм за кодами вашего аутентификатора
Несколько раз в неделю вы вводите 6-значный код из приложения-аутентификатора, и один и тот же код почему-то появляется на телефоне и совпадает с тем, что ждёт сервер, хотя они между собой вообще не общаются. Так как же работает TOTP? Один общий секрет каждые 30 секунд выдаёт новое число, и весь фокус в небольшом детерминированном алгоритме, который обе стороны выполняют независимо. По сети код не передаётся, и никакой центральный сервер это число не раздаёт.
TOTP (Time-based One-Time Password), описанный в RFC 6238, превращает общий секрет и текущее время в короткий числовой код: вычисляет HMAC от времени и усекает результат. Двухфакторная аутентификация (2FA) держится на том, что обе стороны получают одно и то же значение, ни разу им не обменявшись, так что весь механизм доверия сводится к этому алгоритму.
Дальше мы пройдём алгоритм от начала до конца на конкретных числах, а потом разберём ту половину, которую большинство объяснений пропускает: как сервер на самом деле проверяет код, и без прикрас — что 2FA останавливает, а что нет. Живой код можно вычислить в нашем генераторе TOTP прямо по ходу чтения.
Что такое TOTP на самом деле?
TOTP (Time-based One-Time Password), описанный в RFC 6238, — это алгоритм, который объединяет общий секрет с текущим временем и выдаёт короткий код, меняющийся через фиксированный интервал. И приложение-аутентификатор, и сервер хранят один и тот же секрет, читают одни и те же часы и выполняют одну и ту же арифметику, поэтому приходят к одному коду, ни разу его не передав.
На этом и стоит остановиться подробнее. При настройке отправляется только секрет, сам код не передаётся, а дальше каждая сторона выводит коды самостоятельно. В канале перехватывать нечего, кроме секрета при регистрации и тех 6 цифр, что пользователь вводит при входе. Проще всего представить это как три входных значения, которые схлопываются в один выход:
| Вход | Роль | Типичное значение |
|---|---|---|
| Общий секрет | Долгоживущий ключ, согласованный один раз при регистрации | JBSWY3DPEHPK3PXP (Base32) |
| Шаг времени | Счётчик, тикающий вперёд | окно в 30 секунд |
| Выход | Короткий код, выведенный из двух предыдущих | 324550 |
Секрет почти всегда записывают в Base32 (буквы A–Z и цифры 2–7): этот алфавит не различает регистр и спокойно переживает печать, ручной набор и упаковку в QR-код. Регистрируют секрет одним из двух способов — сканируя URI вида otpauth://, который можно отрендерить как QR-код для аутентификатора, либо набирая строку Base32 руками.
TOTP против HOTP, SMS и passkey: ландшафт 2FA
TOTP — лишь один из нескольких вариантов, и чтобы выбрать с умом, полезно видеть всё поле целиком. Запомнить связь несложно: TOTP — это тот же HOTP, только счётчик в нём заменён на число шагов времени, прошедших с эпохи Unix. Дальше начинаются компромиссы: устойчивость к фишингу, удобство и то, какая инфраструктура для этого нужна.
| Механизм | Что движет | Время жизни кода | Устойчив к фишингу? | Нужна сеть? | Типичное применение |
|---|---|---|---|---|---|
| HOTP (RFC 4226) | Растущий счётчик | До использования | Нет | Нет | Аппаратные токены, наследие |
| TOTP (RFC 6238) | Текущее время | ~30 секунд | Нет | Нет (после регистрации) | Приложения-аутентификаторы |
| SMS OTP | Сервер отправляет код | Несколько минут | Нет | Да (сотовая сеть) | Запасной вариант для потребителей |
| Push-подтверждение | Запрос сервера на устройство | На запрос | Частично | Да | 2FA на основе приложения |
| Passkey / FIDO2 | Запрос с открытым ключом | На запрос | Да (привязка к origin) | Да | Современные аккаунты |
В таблице видна закономерность. После регистрации TOTP и HOTP работают офлайн, и за счёт этого они устойчивы и приватны, но от фишинга сами по себе не спасают: убедительная поддельная страница попросит код и передаст его дальше. SMS добавляет сетевой канал, а вместе с ним и собственную поверхность атаки. Passkey закрывают брешь с фишингом, привязывая учётные данные к origin сайта, и именно туда движется индустрия. TOTP остаётся где-то посередине: он надёжен, есть почти везде и ничего не стоит, поэтому никуда и не девается.
Как работает алгоритм TOTP, шаг за шагом
Весь алгоритм укладывается в четыре шага. Каждый из них прогоним на тестовом секрете из RFC JBSWY3DPEHPK3PXP и фиксированном времени Unix 1700000000, чтобы числа можно было воспроизвести.
- Декодировать секрет Base32 в сырые байты ключа.
- Вычислить счётчик шагов времени из текущего времени Unix.
- Вычислить HMAC от счётчика с секретным ключом.
- Усечь дайджест до 6-значного кода.
Шаг 1 — декодировать секрет Base32 в байты
Base32 упаковывает 5 бит в каждый символ, и декодер собирает символы обратно в 8-битные байты. Секрет JBSWY3DPEHPK3PXP декодируется в 10 сырых байтов 48 65 6c 6c 6f 21 de ad be ef. Ключом HMAC служит именно этот массив байтов, а не печатаемая строка.
Шаг 2 — вычислить счётчик шагов времени
Счётчик — это число целых шагов времени, прошедших с отправной точки: T = floor((unixTime − T0) / period). По умолчанию в RFC T0 = 0 (эпоха Unix) и period = 30. При unixTime = 1700000000 получаем T = floor(1700000000 / 30) = 56666666. Дальше это целое кодируется как 8-байтовое значение big-endian: 00 00 00 00 03 60 aa 2a. Счётчик меняется только с началом нового 30-секундного окна, поэтому внутри одного окна код стабилен, а на границе скачком сменяется.
Шаг 3 — вычислить HMAC от счётчика с секретом
Алгоритм вычисляет HMAC-SHA1 над 8-байтовым счётчиком, а ключом служат байты секрета. HMAC — это односторонняя функция с ключом: без секрета дайджест не обратить и корректный не подделать, потому код и невозможно подделать. Для наших входных данных дайджест — это 20 байтов 1d 70 6e 94 1a c7 6b 6d 4a 46 dd 6f af a4 5f e3 35 11 bf 86.
Шаг 4 — динамическое усечение до 6-значного кода (RFC 4226)
20-байтовый дайджест слишком длинный, чтобы его вводить руками, поэтому динамическое усечение из RFC 4226 вытаскивает из него число. Берём младший полубайт последнего байта как смещение: последний байт — 0x86, его младший полубайт — 6, значит смещение равно 6. Читаем 4 байта начиная с этого смещения (6b 6d 4a 46) и сбрасываем старший бит первого из них, чтобы число осталось положительным; выходит целое 1802324550. Берём его по модулю 10^6 и дополняем нулями слева: 1802324550 % 1000000 = 324550. Это и есть код, который приложение показывает для этого секрета прямо сейчас.
Тот же алгоритм на JavaScript, на встроенном в браузер Web Crypto API и без зависимостей. Каждый комментарий привязывает блок к одному из четырёх шагов выше:
// TOTP per RFC 6238 — SHA-1, 6 digits, 30s period (the defaults).
async function generateTotp(base32Secret, unixTime = Date.now() / 1000) {
// Step 1: decode the Base32 secret (A-Z, 2-7) to raw key bytes.
const alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = '';
for (const ch of base32Secret.replace(/=+$/, '').toUpperCase()) {
bits += alpha.indexOf(ch).toString(2).padStart(5, '0');
}
const keyBytes = new Uint8Array(
bits.match(/.{8}/g).map((b) => parseInt(b, 2)));
// Step 2: counter = number of 30s steps since the epoch (8-byte big-endian).
let counter = Math.floor(unixTime / 30);
const msg = new Uint8Array(8);
for (let i = 7; i >= 0; i--) {
msg[i] = counter & 0xff;
counter = Math.floor(counter / 256);
}
// Step 3: HMAC-SHA1 the counter with the secret key.
const key = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
const hmac = new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));
// Step 4: dynamic truncation (RFC 4226) -> 6-digit code.
const offset = hmac[hmac.length - 1] & 0x0f;
const binary = ((hmac[offset] & 0x7f) << 24) | (hmac[offset + 1] << 16) |
(hmac[offset + 2] << 8) | hmac[offset + 3];
return (binary % 1_000_000).toString().padStart(6, '0');
}
const code = await generateTotp('JBSWY3DPEHPK3PXP', 1700000000);
console.log(code); // -> "324550"
То же самое на Python, только на стандартной библиотеке (hmac и struct):
import base64, hmac, hashlib, struct, time
def totp(secret, for_time=None, period=30, digits=6, digest='sha1'):
if for_time is None:
for_time = time.time()
# Step 1: Base32-decode the secret to raw key bytes.
key = base64.b32decode(secret.upper())
# Step 2: counter = number of time steps since the epoch (8-byte big-endian).
counter = int(for_time // period)
msg = struct.pack(">Q", counter)
# Step 3: HMAC the counter with the secret.
h = hmac.new(key, msg, digest).digest()
# Step 4: dynamic truncation (RFC 4226) -> N-digit code.
offset = h[-1] & 0x0f
binary = ((h[offset] & 0x7f) << 24 |
(h[offset + 1] & 0xff) << 16 |
(h[offset + 2] & 0xff) << 8 |
(h[offset + 3] & 0xff))
return str(binary % (10 ** digits)).zfill(digits)
print(totp('JBSWY3DPEHPK3PXP', 1700000000)) # -> 324550
Обе реализации печатают 324550 для нашего фиксированного времени и обе воспроизводят официальные тестовые векторы RFC 6238 (например, вектор SHA-1 при T = 59 даёт 94287082). Если заменить SHA-1 на SHA-256 или SHA-512 либо поменять число цифр, проверяющая сторона обязана повторить ровно те же настройки, иначе коды не совпадут никогда.
Проверка кода TOTP на стороне сервера
Генерация кода — это лишь половина системы. Вторая половина — решение сервера, принимать ли 6 цифр, которые пользователь только что ввёл, и как раз здесь сидят все важные для безопасности компромиссы.
Коды сервер не хранит. Он хранит секрет, а при входе заново вычисляет ожидаемый код из этого секрета и текущего времени и сравнивает. Загвоздка в расхождении часов: устройство пользователя и сервер редко сходятся секунда в секунду, поэтому строгая проверка на равенство отбрасывала бы коды у границы окна. Выход — небольшое окно проверки. Принимайте текущий шаг и по одному шагу с каждой стороны, то есть коды для счётчиков T−1, T и T+1. Чем шире окно, тем оно терпимее к расхождению, но тем больше места для подбора, поэтому окно 1 (допуск ±30 секунд) обычно и берут за баланс. Тот же допуск в ±1 шаг работает в допуске ±1 шаг на вкладке «Проверить» инструмента.
import { createHmac, timingSafeEqual } from 'crypto';
function verifyTotp(secret, code, { window = 1, period = 30, digits = 6 } = {}) {
const counter = Math.floor(Date.now() / 1000 / period);
const submitted = Buffer.from(code);
// Check the current step and ±window steps for clock drift.
for (let i = -window; i <= window; i++) {
const expected = Buffer.from(totpAt(secret, counter + i, digits));
// Constant-time compare so timing can't leak a partial match.
if (expected.length === submitted.length &&
timingSafeEqual(expected, submitted)) {
return counter + i; // matched step — store it to block replay
}
}
return false;
}
Ещё две детали превращают «работает» в «безопасно». Первая — защита от повтора: храните последний принятый счётчик для каждого пользователя и отвергайте любой код с шагом не больше него, тогда однажды подсмотренный код не получится использовать второй раз в том же окне. Поэтому verifyTotp и возвращает совпавший шаг, а не голое true. Вторая — ограничение частоты: 6-значный код — это одно значение из миллиона, а окно ±1 делает действительными сразу три из них в любой момент, так что без троттлинга атакующий переберёт всё пространство. После нескольких неудачных попыток блокируйте аккаунт или добавляйте задержку. И последнее: секрет — это долгоживущий ключ, поэтому шифруйте его при хранении, держите подальше от системы контроля версий и относитесь к нему как к паролю. Заодно сгенерируйте надёжные коды восстановления на тот день, когда устройство потеряется.
От чего TOTP защищает, а от чего — нет
По сравнению с одними паролями TOTP — заметный шаг вперёд, но не панацея, а маркетинговые страницы любят умалчивать о пробелах. Разложим всё по-честному.
| TOTP останавливает | TOTP НЕ останавливает |
|---|---|
| Утёкшие или повторно используемые пароли | Фишинг в реальном времени / adversary-in-the-middle |
| Credential stuffing | Вредонос, считывающий секрет с устройства |
| Удалённый перебор паролей | Слабые процедуры восстановления, обходящие 2FA |
| Утечку БД, раскрывающую только хеши паролей | (для этого нужны другие средства защиты) |
Выигрыш ощутимый. Теперь для входа нужен код, который способен выдать только секрет, поэтому одного утёкшего пароля уже мало, и это начисто убивает credential stuffing и удалённый перебор. Даже если база данных утечёт, но секреты TOTP зашифрованы при хранении, чеканить коды атакующий всё равно не сможет.
Пробелы при этом такие же настоящие. Фишинговый прокси реального времени (страница adversary-in-the-middle) покажет пользователю идеальную копию, перехватит живой код и тут же повторит его на настоящем сайте, пока не истекло окно. Понять, что код ввели не туда, TOTP не в состоянии. Вредонос на устройстве, выгружающий секрет, обходит его целиком, а небрежная процедура «забыл свой 2FA» сводит его на нет. Тут стоит развести один частый источник путаницы: подмена SIM ломает одноразовые коды по SMS, но не TOTP — у TOTP нет канала через телефонный номер, так что перенаправлять атакующему нечего.
Что дальше? Passkey и FIDO2/WebAuthn привязаны к origin и потому устойчивы к фишингу по самой своей конструкции: учётные данные просто не аутентифицируются на чужом домене. Воспринимайте TOTP как надёжную и доступную почти везде ступень над паролями, но не как финальную остановку. С остальным стеком аутентификации он уживается легко: см. лучшие практики безопасности JWT про слой сессионных токенов поверх проверенного входа и хеширование паролей (bcrypt против Argon2) про слой хранения пароля, который дополняет 2FA.
Частые подводные камни при реализации TOTP
Большинство багов TOTP сидит не в самом алгоритме из RFC, а в обвязке вокруг него. Вот те, на которых чаще всего обжигаются.
- Расхождение часов сервера. Без работающего NTP сервер видит «сейчас» иначе, чем устройство пользователя, и коды перестают совпадать сразу у всех. Включите сетевую синхронизацию времени на каждом узле.
- Секреты в открытом виде или в коммитах. Секрет в конфиге, закоммиченный в git, — это постоянный чёрный ход. Храните его зашифрованным в менеджере секретов и никогда не в системе контроля версий.
- Нет защиты от повтора. Если принять код, но не записать совпавший шаг, тот же код пройдёт ещё раз внутри своего окна. Сохраняйте последний использованный шаг для каждого пользователя и отвергайте повтор.
- Слишком широкое или слишком узкое окно. Широкое окно умножает число угадываемых кодов и ослабляет защиту, узкое — отбрасывает нормальных пользователей при малейшем расхождении. Окно 1 обычно и берут.
- Несовпадение параметров. Если в URI
otpauth://при регистрации зашиты SHA-256 и 8 цифр, а проверяющая сторона ждёт SHA-1 и 6 цифр, не пройдёт ни один код. Читайте алгоритм, число цифр и период прямо из URI и применяйте их на обеих сторонах. - Нет резервных кодов или кодов восстановления. Когда телефон потерян, единственная дорога назад — процедура восстановления. Выдавайте коды восстановления при настройке и делайте их настолько надёжными, насколько того стоит аккаунт: та же логика энтропии пароля работает и для секретов восстановления.
FAQ
Защищён ли TOTP от фишинга полностью?
Нет. TOTP останавливает утёкшие пароли и удалённый перебор, но фишинговый прокси реального времени покажет поддельный вход, перехватит живой код и передаст его на настоящий сайт, пока не вышло то же 30-секундное окно. Устойчивый к фишингу апгрейд — это passkey и FIDO2, потому что они привязывают учётные данные к origin сайта.
TOTP безопаснее, чем 2FA по SMS?
Да. Коды SMS идут по сотовой сети, их можно перехватить через подмену SIM или атаки SS7, и они вдобавок зависят от безопасности вашего оператора. У TOTP канала через телефонный номер нет, и код он не передаёт вообще, так что в транзите перехватывать нечего. Секретом обмениваются один раз, при настройке.
Что будет, если я потеряю телефон или приложение-аутентификатор?
Понадобится резервный вариант, заготовленный заранее. Подойдёт что-то одно: коды восстановления, сохранённые при настройке 2FA; второе устройство, зарегистрированное на тот же секрет; или исходная строка-секрет Base32, лежащая в надёжном месте. Если ничего из этого нет, потеря устройства означает блокировку доступа к аккаунту.
Как сервер проверяет код TOTP?
Он заново вычисляет ожидаемый код из общего секрета и текущего времени, а затем сверяет присланный код с текущим шагом и одним шагом с каждой стороны, чтобы учесть расхождение часов. Заодно он запоминает, какой шаг совпал, чтобы тот же код не прошёл повторно, и ограничивает частоту попыток, перекрывая подбор.
Почему коды TOTP обновляются каждые 30 секунд?
Тридцать секунд — это период по умолчанию из RFC 6238: достаточно долгий, чтобы спокойно прочитать и ввести код, и достаточно короткий, чтобы перехваченный атакующим код истёк почти сразу. Некоторые системы берут период в 60 секунд, и тогда это пишется в URI otpauth://, чтобы проверяющая сторона ему соответствовала.
Могут ли два устройства использовать один секрет TOTP?
Да. Любое устройство с тем же секретом Base32 и синхронными часами выдаёт одинаковые коды, ведь алгоритм детерминирован. На этом и держатся резервные копии аутентификаторов на нескольких устройствах, и ровно поэтому секрет должен оставаться приватным: тот, кто его скопирует, сможет выдавать все будущие коды.
TOTP — это то же самое, что Google Authenticator?
Нет. TOTP — это открытый алгоритм из RFC 6238, а Google Authenticator, Authy и 1Password — приложения, которые его реализуют. Стандарт общий, поэтому любое совместимое приложение работает с любым сервисом на TOTP, и привязки к конкретному вендору тут нет.
Заключение
Главное помещается в несколько строк:
- TOTP превращает общий секрет и текущее время в код через HMAC и усечение.
- Обе стороны вычисляют код независимо, по сети он не передаётся никогда.
- Проверяйте с окном ±1 шаг, защитой от повтора и ограничением частоты.
- Атаки на пароли он останавливает, а фишинг реального времени нет, и эту брешь закрывают passkey.
- Часы сервера держите синхронными по NTP, а секрет — зашифрованным и приватным.
Хотите посмотреть, как алгоритм выдаёт реальные числа, и проверить собственное окно верификации? Откройте генератор TOTP / 2FA и вычисляйте, настраивайте и проверяйте коды целиком в браузере: секрет никогда не покинет ваше устройство.