TOTP: Одноразовый пароль на основе времени

TOTP (Time-based One-Time Password) — это алгоритм одноразовых паролей, которые обновляются каждые 30 секунд и используются для двухфакторной аутентификации (2FA).

Это 6-значный код, который вы видите в приложениях вроде:

  • Google Authenticator
  • Microsoft Authenticator
  • Authy
  • 1Password
  • FreeOTP и др.

Он зависит от текущего времени и секретного ключа, который вы получаете при настройке 2FA.

 Точное время на сервере — критическое требование! 

  • При включении 2FA, сайт или сервис показывает вам QR-код или секретный ключ.
  • Вы добавляете его в TOTP-приложение.
  • Приложение и сервер используют одинаковый алгоритм и время, чтобы генерировать коды.
  • При входе вы вводите этот 6-значный код, и сервер проверяет, совпадает ли он с ожидаемым значением.
use Random\RandomException;

readonly class TOTP
{
    private const string BASE32CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; // RFC 4648 Base32
    private const int DEFAULT_DIGITS = 6; // Количество цифр в OTP по умолчанию
    private const int DEFAULT_TIME_STEP = 30; // Временной шаг в секундах по умолчанию
    private const int DEFAULT_SECRET_LENGTH = 20; // Длина секретного ключа по умолчанию

    private string $decodedSecret; // Кэшированный декодированный секрет

    /**
     * Конструктор для инициализации объекта TOTP с секретным ключом.
     *
     * @param string $secret Секретный ключ в кодировке Base32.
     */
    public function __construct(string $secret)
    {
        $this->decodedSecret = $this->base32decode($secret); // Декодируем секрет один раз при создании объекта
    }

    /**
     * Генерация случайного секретного ключа OTP заданной длины.
     *
     * @param int $length Длина секретного ключа (по умолчанию: 20).
     * @return string Сгенерированный секретный ключ OTP.
     */
    public static function generateSecret(int $length = self::DEFAULT_SECRET_LENGTH): string
    {
        try {
            $bytes = random_bytes($length);
        } catch (RandomException) {
            throw new RuntimeException("Критическая ошибка безопасности: системный генератор случайных чисел недоступен.");
        }
        $secret = '';
        for ($i = 0; $i < $length; $i++) {
            $secret .= self::BASE32CHARS[ord($bytes[$i]) % 32];
        }
        return $secret;
    }

    /**
     * Генерация одноразового пароля (OTP) на основе времени с использованием секретного ключа.
     *
     * @param int $digits Количество цифр в OTP (по умолчанию: 6).
     * @param int $timeStep Временной шаг в секундах (по умолчанию: 30).
     * @return string Сгенерированный OTP.
     */
    public function generate(int $digits = self::DEFAULT_DIGITS, int $timeStep = self::DEFAULT_TIME_STEP): string
    {
        $timeCounter = floor(time() / $timeStep); // Текущее время, разделенное на временной шаг
        return $this->computeOtp($timeCounter, $digits); // Вычисляем OTP
    }

    /**
     * Проверка одноразового пароля (OTP) с учётом временного окна.
     *
     * @param string $otp Введённый пользователем OTP.
     * @param int $digits Количество цифр в OTP (по умолчанию: 6).
     * @param int $timeStep Временной шаг в секундах (по умолчанию: 30).
     * @param int $window Количество интервалов до и после текущего для проверки (по умолчанию: 1).
     * @return bool Возвращает true, если OTP валиден в указанном временном окне.
     */
    public function verify(string $otp, int $digits = self::DEFAULT_DIGITS, int $timeStep = self::DEFAULT_TIME_STEP, int $window = 1): bool
    {
        $currentTimeCounter = floor(time() / $timeStep); // Текущий временной счётчик

        // Проверяем OTP для текущего времени и соседних интервалов (±window)
        for ($i = -$window; $i <= $window; $i++) {
            $timeCounter = $currentTimeCounter + $i;
            $generatedOtp = $this->computeOtp($timeCounter, $digits); // Вычисляем OTP
            if (hash_equals($generatedOtp, $otp)) {
                return true; // OTP совпал
            }
        }

        return false; // OTP не совпал ни в одном интервале
    }

    /**
     * Вычисление OTP для заданного временного счётчика.
     *
     * @param int $timeCounter Временной счётчик (время, разделённое на timeStep).
     * @param int $digits Количество цифр в OTP.
     * @return string Сгенерированный OTP.
     */
    private function computeOtp(int $timeCounter, int $digits): string
    {
        $binaryTime = pack('J', $timeCounter); // Упаковываем время в бинарный формат (big endian)
        $hash = hash_hmac('sha1', $binaryTime, $this->decodedSecret, true); // Вычисляем HMAC-SHA1 хеш
        $offset = ord($hash[strlen($hash) - 1]) & 0x0F; // Получаем смещение из последнего полубайта хеша
        $otp = (
                ((ord($hash[$offset]) & 0x7F) << 24) |
                ((ord($hash[$offset + 1]) & 0xFF) << 16) |
                ((ord($hash[$offset + 2]) & 0xFF) << 8) |
                (ord($hash[$offset + 3]) & 0xFF)
            ) % 10 ** $digits; // Вычисляем значение OTP
        return str_pad((string)$otp, $digits, '0', STR_PAD_LEFT); // Форматируем OTP до указанной длины
    }

    /**
     * Декодирование строки, закодированной в Base32.
     *
     * @param string $input Входная строка, закодированная в Base32.
     * @return string Декодированная бинарная строка.
     */
    private function base32decode(string $input): string
    {
        $base32charsFlipped = array_flip(str_split(self::BASE32CHARS));
        $output = '';
        $v = 0;
        $vbits = 0;

        for ($i = 0, $j = strlen($input); $i < $j; $i++) {
            $v <<= 5;
            if ($input[$i] === '=') {continue;} // Пропускаем символы заполнения
            $v += $base32charsFlipped[$input[$i]]; // Декодируем символ Base32
            $vbits += 5;

            if ($vbits >= 8) {
                $vbits -= 8;
                $output .= chr(($v & (0xFF << $vbits)) >> $vbits); // Добавляем декодированный байт
            }
        }
        return $output;
    }
}

