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:
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user