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; } }