Files
michaelschiemer/src/Framework/Totp/TotpService.php
Michael Schiemer 55a330b223 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
2025-08-11 20:13:26 +02:00

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,
];
}
}