Принцип работы

  1. Генерируем секретный ключ при помощи TOTP::generateSecret(), записываем его в БД и показываем пользователю, чтобы он добавил его в своё приложение.
  2. Когда нужно проверить:
$userInput = $_POST['otp'];
$totp = new TOTP($userSecret);
if ($totp->verify($userInput)) {
    echo "Успешная аутентификация!";
} else {
    echo "Неверный код.";
}

Метод generate() используется для случаев, когда актуальный код нужно показать, например для визуального сравнения с кодом в приложении.

Тесты

// 1. Генерация нового секрета
$secret = TOTP::generateSecret();
echo "Сгенерированный секрет: " . $secret . PHP_EOL;

// 2. Инициализация объекта
$totp = new TOTP($secret);

// 3. Генерация текущего кода
$currentCode = $totp->generate();
echo "Текущий OTP код: " . $currentCode . PHP_EOL;

// 4. Мгновенная проверка (должна вернуть true)
$isValid = $totp->verify($currentCode);
echo "Результат проверки (сразу): " . ($isValid ? "VALID" : "INVALID") . PHP_EOL;

// 5. Проверка с неверным кодом (должна вернуть false)
$isInvalid = $totp->verify("000000");
echo "Результат проверки (неверный код): " . ($isInvalid ? "VALID" : "INVALID") . PHP_EOL;

// 6. Вывод ссылки для Google Authenticator (для ручной проверки телефоном)
$issuer = "MyWebsite";
$account = "admin@example.com";
$otpauthUrl = "otpauth://totp/$issuer:$account?secret=$secret&issuer=$issuer";
echo "Ссылка для приложения (проверьте QR-генератором): " . $otpauthUrl . PHP_EOL;


Минимальная версия языка:

  • С readonly class и типами у const → PHP 8.3+
  • С readonly class, но без типов у const → PHP 8.2+
  • Без readonly, без типизированных const → PHP 7.4+

Ещё больше понижать, надо уже переписывать.

Обновил код, закрыл несколько уязвимостей.

  • Генерация секрета: Ушел от, как оказалось, неплохо подбираемого microtime() в пользу random_bytes(). Теперь секрет криптографически стоек и его нельзя вычислить, зная время создания аккаунта. Сам не знал что так можно, сам нагуглил, сам себя взломал, сам удивился. :sweat_smile:
  • Timing Attack: Сравнение кодов в методе verify теперь идет через hash_equals(). Это защищает от подбора кода по времени ответа сервера. Да, оказалось, что окно в 30 секунд это прямо дофига чтобы тупо перебором подобрать 6-значный код. Поэтому посимвольное сравнение === проигрывает гонку.
  • Упаковка данных: Для счетчика времени теперь используется pack('J', $timeCounter). Это корректная 64-битная упаковка (Big Endian), что избавляет от проблем с переполнением и ручной склейкой байтов. Тут мне просто знающие люди подсказали, что 32-бита это нестабильно и лучше перестраховаться.

Добавил блок для внутренних тестов. Для проверки можно добавить в любое приложение (Google Authenticator, Authy) через ссылку otpauth://. Я проверял, работает. Если переносы строк не отстреливают, замените PHP_EOL на <br>.