Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.49% covered (success)
91.49%
86 / 94
66.67% covered (warning)
66.67%
8 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
MfaTotpService
91.40% covered (success)
91.40%
85 / 93
66.67% covered (warning)
66.67%
8 / 12
30.57
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 generateSecret
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
 calculateCode
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 verifyCode
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
7.01
 buildOtpAuthUri
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 generateRecoveryCodes
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 encryptSecret
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 decryptSecret
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 calculateStepCode
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 sanitizeSecret
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 base32Encode
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
5.06
 base32Decode
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
6.04
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Security\Mfa;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Security\Encryption\AesGcmEncryptionService;
12use App\Core\Security\Encryption\EncryptionServiceInterface;
13use App\Core\Time\NtpTimeSyncService;
14use App\Core\Time\NtpTimeSyncServiceInterface;
15use InvalidArgumentException;
16use 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 */
26final 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}