Enable Discovery debug logging for production troubleshooting

- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Framework\Totp;
/**
* TOTP Cache Interface
*
* Interface for caching used TOTP codes to prevent replay attacks.
*/
interface TotpCache
{
/**
* Check if a code has been used
*/
public function hasUsedCode(string $key): bool;
/**
* Mark a code as used
*/
public function markCodeAsUsed(string $key, int $ttl): void;
/**
* Clear expired codes
*/
public function clearExpired(): int;
/**
* Clear all cached codes
*/
public function clear(): void;
/**
* Get cache statistics
*/
public function getStats(): array;
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Framework\Totp;
/**
* TOTP Configuration Validation Result
*
* Value object containing validation results for TOTP configuration parameters.
*/
final readonly class TotpConfigurationValidation
{
public function __construct(
public bool $isValid,
public array $errors,
public array $warnings,
public array $recommendedChanges
) {
}
/**
* Check if configuration has any errors
*/
public function hasErrors(): bool
{
return ! empty($this->errors);
}
/**
* Check if configuration has any warnings
*/
public function hasWarnings(): bool
{
return ! empty($this->warnings);
}
/**
* Check if configuration has recommendations
*/
public function hasRecommendations(): bool
{
return ! empty($this->recommendedChanges);
}
/**
* Get all issues (errors and warnings combined)
*/
public function getAllIssues(): array
{
return array_merge($this->errors, $this->warnings);
}
/**
* Get validation summary
*/
public function getSummary(): string
{
if ($this->isValid) {
if ($this->hasWarnings()) {
return 'Configuration is valid but has ' . count($this->warnings) . ' warnings';
}
return 'Configuration is valid';
}
return 'Configuration is invalid: ' . implode(', ', $this->errors);
}
/**
* Convert to array for API responses
*/
public function toArray(): array
{
return [
'is_valid' => $this->isValid,
'errors' => $this->errors,
'warnings' => $this->warnings,
'recommended_changes' => $this->recommendedChanges,
'has_errors' => $this->hasErrors(),
'has_warnings' => $this->hasWarnings(),
'has_recommendations' => $this->hasRecommendations(),
'summary' => $this->getSummary(),
];
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Framework\Totp;
/**
* TOTP QR Code Data Value Object
*
* Contains all information needed for QR code generation for TOTP setup.
*/
final readonly class TotpQrData
{
public function __construct(
public string $uri,
public TotpSecret $secret,
public string $accountName,
public string $issuer,
public int $digits,
public int $period,
public string $algorithm,
public ?string $qrCodeSvg = null,
public ?string $qrCodeDataUri = null
) {
}
/**
* Get the otpauth URI
*/
public function getUri(): string
{
return $this->uri;
}
/**
* Get Base32 encoded secret for manual entry
*/
public function getManualEntryKey(): string
{
return $this->secret->toFormattedBase32();
}
/**
* Get setup instructions for manual entry
*/
public function getManualSetupInstructions(): array
{
return [
'account' => $this->accountName,
'issuer' => $this->issuer,
'secret' => $this->getManualEntryKey(),
'digits' => $this->digits,
'period' => $this->period,
'algorithm' => strtoupper($this->algorithm),
];
}
/**
* Convert to array for JSON responses
*/
public function toArray(): array
{
return [
'qr_uri' => $this->uri,
'qr_code_svg' => $this->qrCodeSvg,
'qr_code_data_uri' => $this->qrCodeDataUri,
'manual_entry_key' => $this->getManualEntryKey(),
'account_name' => $this->accountName,
'issuer' => $this->issuer,
'digits' => $this->digits,
'period' => $this->period,
'algorithm' => $this->algorithm,
'setup_instructions' => $this->getManualSetupInstructions(),
];
}
}

View File

@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
namespace App\Framework\Totp;
use App\Framework\Core\Encoding\Base32Alphabet;
use App\Framework\Core\Encoding\Base32Encoder;
use App\Framework\Random\RandomGenerator;
use InvalidArgumentException;
use SensitiveParameter;
/**
* TOTP Secret Value Object
*
* Immutable value object representing a TOTP secret key with validation,
* encoding support, and security features for Time-based One-Time Passwords.
*/
final readonly class TotpSecret
{
private const int MIN_SECRET_LENGTH = 10; // 80 bits minimum (RFC 4226)
private const int RECOMMENDED_LENGTH = 20; // 160 bits recommended
private const int MAX_SECRET_LENGTH = 128; // Practical maximum
private function __construct(
private string $binarySecret
) {
$this->validate($binarySecret);
}
/**
* Create TOTP secret from binary data
*/
public static function fromBinary(#[SensitiveParameter] string $binaryData): self
{
return new self($binaryData);
}
/**
* Create TOTP secret from Base32 encoded string
*/
public static function fromBase32(#[SensitiveParameter] string $base32Secret): self
{
if (empty($base32Secret)) {
throw new InvalidArgumentException('TOTP secret cannot be empty');
}
if (! Base32Alphabet::RFC3548->isValidEncoded($base32Secret)) {
throw new InvalidArgumentException('Invalid Base32 TOTP secret format');
}
$binaryData = Base32Encoder::decode($base32Secret);
return new self($binaryData);
}
/**
* Generate a cryptographically secure random TOTP secret
*/
public static function generate(
RandomGenerator $randomGenerator,
int $length = self::RECOMMENDED_LENGTH
): self {
if ($length < self::MIN_SECRET_LENGTH) {
throw new InvalidArgumentException(
sprintf('TOTP secret must be at least %d bytes', self::MIN_SECRET_LENGTH)
);
}
if ($length > self::MAX_SECRET_LENGTH) {
throw new InvalidArgumentException(
sprintf('TOTP secret cannot exceed %d bytes', self::MAX_SECRET_LENGTH)
);
}
$binarySecret = $randomGenerator->bytes($length);
return new self($binarySecret);
}
/**
* Get the binary representation of the secret
*/
public function getBinary(): string
{
return $this->binarySecret;
}
/**
* Get the Base32 encoded representation of the secret
*/
public function toBase32(): string
{
return Base32Encoder::encode($this->binarySecret);
}
/**
* Get the Base32 encoded secret formatted for display
*/
public function toFormattedBase32(int $groupSize = 4): string
{
$base32 = $this->toBase32();
return Base32Encoder::formatForDisplay($base32, $groupSize);
}
/**
* Get the length of the secret in bytes
*/
public function getLength(): int
{
return strlen($this->binarySecret);
}
/**
* Get the length of the secret in bits
*/
public function getBitLength(): int
{
return $this->getLength() * 8;
}
/**
* Check if this secret meets security requirements
*/
public function isSecure(): bool
{
return $this->getLength() >= self::RECOMMENDED_LENGTH;
}
/**
* Get security level assessment
*/
public function getSecurityLevel(): TotpSecurityLevel
{
$length = $this->getLength();
return match (true) {
$length >= 32 => TotpSecurityLevel::HIGH, // 256+ bits
$length >= 20 => TotpSecurityLevel::STANDARD, // 160+ bits (recommended)
$length >= 16 => TotpSecurityLevel::MEDIUM, // 128+ bits
$length >= 10 => TotpSecurityLevel::LOW, // 80+ bits (minimum)
default => TotpSecurityLevel::INSUFFICIENT
};
}
/**
* Generate QR code data URI for authenticator apps
*/
public function toQrCodeUri(
string $accountName,
string $issuer,
int $digits = 6,
int $period = 30,
string $algorithm = 'SHA1'
): string {
$params = [
'secret' => $this->toBase32(),
'issuer' => $issuer,
'algorithm' => strtoupper($algorithm),
'digits' => $digits,
'period' => $period,
];
$queryString = http_build_query($params);
$label = urlencode($issuer . ':' . $accountName);
return sprintf('otpauth://totp/%s?%s', $label, $queryString);
}
/**
* Derive a new secret using HKDF for key rotation
*/
public function deriveNew(RandomGenerator $randomGenerator, string $info = 'totp-rotation'): self
{
// Use HKDF to derive a new secret from the current one
$salt = $randomGenerator->bytes(16);
$derivedKey = hash_hkdf('sha256', $this->binarySecret, $this->getLength(), $info, $salt);
return new self($derivedKey);
}
/**
* Check if two secrets are equal (timing-safe comparison)
*/
public function equals(TotpSecret $other): bool
{
return hash_equals($this->binarySecret, $other->binarySecret);
}
/**
* Get entropy assessment
*/
public function getEntropy(): float
{
return $this->getBitLength();
}
/**
* Check if secret has sufficient entropy for production use
*/
public function hasSufficientEntropy(): bool
{
return $this->getEntropy() >= 160; // 160 bits recommended
}
/**
* Get metadata about the secret
*/
public function getMetadata(): array
{
return [
'length_bytes' => $this->getLength(),
'length_bits' => $this->getBitLength(),
'entropy' => $this->getEntropy(),
'security_level' => $this->getSecurityLevel()->value,
'is_secure' => $this->isSecure(),
'sufficient_entropy' => $this->hasSufficientEntropy(),
'base32_length' => strlen($this->toBase32()),
];
}
/**
* Create a masked representation for logging
*/
public function toMaskedString(): string
{
$base32 = $this->toBase32();
$visibleLength = min(4, strlen($base32));
return substr($base32, 0, $visibleLength) . str_repeat('*', strlen($base32) - $visibleLength);
}
/**
* Validate the binary secret
*/
private function validate(string $binarySecret): void
{
if (empty($binarySecret)) {
throw new InvalidArgumentException('TOTP secret cannot be empty');
}
$length = strlen($binarySecret);
if ($length < self::MIN_SECRET_LENGTH) {
throw new InvalidArgumentException(
sprintf('TOTP secret must be at least %d bytes, got %d', self::MIN_SECRET_LENGTH, $length)
);
}
if ($length > self::MAX_SECRET_LENGTH) {
throw new InvalidArgumentException(
sprintf('TOTP secret cannot exceed %d bytes, got %d', self::MAX_SECRET_LENGTH, $length)
);
}
}
/**
* Prevent serialization of sensitive data
*/
public function __serialize(): array
{
throw new \RuntimeException('TotpSecret cannot be serialized');
}
/**
* Prevent unserialization of sensitive data
*/
public function __unserialize(array $data): void
{
throw new \RuntimeException('TotpSecret cannot be unserialized');
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Framework\Totp;
/**
* TOTP Security Level Enum
*
* Defines security levels for TOTP secrets based on entropy and key length.
*/
enum TotpSecurityLevel: string
{
case INSUFFICIENT = 'insufficient'; // < 80 bits
case LOW = 'low'; // 80-127 bits
case MEDIUM = 'medium'; // 128-159 bits
case STANDARD = 'standard'; // 160-255 bits (recommended)
case HIGH = 'high'; // 256+ bits
/**
* Get human-readable label
*/
public function getLabel(): string
{
return match ($this) {
self::INSUFFICIENT => 'Insufficient',
self::LOW => 'Low',
self::MEDIUM => 'Medium',
self::STANDARD => 'Standard',
self::HIGH => 'High'
};
}
/**
* Get description of security level
*/
public function getDescription(): string
{
return match ($this) {
self::INSUFFICIENT => 'Below minimum security requirements',
self::LOW => 'Minimum acceptable security (80+ bits)',
self::MEDIUM => 'Adequate security for most applications',
self::STANDARD => 'Recommended security level (160+ bits)',
self::HIGH => 'Maximum security for sensitive applications'
};
}
/**
* Check if this level is acceptable for production
*/
public function isProductionReady(): bool
{
return match ($this) {
self::INSUFFICIENT => false,
self::LOW => false,
self::MEDIUM => true,
self::STANDARD => true,
self::HIGH => true
};
}
/**
* Get minimum bits for this level
*/
public function getMinimumBits(): int
{
return match ($this) {
self::INSUFFICIENT => 0,
self::LOW => 80,
self::MEDIUM => 128,
self::STANDARD => 160,
self::HIGH => 256
};
}
}

View File

@@ -0,0 +1,385 @@
<?php
declare(strict_types=1);
namespace App\Framework\Totp;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\Cryptography\ConstantTimeExecutor;
use App\Framework\QrCode\QrCodeGenerator;
use App\Framework\Random\RandomGenerator;
use InvalidArgumentException;
/**
* TOTP Service
*
* Time-based One-Time Password service implementing RFC 6238.
* Provides TOTP generation, verification with time window tolerance,
* and replay attack protection.
*/
final readonly class TotpService
{
private const int DEFAULT_DIGITS = 6;
private const int DEFAULT_PERIOD = 30;
private const int DEFAULT_WINDOW = 1;
// Default algorithm
public static function getDefaultAlgorithm(): HashAlgorithm
{
return HashAlgorithm::SHA1; // SHA1 for maximum compatibility
}
// Supported algorithms for TOTP
public static function getSupportedAlgorithms(): array
{
return [
HashAlgorithm::SHA1,
HashAlgorithm::SHA256,
HashAlgorithm::SHA512,
];
}
public function __construct(
private RandomGenerator $randomGenerator,
private ConstantTimeExecutor $constantTimeExecutor,
private QrCodeGenerator $qrCodeGenerator,
private ?TotpCache $cache = null
) {
}
/**
* Generate a TOTP code for the current time
*/
public function generate(
TotpSecret $secret,
?int $timestamp = null,
int $digits = self::DEFAULT_DIGITS,
int $period = self::DEFAULT_PERIOD,
?HashAlgorithm $algorithm = null
): string {
$algorithm = $algorithm ?? self::getDefaultAlgorithm();
$timestamp = $timestamp ?? time();
$this->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,
];
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Framework\Totp;
/**
* TOTP Verification Result Value Object
*
* Immutable result of TOTP code verification containing success status,
* time window information, and security details.
*/
final readonly class TotpVerificationResult
{
private function __construct(
public bool $isValid,
public ?int $timeWindowOffset,
public ?int $timeStep,
public string $failureReason = '',
public bool $isReplayAttack = false
) {
}
/**
* Create successful verification result
*/
public static function success(int $timeWindowOffset, int $timeStep): self
{
return new self(
isValid: true,
timeWindowOffset: $timeWindowOffset,
timeStep: $timeStep
);
}
/**
* Create failed verification result
*/
public static function failed(string $reason = 'Invalid TOTP code'): self
{
return new self(
isValid: false,
timeWindowOffset: null,
timeStep: null,
failureReason: $reason
);
}
/**
* Create replay attack result
*/
public static function replayAttack(int $timeWindowOffset): self
{
return new self(
isValid: false,
timeWindowOffset: $timeWindowOffset,
timeStep: null,
failureReason: 'Code has already been used',
isReplayAttack: true
);
}
/**
* Check if verification was successful
*/
public function isSuccess(): bool
{
return $this->isValid;
}
/**
* Check if this was a replay attack attempt
*/
public function isReplayAttack(): bool
{
return $this->isReplayAttack;
}
/**
* Get failure reason
*/
public function getFailureReason(): string
{
return $this->failureReason;
}
/**
* Check if the code was verified within the current time window
*/
public function isCurrentTimeWindow(): bool
{
return $this->isValid && $this->timeWindowOffset === 0;
}
/**
* Check if the code was from a past time window
*/
public function isPastTimeWindow(): bool
{
return $this->isValid && $this->timeWindowOffset !== null && $this->timeWindowOffset < 0;
}
/**
* Check if the code was from a future time window
*/
public function isFutureTimeWindow(): bool
{
return $this->isValid && $this->timeWindowOffset !== null && $this->timeWindowOffset > 0;
}
/**
* Get security assessment
*/
public function getSecurityAssessment(): string
{
if (! $this->isValid) {
return $this->isReplayAttack ? 'Replay attack detected' : 'Invalid code';
}
return match (true) {
$this->isCurrentTimeWindow() => 'Perfect timing',
$this->isPastTimeWindow() => 'Clock drift detected (past)',
$this->isFutureTimeWindow() => 'Clock drift detected (future)',
default => 'Valid but unusual timing'
};
}
/**
* Convert to array for API responses
*/
public function toArray(): array
{
return [
'is_valid' => $this->isValid,
'time_window_offset' => $this->timeWindowOffset,
'time_step' => $this->timeStep,
'failure_reason' => $this->failureReason,
'is_replay_attack' => $this->isReplayAttack,
'is_current_window' => $this->isCurrentTimeWindow(),
'security_assessment' => $this->getSecurityAssessment(),
];
}
/**
* Get human-readable summary
*/
public function getSummary(): string
{
if (! $this->isValid) {
return $this->failureReason;
}
$summary = 'TOTP verification successful';
if ($this->timeWindowOffset !== 0) {
$direction = $this->timeWindowOffset > 0 ? 'ahead' : 'behind';
$summary .= sprintf(' (clock %d steps %s)', abs($this->timeWindowOffset), $direction);
}
return $summary;
}
}