Files
TOTP/TOTP.php
Ivor Barhansky e226864351 Update TOTP.php
- Добавлен метод `verify` для верификации TOTP с учётом возможных временных расхождений (например, из-за задержек в синхронизации времени). *Нужно проверять OTP не только для текущего временного интервала, но и для соседних интервалов (±1 или ±2 временных шага). Это стандартная практика, описанная в RFC 6238, чтобы учесть небольшие рассинхронизации между сервером и клиентским устройством (телефоном).* 

- Кэширование декодированного секрета:
	- Добавлено свойство `private string $decodedSecret`.
	- В конструкторе теперь вызывается `$this->base32decode($secret)` один раз, и результат сохраняется в `$decodedSecret`.
	- В методах `generate` и `verify` (через `computeOtp`) используется `$this->decodedSecret` вместо повторного вызова `base32decode`. Это снижает вычислительные затраты, так как декодирование Base32 выполняется только при создании объекта.

- Вынос логики OTP и константы:
	- Добавлен приватный метод computeOtp(int $timeCounter, int $digits): string, который содержит логику вычисления OTP (упаковка времени, HMAC-SHA1, извлечение и форматирование). Это устраняет дублирование кода из generate и verify.
	- В `generate` теперь просто вычисляется `$timeCounter` и вызывается `computeOtp`.
	- В `verify` используется `computeOtp` для генерации OTP на каждой итерации цикла.
	- Добавлены константы `DEFAULT_DIGITS = 6`, `DEFAULT_TIME_STEP = 30`, `DEFAULT_SECRET_LENGTH = 20` для параметров по умолчанию. Они используются в методах `generate`, `verify` и `generateSecret`, что делает код более декларативным и упрощает изменение значений в будущем.
2025-08-27 15:06:19 +00:00

135 lines
6.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
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(private string $secret)
{
$this->decodedSecret = $this->base32decode($secret); // Декодируем секрет один раз при создании объекта
}
/**
* Генерация случайного секретного ключа OTP заданной длины.
*
* @param string $uniqueString Уникальная строка для создания сида.
* @param int $length Длина секретного ключа (по умолчанию: 20).
* @return string Сгенерированный секретный ключ OTP.
*/
public static function generateSecret(string $uniqueString, int $length = self::DEFAULT_SECRET_LENGTH): string
{
// Уникальная строка + текущее время в микросекундах
$seed = $uniqueString . microtime(true);
// Хэшируем сид для более случайного распределения
$seed = hash('sha256', $seed);
$secret = '';
$max = strlen(self::BASE32CHARS) - 1;
// Генерируем случайную строку нужной длины
for ($i = 0; $i < $length; $i++) {
$index = hexdec(substr($seed, $i, 2)) % $max; // Получаем индекс символа из хэшированного сида
$secret .= self::BASE32CHARS[$index];
}
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 ($otp === $generatedOtp) {
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('N*', 0) . pack('N*', $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;
}
}