Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.86% covered (warning)
68.86%
157 / 228
69.23% covered (warning)
69.23%
9 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuthWebAuthn
68.86% covered (warning)
68.86%
157 / 228
69.23% covered (warning)
69.23%
9 / 13
285.05
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
 prepareChallengeForRegistration
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
1 / 1
2
 storeUserInSession
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getUserFromSession
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 register
16.18% covered (danger)
16.18%
11 / 68
0.00% covered (danger)
0.00%
0 / 1
597.01
 prepareForLogin
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
8
 authenticate
80.65% covered (success)
80.65%
50 / 62
0.00% covered (danger)
0.00%
0 / 1
32.29
 setAppId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 decodeSignedClientData
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 byteString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 idList
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 arrayToString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 stringToArray
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Manages user authentication via WebAuthn.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2024-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2024-09-07
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Auth;
21
22use CBOR\CBOREncoder;
23use phpMyFAQ\Auth;
24use phpMyFAQ\Auth\WebAuthn\PublicKeyConverter;
25use phpMyFAQ\Auth\WebAuthn\WebAuthnUser;
26use phpMyFAQ\Configuration;
27use phpMyFAQ\Core\Exception;
28use phpMyFAQ\Utils;
29use Random\RandomException;
30use stdClass;
31use Symfony\Component\HttpFoundation\Session\Session;
32
33class AuthWebAuthn extends Auth
34{
35    private string $appId;
36
37    private const int ES256 = -7;
38
39    private const int RS256 = -257;
40
41    public function __construct(Configuration $configuration)
42    {
43        parent::__construct($configuration);
44
45        $this->setAppId(Utils::getHostFromUrl($configuration->getDefaultUrl()) ?? '');
46    }
47
48    /**
49     * Generate a challenge ready for registering a hardware key, fingerprint or whatever
50     *
51     * @return array<string, array<string, array|int|null>|string>
52     * @throws RandomException
53     */
54    public function prepareChallengeForRegistration(string $username, string $userId): array
55    {
56        $challenge = random_bytes(16);
57
58        // Convert the challenge to an array of bytes
59        $challengeArray = $this->stringToArray($challenge);
60
61        // Prepare user information
62        $user = [
63            'name' => $username,
64            'displayName' => $username,
65            'id' => $this->stringToArray($userId),
66        ];
67
68        // Prepare relying party (rp) information
69        $relyingParty = [
70            'name' => $this->appId,
71        ];
72
73        // Set the 'id' field if not running on localhost
74        if (!str_contains($this->appId, 'localhost')) {
75            $relyingParty['id'] = $this->appId;
76        }
77
78        // Prepare public key credential parameters
79        $pubKeyCredParams = [
80            [
81                'alg' => self::ES256,
82                'type' => 'public-key',
83            ],
84            [
85                'alg' => self::RS256,
86                'type' => 'public-key',
87            ],
88        ];
89
90        // Prepare authenticator selection criteria
91        $authSelection = [
92            'requireResidentKey' => false,
93            'userVerification' => 'preferred',
94        ];
95
96        // Prepare extensions
97        $extensions = [
98            'exts' => true,
99        ];
100
101        // Build the publicKey object
102        $publicKey = [
103            'challenge' => $challengeArray,
104            'user' => $user,
105            'rp' => $relyingParty,
106            'pubKeyCredParams' => $pubKeyCredParams,
107            'authenticatorSelection' => $authSelection,
108            'attestation' => null,
109            'timeout' => 60_000,
110            'excludeCredentials' => [],
111            'extensions' => $extensions,
112        ];
113
114        // Base64 URL-encode the challenge for later verification
115        $b64challenge = rtrim(
116            string: strtr(string: base64_encode(string: $challenge), from: '+/', to: '-_'),
117            characters: '=',
118        );
119
120        // Return the prepared data
121        return [
122            'publicKey' => $publicKey,
123            'b64challenge' => $b64challenge,
124        ];
125    }
126
127    /**
128     * Store the WebAuth user information in the session
129     */
130    public function storeUserInSession(WebAuthnUser $webAuthnUser): void
131    {
132        $session = new Session();
133        $session->set('webauthn', $webAuthnUser);
134    }
135
136    /**
137     * Get the WebAuth user information from the session
138     */
139    public function getUserFromSession(): ?WebAuthnUser
140    {
141        $session = new Session();
142        $webAuthnUser = $session->get('webauthn');
143
144        return $webAuthnUser instanceof WebAuthnUser ? $webAuthnUser : null;
145    }
146
147    /**
148     * Registers a new key for a user, requires info from the hardware via JavaScript given below and returns a modified
149     * user's webauthn field in your database
150     *
151     * @param string $info Info provided by the key
152     * @param string $userWebAuthn The existing WebAuthn field for the user
153     * @throws Exception
154     * @throws \Exception
155     */
156    public function register(string $info, string $userWebAuthn): string
157    {
158        $info = html_entity_decode($info);
159        $info = json_decode(json: $info, associative: false);
160
161        if (!is_object($info)) {
162            throw new Exception('info is not properly JSON encoded');
163        }
164
165        if (
166            !property_exists($info, 'response')
167            || !is_object($info->response)
168            || !property_exists($info->response, 'attestationObject')
169            || $info->response->attestationObject === null
170        ) {
171            throw new Exception('no attestationObject in info');
172        }
173
174        if (!property_exists($info, 'rawId') || $info->rawId === null || $info->rawId === []) {
175            throw new Exception('no rawId in info');
176        }
177
178        $attestationString = $this->byteString($info->response->attestationObject);
179        $attestationObject = (object) CBOREncoder::decode($attestationString);
180
181        if (
182            !property_exists($attestationObject, 'fmt')
183            || $attestationObject->fmt === null
184            || $attestationObject->fmt === ''
185        ) {
186            throw new Exception('Cannot decode key for format');
187        }
188
189        if (!property_exists($attestationObject, 'authData') || $attestationObject->authData === null) {
190            throw new Exception('Cannot decode key for authentication data');
191        }
192
193        $authData = $attestationObject->authData;
194        if (!is_object($authData) || !method_exists($authData, 'get_byte_string')) {
195            throw new Exception('Cannot decode key for authentication data');
196        }
197
198        $byteString = (string) $authData->get_byte_string();
199
200        if ($attestationObject->fmt === 'fido-u2f') {
201            throw new Exception('Cannot decode FIDO format responses');
202        }
203
204        if ($attestationObject->fmt !== 'none' && $attestationObject->fmt !== 'packed') {
205            throw new Exception('Cannot decode key for format if not none or packed');
206        }
207
208        $rpIdHash = substr(string: $byteString, offset: 0, length: 32);
209        $flags = ord(substr(string: $byteString, offset: 32, length: 1));
210
211        $hashId = hash(algo: 'sha256', data: $this->appId, binary: true);
212        if ($hashId !== $rpIdHash) {
213            throw new Exception('Cannot decode key as RP ID hash does not match');
214        }
215
216        if (($flags & 0x41) === 0) {
217            throw new Exception('Cannot decode key as flags are not correct');
218        }
219
220        $credIdLen = (ord($byteString[53]) << 8) + ord($byteString[54]);
221        $credId = substr(string: $byteString, offset: 55, length: $credIdLen);
222
223        $cborPubKey = substr(string: $byteString, offset: 55 + $credIdLen);
224
225        $keyBytes = PublicKeyConverter::fromCoseToPkcs($cborPubKey);
226
227        if (is_null($keyBytes)) {
228            $credIdLen = (ord($byteString[38]) << 8) + ord($byteString[39]);
229            $credId = substr(string: $byteString, offset: 40, length: $credIdLen);
230            $cborPubKey = substr(string: $byteString, offset: 40 + $credIdLen);
231            $keyBytes = PublicKeyConverter::fromCoseToPkcs($cborPubKey);
232            if (is_null($keyBytes)) {
233                throw new Exception('Cannot decode key for key bytes');
234            }
235        }
236
237        $rawId = $this->byteString($info->rawId);
238        if ($credId !== $rawId) {
239            throw new Exception('Cannot decode key for credId');
240        }
241
242        $publicKey = new stdClass();
243        $publicKey->key = $keyBytes;
244        $publicKey->id = $info->rawId;
245
246        if ($userWebAuthn === '' || $userWebAuthn === '0') {
247            return (string) json_encode([$publicKey]);
248        }
249
250        $existingKeys = json_decode($userWebAuthn);
251        if (!is_array($existingKeys)) {
252            $existingKeys = [];
253        }
254
255        $found = false;
256        foreach ($existingKeys as $key) {
257            if (!$key instanceof stdClass) {
258                continue;
259            }
260
261            if ($this->idList($key->id) !== $this->idList($publicKey->id)) {
262                continue;
263            }
264
265            $key->key = $publicKey->key;
266            $found = true;
267            break;
268        }
269
270        if (!$found) {
271            array_unshift($existingKeys, $publicKey);
272        }
273
274        return (string) json_encode($existingKeys);
275    }
276
277    /**
278     * Generates a new key string for the physical key, fingerprint reader, or whatever to respond to on login.
279     * You should store the revised $userWebAuthn back to your database after calling this function
280     * (to avoid replay attacks)
281     *
282     * @param string $userWebAuthn the existing webauthn field for the user from your database
283     * @throws RandomException
284     */
285    public function prepareForLogin(string &$userWebAuthn): stdClass
286    {
287        $allow = new stdClass();
288        $allow->type = 'public-key';
289        $allow->transports = ['usb', 'nfc', 'ble', 'internal'];
290        $allow->id = null;
291
292        $allows = [];
293
294        $challengeBytes = random_bytes(16);
295        $challengeB64 = rtrim(
296            string: strtr(string: base64_encode(string: $challengeBytes), from: '+/', to: '-_'),
297            characters: '=',
298        );
299
300        if ($userWebAuthn !== '' && $userWebAuthn !== '0') {
301            $storedKeys = json_decode($userWebAuthn);
302            if (is_array($storedKeys)) {
303                foreach ($storedKeys as $key) {
304                    if (!$key instanceof stdClass) {
305                        continue;
306                    }
307
308                    $allow->id = $key->id;
309                    $allows[] = clone $allow;
310                    $key->challenge = $challengeB64;
311                }
312
313                $userWebAuthn = (string) json_encode($storedKeys);
314            }
315        }
316
317        if ($userWebAuthn === '' || $userWebAuthn === '0') {
318            $allow->id = [];
319            $rb = md5((string) time());
320            $allow->id = $this->stringToArray($rb);
321            $allows[] = clone $allow;
322        }
323
324        /* generate key request */
325        $publicKey = new stdClass();
326        $publicKey->challenge = $this->stringToArray($challengeBytes);
327        $publicKey->timeout = 60_000;
328        $publicKey->allowCredentials = $allows;
329        $publicKey->userVerification = 'preferred';
330        $publicKey->rpId = str_replace(search: 'https://', replace: '', subject: $this->appId);
331
332        return $publicKey;
333    }
334
335    /**
336     * Validates a response for login or 2FA, requires info from the hardware via JavaScript given below.
337     *
338     * @param string $userWebAuthn the existing webauthn field for the user
339     * @throws Exception
340     */
341    public function authenticate(stdClass $info, string &$userWebAuthn): bool
342    {
343        $storedKeys = $userWebAuthn === '' || $userWebAuthn === '0' ? [] : json_decode($userWebAuthn);
344        if (!is_array($storedKeys)) {
345            $storedKeys = [];
346        }
347
348        $response = $info->response ?? null;
349        if (!$response instanceof stdClass) {
350            throw new Exception('No response in info');
351        }
352
353        // Everything the relying party verifies has to be read out of the clientDataJSON the
354        // authenticator actually signed over. The pre-parsed `clientData` object the client sends
355        // alongside it is unsigned, so an attacker replaying an assertion can put anything there.
356        $clientDataJson = $this->byteString($response->clientDataJSONarray ?? null);
357        $clientDataObject = $this->decodeSignedClientData($clientDataJson);
358
359        $rawIdList = $this->idList($info->rawId ?? null);
360
361        $key = null;
362        foreach ($storedKeys as $webAuthnKey) {
363            if (!$webAuthnKey instanceof stdClass) {
364                continue;
365            }
366
367            if ($this->idList($webAuthnKey->id) !== $rawIdList) {
368                continue;
369            }
370
371            $key = $webAuthnKey;
372            break;
373        }
374
375        if ($key === null) {
376            throw new Exception('No key with ID ' . $rawIdList);
377        }
378
379        // A key only carries a challenge between prepareForLogin() and the login that consumes it.
380        // No pending challenge means this assertion answers nothing we asked for, so it is either a
381        // replay or a login that never started: fail closed.
382        $storedChallenge = $key->challenge ?? null;
383        if (!is_string($storedChallenge) || $storedChallenge === '') {
384            throw new Exception('You cannot use the same login more than once');
385        }
386
387        $presentedChallenge = $clientDataObject->challenge ?? null;
388        if (!is_string($presentedChallenge) || !hash_equals($storedChallenge, $presentedChallenge)) {
389            throw new Exception('Challenge mismatch');
390        }
391
392        foreach ($storedKeys as $webAuthnKey) {
393            if (!$webAuthnKey instanceof stdClass) {
394                continue;
395            }
396
397            $webAuthnKey->challenge = '';
398        }
399
400        $userWebAuthn = (string) json_encode($storedKeys);
401
402        $clientOrigin = is_string($clientDataObject->origin ?? null) ? $clientDataObject->origin : '';
403        $origin = parse_url($clientOrigin);
404        $originHost = is_array($origin) ? $origin['host'] ?? null : null;
405        if ($originHost !== $this->appId) {
406            throw new Exception(sprintf("Origin mismatch for '%s'", $clientOrigin));
407        }
408
409        $clientType = is_string($clientDataObject->type ?? null) ? $clientDataObject->type : '';
410        if ($clientType !== 'webauthn.get') {
411            throw new Exception(sprintf("Type mismatch for '%s'", $clientType));
412        }
413
414        $authDataString = $this->byteString($response->authenticatorData ?? null);
415
416        $rpIdHash = substr(string: $authDataString, offset: 0, length: 32);
417        $flags = ord(substr(string: $authDataString, offset: 32, length: 1));
418        $counter = substr(string: $authDataString, offset: 33, length: 4);
419
420        $hashId = hash(algo: 'sha256', data: $this->appId, binary: true);
421        if ($hashId !== $rpIdHash) {
422            throw new Exception('Cannot decode key response for RP ID hash');
423        }
424
425        if (($flags & 0x1) !== 0x1) {
426            throw new Exception('Cannot decode key response (2c)');
427        }
428
429        $signedData = $hashId . chr($flags) . $counter . hash(algo: 'sha256', data: $clientDataJson, binary: true);
430
431        $signatureBytes = $response->signature ?? null;
432        if (!is_array($signatureBytes) || count($signatureBytes) < 70) {
433            throw new Exception('Cannot decode key response (3)');
434        }
435
436        $signature = $this->arrayToString($signatureBytes);
437
438        $publicKeyPem = (string) $key->key;
439        $verificationResult = openssl_verify($signedData, $signature, $publicKeyPem, OPENSSL_ALGO_SHA256);
440        if ($verificationResult === 1) {
441            return true;
442        }
443
444        if ($verificationResult === 0) {
445            return false;
446        }
447
448        $opensslError = openssl_error_string();
449        throw new Exception(
450            'Cannot decode key response because of ' . ($opensslError === false ? 'unknown error' : $opensslError),
451        );
452    }
453
454    public function setAppId(string $appId): void
455    {
456        $this->appId = $appId;
457    }
458
459    /**
460     * Decodes the clientDataJSON the authenticator signed over. This is the only trustworthy
461     * source for the challenge, origin and type, because the signature covers exactly these bytes.
462     *
463     * @throws Exception
464     */
465    private function decodeSignedClientData(string $clientDataJson): stdClass
466    {
467        $clientData = json_decode($clientDataJson);
468        if (!$clientData instanceof stdClass) {
469            throw new Exception('No client data in info');
470        }
471
472        return $clientData;
473    }
474
475    /**
476     * Normalizes a JSON-decoded byte list to a binary string; non-arrays
477     * yield an empty string so the callers' validation fails loudly.
478     */
479    private function byteString(mixed $bytes): string
480    {
481        return is_array($bytes) ? $this->arrayToString($bytes) : '';
482    }
483
484    /**
485     * Renders a JSON-decoded credential ID byte list as a comparable string.
486     */
487    private function idList(mixed $id): string
488    {
489        if (!is_array($id)) {
490            return '';
491        }
492
493        return implode(',', array_map(static fn(mixed $byte): string => is_scalar($byte) ? (string) $byte : '', $id));
494    }
495
496    /**
497     * Convert an array of uint8's to a binary string
498     *
499     * @param array<array-key, mixed> $array
500     */
501    private function arrayToString(array $array): string
502    {
503        return implode('', array_map(chr(...), $array));
504    }
505
506    /**
507     * Convert a binary string to an array of uint8's
508     */
509    private function stringToArray(string $string): array
510    {
511        return array_map(ord(...), str_split($string));
512    }
513}