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