validateParameters($digits, $period, $algorithm); $timeStep = intval($timestamp / $period); return $this->calculateTotp($secret, $timeStep, $digits, $algorithm); } /** * Verify a TOTP code against a secret */ public function verify( TotpSecret $secret, string $code, ?int $timestamp = null, int $digits = self::DEFAULT_DIGITS, int $period = self::DEFAULT_PERIOD, ?HashAlgorithm $algorithm = null, int $window = self::DEFAULT_WINDOW ): TotpVerificationResult { return $this->constantTimeExecutor->execute( fn () => $this->doVerification($secret, $code, $timestamp, $digits, $period, $algorithm, $window) ); } /** * Internal verification logic (wrapped by constant time executor) */ private function doVerification( TotpSecret $secret, string $code, ?int $timestamp, int $digits, int $period, ?HashAlgorithm $algorithm, int $window ): TotpVerificationResult { $algorithm = $algorithm ?? self::getDefaultAlgorithm(); $timestamp = $timestamp ?? time(); $this->validateParameters($digits, $period, $algorithm); $this->validateCode($code, $digits); $timeStep = intval($timestamp / $period); // Check current time step and adjacent windows for clock drift for ($i = -$window; $i <= $window; $i++) { $testTimeStep = $timeStep + $i; $expectedCode = $this->calculateTotp($secret, $testTimeStep, $digits, $algorithm); if (hash_equals($code, $expectedCode)) { // Check for replay attacks if ($this->cache && $this->isCodeUsed($secret, $testTimeStep)) { return TotpVerificationResult::replayAttack($i); } // Mark code as used to prevent replay attacks if ($this->cache) { $this->markCodeAsUsed($secret, $testTimeStep, $period); } return TotpVerificationResult::success($i, $testTimeStep); } } return TotpVerificationResult::failed(); } /** * Generate a new TOTP secret */ public function generateSecret(int $length = 20): TotpSecret { return TotpSecret::generate($this->randomGenerator, $length); } /** * Create QR code data for authenticator app setup */ public function createQrCodeData( TotpSecret $secret, string $accountName, string $issuer, int $digits = self::DEFAULT_DIGITS, int $period = self::DEFAULT_PERIOD, ?HashAlgorithm $algorithm = null ): TotpQrData { $algorithm = $algorithm ?? self::getDefaultAlgorithm(); $this->validateParameters($digits, $period, $algorithm); $uri = $secret->toQrCodeUri($accountName, $issuer, $digits, $period, $algorithm->value); // Generate QR code SVG and data URI $qrCodeSvg = $this->qrCodeGenerator->generateTotpQrCode($uri); $qrCodeDataUri = $this->qrCodeGenerator->generateDataUri($uri); return new TotpQrData( uri: $uri, secret: $secret, accountName: $accountName, issuer: $issuer, digits: $digits, period: $period, algorithm: $algorithm->value, qrCodeSvg: $qrCodeSvg, qrCodeDataUri: $qrCodeDataUri ); } /** * Get the current time step for a given timestamp */ public function getCurrentTimeStep(?int $timestamp = null, int $period = self::DEFAULT_PERIOD): int { $timestamp = $timestamp ?? time(); return intval($timestamp / $period); } /** * Get the time remaining until the next TOTP period */ public function getTimeRemaining(?int $timestamp = null, int $period = self::DEFAULT_PERIOD): int { $timestamp = $timestamp ?? time(); return $period - ($timestamp % $period); } /** * Validate TOTP configuration parameters */ public function validateConfiguration( int $digits, int $period, HashAlgorithm $algorithm, int $window = self::DEFAULT_WINDOW ): TotpConfigurationValidation { $errors = []; $warnings = []; // Validate digits if ($digits < 4 || $digits > 8) { $errors[] = 'Digits must be between 4 and 8'; } elseif ($digits < 6) { $warnings[] = '6 digits recommended for better security'; } // Validate period if ($period < 15 || $period > 300) { $errors[] = 'Period must be between 15 and 300 seconds'; } elseif ($period !== 30) { $warnings[] = '30 seconds is the standard period for compatibility'; } // Validate algorithm if (! in_array($algorithm, self::getSupportedAlgorithms(), true)) { $errors[] = 'Unsupported algorithm: ' . $algorithm->value; } elseif ($algorithm !== HashAlgorithm::SHA1) { $warnings[] = 'SHA1 is most compatible with authenticator apps'; } // Validate window if ($window < 0 || $window > 5) { $errors[] = 'Window must be between 0 and 5'; } elseif ($window > 2) { $warnings[] = 'Large windows reduce security'; } return new TotpConfigurationValidation( isValid: empty($errors), errors: $errors, warnings: $warnings, recommendedChanges: $this->getRecommendedChanges($digits, $period, $algorithm, $window) ); } /** * Calculate TOTP value for a specific time step */ private function calculateTotp( TotpSecret $secret, int $timeStep, int $digits, HashAlgorithm $algorithm ): string { // Convert time step to 8-byte big-endian binary $timeBytes = pack('N*', 0) . pack('N*', $timeStep); // Calculate HMAC $hash = hash_hmac($algorithm->value, $timeBytes, $secret->getBinary(), true); // Dynamic truncation (RFC 4226) $offset = ord($hash[strlen($hash) - 1]) & 0xf; $code = ( ((ord($hash[$offset]) & 0x7f) << 24) | ((ord($hash[$offset + 1]) & 0xff) << 16) | ((ord($hash[$offset + 2]) & 0xff) << 8) | (ord($hash[$offset + 3]) & 0xff) ) % (10 ** $digits); return str_pad((string) $code, $digits, '0', STR_PAD_LEFT); } /** * Check if a code has already been used (replay attack prevention) */ private function isCodeUsed(TotpSecret $secret, int $timeStep): bool { if (! $this->cache) { return false; } $key = $this->getUsageKey($secret, $timeStep); return $this->cache->hasUsedCode($key); } /** * Mark a code as used to prevent replay attacks */ private function markCodeAsUsed(TotpSecret $secret, int $timeStep, int $period): void { if (! $this->cache) { return; } $key = $this->getUsageKey($secret, $timeStep); $ttl = $period * 2; // Keep for 2 periods to handle window $this->cache->markCodeAsUsed($key, $ttl); } /** * Generate cache key for used code tracking */ private function getUsageKey(TotpSecret $secret, int $timeStep): string { // Use first 8 bytes of secret hash + time step for cache key $secretHash = hash('sha256', $secret->getBinary(), true); $shortHash = substr($secretHash, 0, 8); return hash('sha256', $shortHash . ':' . $timeStep); } /** * Validate TOTP parameters */ private function validateParameters(int $digits, int $period, HashAlgorithm $algorithm): void { if ($digits < 4 || $digits > 8) { throw new InvalidArgumentException('TOTP digits must be between 4 and 8'); } if ($period < 1) { throw new InvalidArgumentException('TOTP period must be positive'); } if (! in_array($algorithm, self::getSupportedAlgorithms(), true)) { $supported = array_map(fn ($alg) => $alg->value, self::getSupportedAlgorithms()); throw new InvalidArgumentException( 'Unsupported TOTP algorithm: ' . $algorithm->value . '. Supported: ' . implode(', ', $supported) ); } } /** * Validate TOTP code format */ private function validateCode(string $code, int $digits): void { if (! ctype_digit($code)) { throw new InvalidArgumentException('TOTP code must contain only digits'); } if (strlen($code) !== $digits) { throw new InvalidArgumentException( sprintf('TOTP code must be exactly %d digits, got %d', $digits, strlen($code)) ); } } /** * Get recommended configuration changes */ private function getRecommendedChanges(int $digits, int $period, HashAlgorithm $algorithm, int $window): array { $changes = []; if ($digits !== 6) { $changes[] = 'Use 6 digits for standard compatibility'; } if ($period !== 30) { $changes[] = 'Use 30-second period for standard compatibility'; } if ($algorithm !== HashAlgorithm::SHA1) { $changes[] = 'Consider SHA1 for maximum compatibility'; } if ($window > 1) { $changes[] = 'Reduce window size to improve security'; } return $changes; } /** * Get default configuration */ public function getDefaultConfiguration(): array { return [ 'digits' => self::DEFAULT_DIGITS, 'period' => self::DEFAULT_PERIOD, 'algorithm' => self::getDefaultAlgorithm(), 'window' => self::DEFAULT_WINDOW, ]; } }