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:
38
src/Framework/Totp/TotpCache.php
Normal file
38
src/Framework/Totp/TotpCache.php
Normal 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;
|
||||
}
|
||||
86
src/Framework/Totp/TotpConfigurationValidation.php
Normal file
86
src/Framework/Totp/TotpConfigurationValidation.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
76
src/Framework/Totp/TotpQrData.php
Normal file
76
src/Framework/Totp/TotpQrData.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
273
src/Framework/Totp/TotpSecret.php
Normal file
273
src/Framework/Totp/TotpSecret.php
Normal 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');
|
||||
}
|
||||
}
|
||||
75
src/Framework/Totp/TotpSecurityLevel.php
Normal file
75
src/Framework/Totp/TotpSecurityLevel.php
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
385
src/Framework/Totp/TotpService.php
Normal file
385
src/Framework/Totp/TotpService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
162
src/Framework/Totp/TotpVerificationResult.php
Normal file
162
src/Framework/Totp/TotpVerificationResult.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user