Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
91.49% |
86 / 94 |
|
66.67% |
8 / 12 |
CRAP | |
0.00% |
0 / 1 |
| MfaTotpService | |
91.40% |
85 / 93 |
|
66.67% |
8 / 12 |
30.57 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| generateSecret | |
50.00% |
3 / 6 |
|
0.00% |
0 / 1 |
4.12 | |||
| calculateCode | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| verifyCode | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
7.01 | |||
| buildOtpAuthUri | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
| generateRecoveryCodes | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| encryptSecret | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| decryptSecret | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| calculateStepCode | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
| sanitizeSecret | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| base32Encode | |
86.67% |
13 / 15 |
|
0.00% |
0 / 1 |
5.06 | |||
| base32Decode | |
90.00% |
18 / 20 |
|
0.00% |
0 / 1 |
6.04 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Core\Security\Mfa; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Security\Encryption\AesGcmEncryptionService; |
| 12 | use App\Core\Security\Encryption\EncryptionServiceInterface; |
| 13 | use App\Core\Time\NtpTimeSyncService; |
| 14 | use App\Core\Time\NtpTimeSyncServiceInterface; |
| 15 | use InvalidArgumentException; |
| 16 | use Random\RandomException; |
| 17 | |
| 18 | /** |
| 19 | * Enterprise Multi-Factor Authentication TOTP Engine. |
| 20 | * |
| 21 | * Implements RFC 6238 and NIST SP 800-63B standards with authoritative NTP time sync, |
| 22 | * anti-replay protection, Base32 encoding, and AEAD-encrypted secret persistence. |
| 23 | * |
| 24 | * @package App\Core\Security\Mfa |
| 25 | */ |
| 26 | final readonly class MfaTotpService implements MfaTotpServiceInterface |
| 27 | { |
| 28 | /** @var string Standard Base32 alphabet (RFC 4648). */ |
| 29 | private const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; |
| 30 | |
| 31 | /** @var EncryptionServiceInterface Encryption service for secret keys. */ |
| 32 | private EncryptionServiceInterface $encryptionService; |
| 33 | |
| 34 | /** @var NtpTimeSyncServiceInterface NTP time synchronization service. */ |
| 35 | private NtpTimeSyncServiceInterface $timeService; |
| 36 | |
| 37 | /** |
| 38 | * MfaTotpService constructor. |
| 39 | * |
| 40 | * @param EncryptionServiceInterface|null $encryptionService Optional encryption service. |
| 41 | * @param NtpTimeSyncServiceInterface|null $timeService Optional NTP time sync service. |
| 42 | */ |
| 43 | public function __construct( |
| 44 | ?EncryptionServiceInterface $encryptionService = null, |
| 45 | ?NtpTimeSyncServiceInterface $timeService = null |
| 46 | ) { |
| 47 | $this->encryptionService = $encryptionService ?? new AesGcmEncryptionService(); |
| 48 | $this->timeService = $timeService ?? new NtpTimeSyncService(); |
| 49 | } |
| 50 | |
| 51 | /** {@inheritdoc} */ |
| 52 | public function generateSecret(int $byteLength = 20): string |
| 53 | { |
| 54 | if ($byteLength < 16) { |
| 55 | throw new InvalidArgumentException('TOTP secret length must be at least 16 bytes for security.'); |
| 56 | } |
| 57 | |
| 58 | try { |
| 59 | $randomBytes = random_bytes($byteLength); |
| 60 | } catch (RandomException $e) { |
| 61 | throw new InvalidArgumentException('Cryptographically secure RNG failed: ' . $e->getMessage(), 0, $e); |
| 62 | } |
| 63 | |
| 64 | return $this->base32Encode($randomBytes); |
| 65 | } |
| 66 | |
| 67 | /** {@inheritdoc} */ |
| 68 | public function calculateCode( |
| 69 | string $secret, |
| 70 | ?int $timestamp = null, |
| 71 | int $digits = 6, |
| 72 | int $period = 30, |
| 73 | string $algorithm = 'sha1' |
| 74 | ): string { |
| 75 | $cleanSecret = $this->sanitizeSecret($secret); |
| 76 | $secretBytes = $this->base32Decode($cleanSecret); |
| 77 | $time = $timestamp ?? $this->timeService->getAdjustedTimestamp(); |
| 78 | $step = intdiv($time, $period); |
| 79 | |
| 80 | return $this->calculateStepCode($secretBytes, $step, $digits, $algorithm); |
| 81 | } |
| 82 | |
| 83 | /** {@inheritdoc} */ |
| 84 | public function verifyCode( |
| 85 | string $code, |
| 86 | string $secret, |
| 87 | ?int $lastUsedStep = null, |
| 88 | int $window = 1, |
| 89 | int $period = 30, |
| 90 | string $algorithm = 'sha1' |
| 91 | ): ?int { |
| 92 | $cleanCode = preg_replace('/\s+/', '', $code) ?? ''; |
| 93 | if ($cleanCode === '' || !ctype_digit($cleanCode)) { |
| 94 | return null; |
| 95 | } |
| 96 | |
| 97 | $cleanSecret = $this->sanitizeSecret($secret); |
| 98 | $secretBytes = $this->base32Decode($cleanSecret); |
| 99 | $digits = strlen($cleanCode); |
| 100 | |
| 101 | $now = $this->timeService->getAdjustedTimestamp(); |
| 102 | $currentStep = intdiv($now, $period); |
| 103 | |
| 104 | for ($offset = -$window; $offset <= $window; $offset++) { |
| 105 | $targetStep = $currentStep + $offset; |
| 106 | if ($lastUsedStep !== null && $targetStep <= $lastUsedStep) { |
| 107 | continue; |
| 108 | } |
| 109 | |
| 110 | $expectedCode = $this->calculateStepCode($secretBytes, $targetStep, $digits, $algorithm); |
| 111 | if (hash_equals($expectedCode, $cleanCode)) { |
| 112 | return $targetStep; |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | return null; |
| 117 | } |
| 118 | |
| 119 | /** {@inheritdoc} */ |
| 120 | public function buildOtpAuthUri( |
| 121 | string $accountName, |
| 122 | string $secret, |
| 123 | string $issuer = 'Ammonly', |
| 124 | int $digits = 6, |
| 125 | int $period = 30, |
| 126 | string $algorithm = 'sha1' |
| 127 | ): string { |
| 128 | $encodedLabel = rawurlencode($issuer . ':' . $accountName); |
| 129 | $cleanSecret = $this->sanitizeSecret($secret); |
| 130 | |
| 131 | $params = [ |
| 132 | 'secret' => $cleanSecret, |
| 133 | 'issuer' => $issuer, |
| 134 | 'algorithm' => strtoupper($algorithm), |
| 135 | 'digits' => (string) $digits, |
| 136 | 'period' => (string) $period, |
| 137 | ]; |
| 138 | |
| 139 | return 'otpauth://totp/' . $encodedLabel . '?' . http_build_query($params); |
| 140 | } |
| 141 | |
| 142 | /** {@inheritdoc} */ |
| 143 | public function generateRecoveryCodes(int $count = 8): array |
| 144 | { |
| 145 | $codes = []; |
| 146 | for ($i = 0; $i < $count; $i++) { |
| 147 | $raw = bin2hex(random_bytes(5)); |
| 148 | $formatted = strtoupper(substr($raw, 0, 5) . '-' . substr($raw, 5, 5)); |
| 149 | $codes[] = $formatted; |
| 150 | } |
| 151 | return $codes; |
| 152 | } |
| 153 | |
| 154 | /** {@inheritdoc} */ |
| 155 | public function encryptSecret(string $secret): string |
| 156 | { |
| 157 | return $this->encryptionService->encrypt($this->sanitizeSecret($secret)); |
| 158 | } |
| 159 | |
| 160 | /** {@inheritdoc} */ |
| 161 | public function decryptSecret(string $encrypted): string |
| 162 | { |
| 163 | return $this->encryptionService->decrypt($encrypted); |
| 164 | } |
| 165 | |
| 166 | /** |
| 167 | * Calculates TOTP code for single time step via HMAC and dynamic truncation (RFC 4226). |
| 168 | * |
| 169 | * @param string $secretBytes Raw binary secret key. |
| 170 | * @param int $step Discrete time step. |
| 171 | * @param int $digits Output digits count. |
| 172 | * @param string $algorithm Hash algorithm. |
| 173 | * @return string Formatted TOTP code. |
| 174 | */ |
| 175 | private function calculateStepCode(string $secretBytes, int $step, int $digits, string $algorithm): string |
| 176 | { |
| 177 | $packedStep = pack('J', $step); |
| 178 | $hash = hash_hmac($algorithm, $packedStep, $secretBytes, true); |
| 179 | $offset = ord($hash[strlen($hash) - 1]) & 0x0F; |
| 180 | |
| 181 | $binary = ((ord($hash[$offset]) & 0x7F) << 24) |
| 182 | | ((ord($hash[$offset + 1]) & 0xFF) << 16) |
| 183 | | ((ord($hash[$offset + 2]) & 0xFF) << 8) |
| 184 | | (ord($hash[$offset + 3]) & 0xFF); |
| 185 | |
| 186 | $modulo = 10 ** $digits; |
| 187 | $code = $binary % $modulo; |
| 188 | |
| 189 | return str_pad((string) $code, $digits, '0', STR_PAD_LEFT); |
| 190 | } |
| 191 | |
| 192 | /** |
| 193 | * Sanitizes Base32 secret string. |
| 194 | * |
| 195 | * @param string $secret Raw secret. |
| 196 | * @return string Normalized uppercase Base32 secret without spaces or padding. |
| 197 | */ |
| 198 | private function sanitizeSecret(string $secret): string |
| 199 | { |
| 200 | $upper = strtoupper(trim($secret)); |
| 201 | return str_replace([' ', '-', '='], '', $upper); |
| 202 | } |
| 203 | |
| 204 | /** |
| 205 | * Encodes binary data into Base32 string (RFC 4648). |
| 206 | * |
| 207 | * @param string $data Binary string. |
| 208 | * @return string Base32 encoded string. |
| 209 | */ |
| 210 | private function base32Encode(string $data): string |
| 211 | { |
| 212 | if ($data === '') { |
| 213 | return ''; |
| 214 | } |
| 215 | |
| 216 | $alphabet = self::BASE32_ALPHABET; |
| 217 | $output = ''; |
| 218 | $buffer = 0; |
| 219 | $bitsLeft = 0; |
| 220 | |
| 221 | $length = strlen($data); |
| 222 | for ($i = 0; $i < $length; $i++) { |
| 223 | $buffer = ($buffer << 8) | ord($data[$i]); |
| 224 | $bitsLeft += 8; |
| 225 | while ($bitsLeft >= 5) { |
| 226 | $bitsLeft -= 5; |
| 227 | $output .= $alphabet[($buffer >> $bitsLeft) & 0x1F]; |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | if ($bitsLeft > 0) { |
| 232 | $output .= $alphabet[($buffer << (5 - $bitsLeft)) & 0x1F]; |
| 233 | } |
| 234 | |
| 235 | return $output; |
| 236 | } |
| 237 | |
| 238 | /** |
| 239 | * Decodes Base32 string into binary data (RFC 4648). |
| 240 | * |
| 241 | * @param string $base32 Base32 string. |
| 242 | * @return string Binary string. |
| 243 | * @throws InvalidArgumentException On invalid Base32 characters. |
| 244 | */ |
| 245 | private function base32Decode(string $base32): string |
| 246 | { |
| 247 | $clean = strtoupper(str_replace([' ', '-', '='], '', $base32)); |
| 248 | if ($clean === '') { |
| 249 | return ''; |
| 250 | } |
| 251 | |
| 252 | $alphabet = self::BASE32_ALPHABET; |
| 253 | $lookup = []; |
| 254 | for ($i = 0; $i < 32; $i++) { |
| 255 | $lookup[$alphabet[$i]] = $i; |
| 256 | } |
| 257 | |
| 258 | $buffer = 0; |
| 259 | $bitsLeft = 0; |
| 260 | $output = ''; |
| 261 | |
| 262 | $length = strlen($clean); |
| 263 | for ($i = 0; $i < $length; $i++) { |
| 264 | $char = $clean[$i]; |
| 265 | if (!isset($lookup[$char])) { |
| 266 | throw new InvalidArgumentException("Invalid Base32 character encountered: '{$char}'"); |
| 267 | } |
| 268 | |
| 269 | $buffer = ($buffer << 5) | $lookup[$char]; |
| 270 | $bitsLeft += 5; |
| 271 | |
| 272 | if ($bitsLeft >= 8) { |
| 273 | $bitsLeft -= 8; |
| 274 | $output .= chr(($buffer >> $bitsLeft) & 0xFF); |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | return $output; |
| 279 | } |
| 280 | } |