1: <?php
2: /*
3: * SimpleID
4: *
5: * Copyright (C) Kelvin Mo 2024-2026
6: *
7: * This program is free software; you can redistribute it and/or
8: * modify it under the terms of the GNU General Public
9: * License as published by the Free Software Foundation; either
10: * version 2 of the License, or (at your option) any later version.
11: *
12: * This program is distributed in the hope that it will be useful,
13: * but WITHOUT ANY WARRANTY; without even the implied warranty of
14: * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15: * General Public License for more details.
16: *
17: * You should have received a copy of the GNU General Public
18: * License along with this program; if not, write to the Free
19: * Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
20: *
21: */
22:
23: namespace SimpleID\Auth;
24:
25: use SimpleID\ModuleManager;
26: use Psr\Log\LogLevel;
27: use SimpleID\Auth\AuthManager;
28: use SimpleID\Auth\LoginFormBuildEvent;
29: use SimpleID\Crypt\Random;
30: use SimpleID\Crypt\SecurityToken;
31: use SimpleID\Models\User;
32: use SimpleID\Store\StoreManager;
33: use SimpleID\Util\Events\UIBuildEvent;
34: use SimpleID\Util\Forms\FormSubmitEvent;
35: use SimpleID\Util\UI\Template;
36: use SimpleJWT\Crypt\AlgorithmFactory;
37: use SimpleJWT\Keys\KeyFactory;
38: use SimpleJWT\Keys\KeySet;
39: use SimpleJWT\Util\Util as SimpleJWTUtil;
40:
41: /**
42: * An authentication scheme module that uses Passkeys and security keys with WebAuthn.
43: *
44: * **Passkeys** are used to identify users (under
45: * {@link AuthManager::MODE_IDENTIFY_USERS}).
46: *
47: * **Security keys** may be used as a second factor for login verification (under
48: * {@link AuthManager::MODE_VERIFY}). This
49: * module restricts security keys to cross-platform attachments only.
50: */
51: class WebAuthnAuthSchemeModule extends AuthSchemeModule {
52: const USE_PASSKEY = 'passkey';
53: const USE_VERIFY = 'verify';
54:
55: /** @var array<int, string> */
56: static $cose_alg_map = [
57: -257 => 'RS256',
58: -7 => 'ES256'
59: ];
60:
61: static function init($f3) {
62: $f3->route('GET|POST /auth/webauthn', 'SimpleID\Auth\WebAuthnAuthSchemeModule->setup');
63: $f3->route('POST @webauthn_challenge: /auth/webauthn/challenge [ajax]', 'SimpleID\Auth\WebAuthnAuthSchemeModule->createChallenge');
64: $f3->route('GET /auth/webauthn/credentials [ajax]', 'SimpleID\Auth\WebAuthnAuthSchemeModule->listCredentials');
65: $f3->map('/auth/webauthn/credentials/@id', 'SimpleID\Auth\WebAuthnAuthSchemeModule');
66: }
67:
68: public function __construct() {
69: parent::__construct();
70:
71: $mgr = ModuleManager::instance();
72: $mgr->loadModule('SimpleID\Auth\RecoveryCodeAuthSchemeModule');
73: }
74:
75: /**
76: * API endpoint to create a random challenge that can be verified
77: * using this module.
78: *
79: * @return void
80: */
81: public function createChallenge() {
82: $this->checkHttps('error', true);
83:
84: $token = new SecurityToken();
85: if (!$this->f3->exists('HEADERS.X-Request-Token') || !$token->verify($this->f3->get('HEADERS.X-Request-Token'), 'webauthn')) {
86: $this->f3->status(401);
87: print json_encode([
88: 'error' => 'unauthorized',
89: 'error_description' => $this->f3->get('intl.common.unauthorized')
90: ]);
91: return;
92: }
93:
94: $rand = new Random();
95: $challenge = SimpleJWTUtil::base64url_encode($rand->bytes(32));
96:
97: // Wrap the challenge in a SecurityToken to ensure it is only used once
98: $nonce = $token->generate($challenge, SecurityToken::OPTION_NONCE);
99:
100: header('Content-Type: application/json');
101:
102: print json_encode([
103: 'challenge' => $challenge,
104: 'nonce' => $nonce,
105: 'expires_in' => SIMPLEID_HUMAN_TOKEN_EXPIRES_IN,
106: ]);
107: }
108:
109: /**
110: * API endpoint to list the saved credentials for the current logged-in
111: * user.
112: *
113: * @return void
114: */
115: public function listCredentials() {
116: $this->checkHttps('error', true);
117:
118: header('Content-Type: application/json');
119:
120: $token = new SecurityToken();
121: if (!$this->f3->exists('HEADERS.X-Request-Token') || !$token->verify($this->f3->get('HEADERS.X-Request-Token'), 'webauthn')) {
122: $this->f3->status(401);
123: print json_encode([
124: 'error' => 'unauthorized',
125: 'error_description' => $this->f3->get('intl.common.unauthorized')
126: ]);
127: return;
128: }
129:
130: $auth = AuthManager::instance();
131: $user = $auth->getUser();
132: $results = $this->getSavedCredentials($user, true);
133:
134: print json_encode($results);
135: }
136:
137: /**
138: * API endpoint to delete a stored credential
139: *
140: * @param \Base $f3
141: * @param array<string, mixed> $params
142: * @return void
143: */
144: public function delete($f3, $params) {
145: $this->checkHttps('error', true);
146: parse_str($this->f3->get('BODY'), $delete);
147:
148: header('Content-Type: application/json');
149:
150: $token = new SecurityToken();
151: if (!$this->f3->exists('HEADERS.X-Request-Token') || !$token->verify($this->f3->get('HEADERS.X-Request-Token'), 'webauthn')) {
152: $this->f3->status(401);
153: print json_encode([
154: 'error' => 'unauthorized',
155: 'error_description' => $this->f3->get('intl.common.unauthorized'),
156: ]);
157: return;
158: }
159:
160: $auth = AuthManager::instance();
161: $user = $auth->getUser();
162:
163: // $params['id'] is escaped using brackets
164: if (!$user->exists('webauthn.credentials.[' . $params['id'] . ']')) {
165: $this->f3->status(404);
166: print json_encode([
167: 'error' => 'not_found',
168: 'error_description' => $this->f3->get('intl.common.not_found')
169: ]);
170: return;
171: }
172:
173: $user->unset('webauthn.credentials.[' . $params['id'] . ']');
174:
175: $event = new CredentialEvent($user, CredentialEvent::CREDENTIAL_DELETED_EVENT, self::class, $params['id']);
176: \Events::instance()->dispatch($event);
177:
178:
179: $store = StoreManager::instance();
180: $store->saveUser($user);
181:
182: print json_encode([
183: 'result' => 'success',
184: 'result_description' => $this->f3->get('intl.core.auth_webauthn.credential_delete_success')
185: ]);
186: }
187:
188: /**
189: * Displays the page to add a WebAuthn credential.
190: *
191: * @return void
192: */
193: public function setup() {
194: $auth = AuthManager::instance();
195: $store = StoreManager::instance();
196: /** @var User $user */
197: $user = $auth->getUser();
198:
199: $tpl = Template::instance();
200: $token = new SecurityToken();
201:
202: // Require HTTPS, redirect if necessary
203: $this->checkHttps('redirect', true);
204:
205: if (!$auth->isLoggedIn()) {
206: $this->f3->reroute('/my/dashboard');
207: return;
208: }
209:
210: if ($this->f3->exists('POST.result')) {
211: if (($this->f3->exists('POST.tk') === false) || (!$token->verify($this->f3->get('POST.tk'), 'webauthn'))) {
212: $this->f3->set('message', $this->f3->get('intl.common.invalid_tk'));
213: $this->f3->mock('GET /my/dashboard');
214: return;
215: }
216:
217: $use = $this->f3->get('POST.use');
218: if (!in_array($use, [ self::USE_PASSKEY, self::USE_VERIFY ])) $use = self::USE_VERIFY;
219:
220: $credential = $this->processNewCredential($this->f3->get('POST.challenge'), $this->f3->get('POST.nonce'), $this->f3->get('POST.result'), $use, $this->f3->get('POST.name'));
221:
222: if ($credential == null) {
223: $this->f3->set('message', $this->f3->get('intl.core.auth_webauthn.credential_add_error'));
224: } else {
225: $user->set('webauthn.credentials.' . $credential['id'], $credential);
226: $store->saveUser($user);
227:
228: $event = new CredentialEvent($user, CredentialEvent::CREDENTIAL_ADDED_EVENT, self::class, $credential['id']);
229: \Events::instance()->dispatch($event);
230:
231: $this->f3->set('message', $this->f3->get('intl.core.auth_webauthn.credential_add_success'));
232: if ($use == self::USE_VERIFY) {
233: $this->f3->mock('POST /auth/recovery');
234: } else {
235: $this->f3->mock('GET /my/dashboard');
236: }
237: return;
238: }
239: }
240:
241: $rp_name = ($this->f3->exists('config.site_title')) ? $this->f3->get('config.site_title') : 'SimpleID';
242: $base_options = [
243: 'rp' => [
244: 'id' => $this->getRpId(),
245: 'name' => $rp_name
246: ],
247: 'user' => [
248: 'id' => SimpleJWTUtil::base64url_encode($user->getPairwiseIdentity('webauthn')),
249: 'name' => ($user->exists('userinfo.nickname')) ? $user->get('userinfo.nickname') : $user['uid'],
250: 'displayName' => $user->getDisplayName()
251: ],
252: 'pubKeyCredParams' => array_map(function ($n) { return [ 'alg' => $n, 'type' => 'public-key' ]; }, array_keys(self::$cose_alg_map)),
253: 'authenticatorSelection' => [
254: 'userVerification' => 'preferred'
255: ],
256: 'timeout' => SIMPLEID_HUMAN_TOKEN_EXPIRES_IN,
257: 'attestation' => 'none',
258: ];
259: if (isset($user['webauthn']['credentials']))
260: $base_options['excludeCredentials'] = $this->getSavedCredentials($user);
261:
262: $use_options = [
263: self::USE_PASSKEY => [
264: 'hints' => [ 'client-device', 'hybrid' ],
265: 'authenticatorSelection' => [
266: 'authenticatorAttachment' => 'platform',
267: 'residentKey' => 'required',
268: ],
269: ],
270: self::USE_VERIFY => [
271: 'hints' => [ 'security-key', 'client-device' ],
272: 'authenticatorSelection' => [
273: 'authenticatorAttachment' => 'cross-platform',
274: 'residentKey' => 'discouraged',
275: ],
276: ]
277: ];
278:
279: $tk = $token->generate('webauthn', SecurityToken::OPTION_BIND_SESSION);
280: $this->f3->set('tk', $tk);
281:
282: $this->f3->set('create_credential_app_options', [
283: 'tk' => $tk,
284: 'url' => $this->getCanonicalURL('@webauthn_challenge', '', 'https'),
285: 'baseCreateOptions' => $base_options,
286: 'useCreateOptions' => $use_options
287: ]);
288:
289: $this->f3->set('otp_recovery_url', 'https://simpleid.org/docs/2/common-problems/#otp');
290:
291: $this->f3->set('js_data.intl.challenge_error', $this->f3->get('intl.core.auth_webauthn.challenge_error'));
292: $this->f3->set('js_data.intl.browser_error', $this->f3->get('intl.core.auth_webauthn.browser_error'));
293:
294: $this->f3->set('page_class', 'is-dialog-page');
295: $this->f3->set('title', $this->f3->get('intl.core.auth_webauthn.webauthn_title'));
296: $this->f3->set('layout', 'auth_webauthn_setup.html');
297:
298: header('X-Frame-Options: DENY');
299: print $tpl->render('page.html');
300: }
301:
302: /**
303: * Returns the dashboard block.
304: *
305: * @param UIBuildEvent $event the event to collect
306: * the dashboard block
307: * @return void
308: */
309: public function onDashboardBlocks(UIBuildEvent $event) {
310: $tpl = Template::instance();
311:
312: $auth = AuthManager::instance();
313: $user = $auth->getUser();
314:
315: $base_path = $this->f3->get('base_path');
316:
317: $token = new SecurityToken();
318: $this->f3->set('webauthn_tk', $token->generate('webauthn', SecurityToken::OPTION_BIND_SESSION));
319:
320: $this->f3->set('js_data.intl.credential_confirm_delete', $this->f3->get('intl.core.auth_webauthn.credential_confirm_delete'));
321:
322: $event->addBlock('webauthn', $tpl->render('auth_webauthn_dashboard.html', false), 0, [
323: 'title' => $this->f3->get('intl.core.auth_webauthn.webauthn_title')
324: ]);
325: }
326:
327: /**
328: * @param LoginFormBuildEvent $event
329: * @return void
330: */
331: public function onLoginFormBuild(LoginFormBuildEvent $event) {
332: $form_state = $event->getFormState();
333:
334: if (($form_state['mode'] == AuthManager::MODE_IDENTIFY_USER) || ($form_state['mode'] == AuthManager::MODE_VERIFY)) {
335: $additional = [];
336: $allow_credentials = [];
337: $tpl = Template::instance();
338:
339: $token = new SecurityToken();
340: $tk = $token->generate('webauthn', SecurityToken::OPTION_BIND_SESSION);
341:
342: if ($form_state['mode'] == AuthManager::MODE_IDENTIFY_USER) {
343: $additional = [ 'region' => LoginFormBuildEvent::SECONDARY_REGION, 'title' => $this->f3->get('intl.core.auth_webauthn.login_block_title') ];
344:
345: $this->f3->set('webauthn_use', self::USE_PASSKEY);
346: } elseif ($form_state['mode'] == AuthManager::MODE_VERIFY) {
347: $auth = AuthManager::instance();
348: $store = StoreManager::instance();
349:
350: /** @var \SimpleID\Models\User $test_user */
351: $test_user = $store->loadUser($form_state['uid']);
352: if (!isset($test_user['webauthn'])) return;
353: /*if ($test_user['otp']['type'] == 'recovery') return;*/
354:
355: $uaid = $auth->assignUAID();
356: if ($test_user->exists('webauthn.remember') && in_array($uaid, $test_user->get('webauthn.remember'))) return;
357:
358: $allow_credentials = $this->getSavedCredentials($test_user);
359:
360: $this->f3->set('otp_recovery_url', 'https://simpleid.org/docs/2/common_problems/#otp');
361:
362: $additional = [ 'showSubmitButton' => false, 'title' => $this->f3->get('intl.core.auth_webauthn.verify_block_title') ];
363:
364: $this->f3->set('webauthn_use', self::USE_VERIFY);
365: }
366:
367: $options = [
368: 'mediation' => 'required',
369: 'publicKey' => [
370: 'userVerification' => 'required',
371: 'timeout' => 30000,
372: 'rpId' => $this->getRpId(),
373: 'allowCredentials' => $allow_credentials
374: ]
375: ];
376: $this->f3->set('request_credential_app_options', [
377: 'tk' => $tk,
378: 'url' => $this->getCanonicalURL('@webauthn_challenge', '', 'https'),
379: 'baseRequestOptions' => $options
380: ]);
381:
382: $this->f3->set('js_data.intl.challenge_error', $this->f3->get('intl.core.auth_webauthn.challenge_error'));
383: $this->f3->set('js_data.intl.browser_error', $this->f3->get('intl.core.auth_webauthn.browser_error'));
384:
385: $event->addBlock('auth_webauthn', $tpl->render('auth_webauthn_verify.html', false), 0, $additional);
386: }
387: }
388:
389: /**
390: * @param LoginFormSubmitEvent $event
391: * @return void
392: */
393: public function onLoginFormSubmit(LoginFormSubmitEvent $event) {
394: $store = StoreManager::instance();
395: $form_state = $event->getFormState();
396: $process_submit = false;
397:
398: if (($form_state['mode'] == AuthManager::MODE_IDENTIFY_USER) && $this->f3->exists('POST.op') && ($this->f3->exists('POST.op') == 'auth_webauthn')) {
399: $credential = json_decode($this->f3->get('POST.webauthn.result'), true);
400: /** @var User|null $test_user */
401: $test_user = $store->findUser('webauthn.credentials', $credential['id']);
402:
403: if ($test_user == null) {
404: $event->addMessage($this->f3->get('intl.core.auth_webauthn.credential_match_error'));
405: $event->setInvalid();
406: return;
407: }
408:
409: // Check that the credential's use is 'passkey'
410: $test_credentials = $test_user->get('webauthn.credentials');
411: $test_credential = $test_credentials[$credential['id']];
412: if ($test_credential['use'] != self::USE_PASSKEY) {
413: $event->addMessage($this->f3->get('intl.core.auth_webauthn.credential_use_error'));
414: $event->setInvalid();
415: return;
416: }
417:
418: $form_state['uid'] = $test_user['uid'];
419: $process_submit = true;
420: } elseif (($form_state['mode'] == AuthManager::MODE_VERIFY) && $this->isBlockActive('auth_webauthn')) {
421: $process_submit = true;
422: }
423:
424: if ($process_submit) {
425: /** @var \SimpleID\Models\User $test_user */
426: $test_user = $store->loadUser($form_state['uid']);
427: $test_credentials = $test_user->get('webauthn.credentials');
428:
429: $result = $this->verifyCredential($this->f3->get('POST.webauthn.challenge'), $this->f3->get('POST.webauthn.nonce'), $test_credentials, $this->f3->get('POST.webauthn.result'));
430:
431: if ($result === false) {
432: $event->addMessage($this->f3->get('intl.core.auth_webauthn.credential_verify_error'));
433: $event->setInvalid();
434: return;
435: }
436:
437: if ($this->f3->get('POST.webauthn.remember') == '1') $form_state['webauthn_remember'] = 1;
438:
439: // Update activity.sign_count, activity.last_time
440: $prefix = 'webauthn.credentials.[' . $result['credential_id'] . ']';
441: $test_user->set($prefix . '.activity.last_time', (new \DateTimeImmutable())->getTimestamp());
442: $test_user->set($prefix . '.activity.sign_count', $result['sign_count']);
443: $store->saveUser($test_user);
444:
445: $event->addAuthModuleName(self::class);
446: $event->setUser($test_user);
447: $event->setAuthLevel(AuthManager::AUTH_LEVEL_VERIFIED);
448: }
449: }
450:
451: /**
452: * @see SimpleID\Auth\LoginEvent
453: * @return void
454: */
455: public function onLoginEvent(LoginEvent $event) {
456: $user = $event->getUser();
457: $level = $event->getAuthLevel();
458: $form_state = $event->getFormState();
459:
460: $auth = AuthManager::instance();
461: $store = StoreManager::instance();
462:
463: if ($level >= AuthManager::AUTH_LEVEL_VERIFIED) {
464: $user->set('auth_login_last_active_block.' . AuthManager::MODE_VERIFY, 'auth_webauthn');
465:
466: if (isset($form_state['webauthn_remember']) && ($form_state['webauthn_remember'] == 1)) {
467: $uaid = $auth->assignUAID();
468: $remember = $user['webauthn']['remember'];
469: $remember[] = $uaid;
470: $user->set('webauthn.remember', array_unique($remember));
471: }
472:
473: $store->saveUser($user);
474: }
475: }
476:
477: /**
478: * @return void
479: */
480: public function onLogoutEvent(LogoutEvent $event) {
481: $tpl = Template::instance();
482: $tpl->addAttachment('js', [
483: 'inline' => "document.addEventListener('DOMContentLoaded', () => { window.credentials.preventSilentAccess(); });"
484: ]);
485: }
486:
487: /**
488: * Processes a new WebAuthn credential. The new credential is represented
489: * by PublicKeyCredential object (with AuthenticatorAttestationResponse).
490: *
491: * This method checks that the credential creation response is valid and, if so, provides
492: * a result array which can be saved in the user's profile.
493: *
494: * Note that this method does not perform detailed checks on the
495: * attestation data.
496: *
497: * @param string $challenge the expected challenge value
498: * @param string $nonce the expected nonce value provided by the {@link #createChallenge()}
499: * method
500: * @param string $new_credential_json the credential creation response
501: * as a JSON string (with Uint8Array and Buffer values encoded as base64url)
502: * @param ?string $display_name the name of the credential chosen
503: * by the user
504: * @return array<string, mixed>|null an array representing the credential
505: * to be used in the user's profile, or null if the credential creation response
506: * is not valid
507: */
508: protected function processNewCredential(string $challenge, string $nonce, string $new_credential_json, string $use, string $display_name = null): ?array {
509: // 1. Check that nonce = challenge
510: $token = new SecurityToken();
511: if (!$token->verify($nonce, $challenge)) {
512: return null;
513: }
514:
515: // 2. Decode WebAuthn credential creation response
516: $new_credential = json_decode($new_credential_json, true);
517:
518: // 3. Check client data
519: $client_data = json_decode(SimpleJWTUtil::base64url_decode($new_credential['response']['clientDataJSON']), true);
520:
521: if ($client_data['type'] != 'webauthn.create') {
522: $this->logger->log(LogLevel::ERROR, 'Invalid client type: expected webauthn.create, got ' . $client_data['type']);
523: return null;
524: }
525:
526: if ($client_data['origin'] != $this->getOrigin($this->f3->get('config.canonical_base_path'))) {
527: $this->logger->log(LogLevel::ERROR, 'Invalid client origin: ' . $client_data['origin']);
528: return null;
529: }
530:
531: if (!$this->secureCompare($client_data['challenge'], $challenge)) {
532: $this->logger->log(LogLevel::ERROR, 'Challenge value does not match: expected ' . $challenge . ', got ' . $client_data['challenge']);
533: return null;
534: }
535:
536: // 4. Check authenticator data
537: $authenticator = new WebAuthnAuthenticatorData(SimpleJWTUtil::base64url_decode($new_credential['response']['authenticatorData']));
538: $aaguid = $authenticator->getAAGUID();
539:
540: if ($aaguid != null) {
541: // These are the most common authenticators
542: // https://passkeydeveloper.github.io/passkey-authenticator-aaguids/explorer/
543: $authenticator_names = [
544: 'ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4' => 'Google Password Manager',
545: '08987058-cadc-4b81-b6e1-30de50dcbe96' => 'Windows Hello',
546: 'dd4ec289-e01d-41c9-bb89-70fa845d4bf2' => 'iCloud Keychain'
547: ];
548:
549: if (isset($authenticator_names[$aaguid])) {
550: $authenticator_name = $authenticator_names[$aaguid];
551: } else {
552: $authenticator_name = null;
553: }
554: } else {
555: $authenticator_name = null;
556: }
557:
558: // 5. Convert public key from base64url encoded DER to JWK
559: // (by converting it to PEM first)
560: $pem = wordwrap("-----BEGIN PUBLIC KEY-----\n" . strtr($new_credential['response']['publicKey'], '-_', '+/') . "\n-----END PUBLIC KEY-----\n", 64, "\n", true);
561: $key = KeyFactory::create($pem, 'pem');
562:
563: // 6. Display name
564: $time = new \DateTimeImmutable();
565: if ($display_name == null) {
566: if ($authenticator_name != null) {
567: $display_name = $authenticator_name . ' - ' . $time->format(\DateTimeImmutable::ISO8601);
568: } else {
569: $display_name = $time->format(\DateTimeImmutable::ISO8601);
570: }
571: }
572:
573: // 7. Return result
574: $result = [
575: 'id' => $new_credential['id'],
576: 'type' => $new_credential['type'],
577:
578: 'display_name' => $display_name,
579: 'use' => $use,
580: 'authenticator' => [
581: 'aaguid' => $aaguid,
582: 'user_verified' => $authenticator->isUserVerified(),
583: 'backup_eligible' => $authenticator->isBackupEligible()
584: ],
585: 'public_key' => [
586: 'jwk' => $key->getKeyData(),
587: 'alg' => self::$cose_alg_map[$new_credential['response']['publicKeyAlgorithm']],
588: 'transports' => $new_credential['response']['transports']
589: ],
590: 'activity' => [
591: 'first_time' => $time->getTimestamp(),
592: 'last_time' => $time->getTimestamp(),
593: 'backed_up' => $authenticator->isBackedUp(),
594: 'sign_count' => $authenticator->getSignCount()
595: ]
596: ];
597:
598: return $result;
599: }
600:
601: /**
602: * Verifies a WebAuthn credential supplied by the browser against credentials that are
603: * stored for a user. The supplied credential is represented
604: * by PublicKeyCredential object (with AuthenticatorAssertionResponse).
605: *
606: * This method checks that the credential response is valid and, if so, provides
607: * a result array which can be used to update the user's profile.
608: *
609: * Note that this method does not perform detailed checks on the
610: * assertion data.
611: *
612: * @param string $challenge the expected challenge value
613: * @param string $nonce the expected nonce value provided by the {@link #createChallenge()}
614: * method
615: * @param array<string, mixed> $stored_credentials an associative array of
616: * credentials stored in the user's profile
617: * @param string $credential_json the credential response
618: * as a JSON string (with Uint8Array and Buffer values encoded as base64url)
619: * @return array<string, mixed>|false an array representing the verification result,
620: * or false if the credential response is not valid
621: */
622: protected function verifyCredential(string $challenge, string $nonce, array $stored_credentials, string $credential_json) {
623: // 1. Check that nonce = challenge
624: $token = new SecurityToken();
625: if (!$token->verify($nonce, $challenge)) {
626: return false;
627: }
628:
629: // 2. Decode WebAuthn credential response
630: $credential = json_decode($credential_json, true);
631:
632: // 3. Check if the credential ID has been stored
633: if (!array_key_exists($credential['id'], $stored_credentials)) {
634: return false;
635: }
636:
637: $test_credential = $stored_credentials[$credential['id']];
638:
639: // 4. Verify signature
640: $client_data_json = SimpleJWTUtil::base64url_decode($credential['response']['clientDataJSON']);
641: $authenticator_data = SimpleJWTUtil::base64url_decode($credential['response']['authenticatorData']);
642: if (!$this->verifySignature($credential['response']['signature'], $authenticator_data, $client_data_json, $test_credential['public_key'])) {
643: return false;
644: }
645:
646: // 5. Check client data
647: $client_data = json_decode($client_data_json, true);
648:
649: if ($client_data['type'] != 'webauthn.get') {
650: $this->logger->log(LogLevel::ERROR, 'Invalid client type: expected webauthn.get, got ' . $client_data['type']);
651: return false;
652: }
653:
654: if ($client_data['origin'] != $this->getOrigin($this->f3->get('config.canonical_base_path'))) {
655: $this->logger->log(LogLevel::ERROR, 'Invalid client origin: ' . $client_data['origin']);
656: return false;
657: }
658:
659: if (!$this->secureCompare($client_data['challenge'], $challenge)) {
660: $this->logger->log(LogLevel::ERROR, 'Challenge value does not match: expected ' . $challenge . ', got ' . $client_data['challenge']);
661: return false;
662: }
663:
664: // 6. Check authenticator data
665: $authenticator = new WebAuthnAuthenticatorData($authenticator_data);
666:
667: $rpIdHash = SimpleJWTUtil::base64url_encode(hash('sha256', $this->getRpId(), true));
668: if (!$this->secureCompare($authenticator->getRpIdHash(), $rpIdHash)) {
669: $this->logger->log(LogLevel::ERROR, 'RP ID hash does not match: expected ' . $rpIdHash . ', got ' . $authenticator->getRpIdHash());
670: return false;
671: }
672:
673: if (!$authenticator->isUserPresent()) {
674: $this->logger->log(LogLevel::ERROR, 'User present flag not set in authenticatorData');
675: return false;
676: }
677:
678: // If the user was verified when the credential was added, then the user
679: // must be verified on each use
680: if ($test_credential['authenticator']['user_verified'] && !$authenticator->isUserVerified()) {
681: $this->logger->log(LogLevel::ERROR, 'User verified flag not set in authenticatorData when flag it was set on creation');
682: return false;
683: }
684:
685: $test_sign_count = $test_credential['activity']['sign_count'];
686: if (($test_sign_count > 0) && ($authenticator->getSignCount() <= $test_sign_count)) {
687: $this->logger->log(LogLevel::ERROR, 'Sign count too low: expected >' . $test_sign_count . ', got ' . $authenticator->getSignCount());
688: return false;
689: }
690:
691: // 7. Return result
692: return [
693: 'credential_id' => $credential['id'],
694: 'user_ppid' => $credential['response']['userHandle'],
695: 'user_verified' => $authenticator->isUserVerified(),
696: 'backed_up' => $authenticator->isBackedUp(),
697: 'sign_count' => $authenticator->getSignCount()
698: ];
699: }
700:
701: /**
702: * Verifies the WebAuthn signature.
703: *
704: * @param string $signature the base64url encoded signature to verify
705: * @param string $authenticator_data the authenticatorData provided by the browser
706: * as a binary string
707: * @param string $client_data_json the clientDataJSON provided by the browser
708: * as a JSON string
709: * @param array<string, mixed> $test_public_key the `public_key` value from the
710: * stored credentials
711: * @return bool true if the signature is valid
712: */
713: protected function verifySignature(string $signature, string $authenticator_data, string $client_data_json, array $test_public_key): bool {
714: $signing_input = $authenticator_data . hash('sha256', $client_data_json, true);
715:
716: $set = new KeySet();
717: $key = KeyFactory::create($test_public_key['jwk'], 'php');
718: $set->add($key);
719:
720: if ($key->getKeyType() == \SimpleJWT\Keys\ECKey::KTY) {
721: // Under the WebAuthn specification, $signature for ECDSA-based algorithms
722: // is encoded as a ASN.1 DER SEQUENCE. However, SimpleJWT expects
723: // this signature to be the raw integers (r, s) concatenated. Therefore
724: // we need to convert the signature into the required format
725: $binary = SimpleJWTUtil::base64url_decode($signature);
726:
727: $der = new \SimpleJWT\Util\ASN1\DER();
728: $seq = $der->decode($binary);
729: $r = $seq->getChildAt(0)->getValueAsUIntOctets();
730: $s = $seq->getChildAt(1)->getValueAsUIntOctets();
731:
732: // Now pad out r and s so that they are $key->getSize() bits long
733: $r = str_pad($r, $key->getSize() / 8, "\x00", STR_PAD_LEFT);
734: $s = str_pad($s, $key->getSize() / 8, "\x00", STR_PAD_LEFT);
735:
736: $signature = SimpleJWTUtil::base64url_encode($r . $s);
737: }
738:
739: /** @var \SimpleJWT\Crypt\Signature\SignatureAlgorithm $alg */
740: $alg = AlgorithmFactory::create($test_public_key['alg']);
741: return $alg->verify($signature, $signing_input, $set);
742: }
743:
744: /**
745: * Returns the RP ID for this installation.
746: *
747: * The RP ID is generated from the `canonical_base_path` variable.
748: *
749: * @return string the RP ID
750: */
751: protected function getRpId(): string {
752: /** @var string $rpId */
753: $rpId = parse_url($this->f3->get('config.canonical_base_path'), PHP_URL_HOST);
754: return $rpId;
755: }
756:
757: /**
758: * Retrieves saved credentials for a specified user.
759: *
760: * The `$include_details` parameter can be set to determine whether
761: * additional details (such as the display name and usage information) are
762: * returned. When using the Credentials browser API, `$include_details`
763: * should be set to false.
764: *
765: * @param User $user the user
766: * @param bool $include_details whether additional details are included in the result
767: * @return array<array<string, mixed>> the credentials
768: */
769: protected function getSavedCredentials(User $user, bool $include_details = false): array {
770: if (!$user->exists('webauthn.credentials') || (count($user->get('webauthn.credentials')) == 0))
771: return [];
772:
773: return array_map(function($credential) use ($include_details) {
774: $result = [
775: 'id' => $credential['id'],
776: 'type' => $credential['type']
777: ];
778: if ($include_details) {
779: $result['display_name'] = $credential['display_name'];
780: $result['use'] = $credential['use'];
781: $result['authenticator'] = $credential['authenticator'];
782: $result['activity'] = $credential['activity'];
783: }
784: return $result;
785: }, array_values($user->get('webauthn.credentials')));
786: }
787: }
788: ?>
789: