- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
386 lines
12 KiB
PHP
386 lines
12 KiB
PHP
<?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,
|
|
];
|
|
}
|
|
}
|