From f143d2a709c65d2ee46e76e07f3f3e215c67d4a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maur=C3=ADcio=20Meneghini=20Fauth?= Date: Mon, 19 Dec 2022 19:51:38 -0300 Subject: [PATCH] Add support for Web Authentication API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a two factor authentication plugin that supports FIDO2/WebAuthn security keys. Signed-off-by: MaurĂ­cio Meneghini Fauth --- composer.json | 6 +- js/src/webauthn.js | 133 +++++ .../JavaScriptMessagesController.php | 4 + libraries/classes/Plugins/TwoFactor/Key.php | 2 +- .../classes/Plugins/TwoFactor/WebAuthn.php | 246 +++++++++ libraries/classes/TwoFactor.php | 2 + libraries/classes/WebAuthn/CBORDecoder.php | 289 ++++++++++ libraries/classes/WebAuthn/CustomServer.php | 497 ++++++++++++++++++ libraries/classes/WebAuthn/DataStream.php | 68 +++ libraries/classes/WebAuthn/Server.php | 78 +++ .../classes/WebAuthn/WebAuthnException.php | 11 + .../classes/WebAuthn/WebauthnLibServer.php | 277 ++++++++++ psalm.xml | 1 + scripts/create-release.sh | 19 +- .../login/twofactor/webauthn_creation.twig | 3 + .../login/twofactor/webauthn_request.twig | 3 + templates/preferences/two_factor/main.twig | 11 + .../Plugins/TwoFactor/WebAuthnTest.php | 299 +++++++++++ test/classes/WebAuthn/CBORDecoderTest.php | 198 +++++++ .../WebAuthn/WebauthnLibServerTest.php | 83 +++ 20 files changed, 2225 insertions(+), 5 deletions(-) create mode 100644 js/src/webauthn.js create mode 100644 libraries/classes/Plugins/TwoFactor/WebAuthn.php create mode 100644 libraries/classes/WebAuthn/CBORDecoder.php create mode 100644 libraries/classes/WebAuthn/CustomServer.php create mode 100644 libraries/classes/WebAuthn/DataStream.php create mode 100644 libraries/classes/WebAuthn/Server.php create mode 100644 libraries/classes/WebAuthn/WebAuthnException.php create mode 100644 libraries/classes/WebAuthn/WebauthnLibServer.php create mode 100644 templates/login/twofactor/webauthn_creation.twig create mode 100644 templates/login/twofactor/webauthn_request.twig create mode 100644 test/classes/Plugins/TwoFactor/WebAuthnTest.php create mode 100644 test/classes/WebAuthn/CBORDecoderTest.php create mode 100644 test/classes/WebAuthn/WebauthnLibServerTest.php diff --git a/composer.json b/composer.json index 56e386f0d5d8..8438ae14c222 100644 --- a/composer.json +++ b/composer.json @@ -93,7 +93,8 @@ "tecnickcom/tcpdf": "For PDF support", "pragmarx/google2fa-qrcode": "^2.1 - For 2FA authentication", "bacon/bacon-qr-code": "^2.0 - For 2FA authentication", - "code-lts/u2f-php-server": "For FIDO U2F authentication" + "code-lts/u2f-php-server": "For FIDO U2F authentication", + "web-auth/webauthn-lib": "For better WebAuthn/FIDO2 authentication support" }, "require-dev": { "bacon/bacon-qr-code": "^2.0", @@ -111,7 +112,8 @@ "squizlabs/php_codesniffer": "~3.6.0", "symfony/console": "^5.2.3", "tecnickcom/tcpdf": "^6.4.4", - "vimeo/psalm": "^4.22" + "vimeo/psalm": "^4.22", + "web-auth/webauthn-lib": "^3.3" }, "extra": { "branch-alias": { diff --git a/js/src/webauthn.js b/js/src/webauthn.js new file mode 100644 index 000000000000..5f63e82227a5 --- /dev/null +++ b/js/src/webauthn.js @@ -0,0 +1,133 @@ +/** + * @param {ArrayBuffer} buffer + * + * @return {string} + */ +const arrayBufferToBase64 = buffer => { + const bytes = new Uint8Array(buffer); + let string = ''; + for (const byte of bytes) { + string += String.fromCharCode(byte); + } + + return window.btoa(string); +}; + +/** + * @param {string} string + * + * @return {Uint8Array} + */ +const base64ToUint8Array = string => { + return Uint8Array.from(window.atob(string), char => char.charCodeAt(0)); +}; + +/** + * @param {JQuery} $input + * + * @return {void} + */ +const handleCreation = $input => { + const $form = $input.parents('form'); + $form.find('input[type=submit]').hide(); + + const creationOptionsJson = $input.attr('data-creation-options'); + const creationOptions = JSON.parse(creationOptionsJson); + + const publicKey = creationOptions; + publicKey.challenge = base64ToUint8Array(creationOptions.challenge); + publicKey.user.id = base64ToUint8Array(creationOptions.user.id); + if (creationOptions.excludeCredentials) { + const excludedCredentials = []; + for (let value of creationOptions.excludeCredentials) { + let excludedCredential = value; + excludedCredential.id = base64ToUint8Array(value.id); + excludedCredentials.push(excludedCredential); + } + publicKey.excludeCredentials = excludedCredentials; + } + + // eslint-disable-next-line compat/compat + navigator.credentials.create({ publicKey: publicKey }) + .then((credential) => { + const credentialJson = JSON.stringify({ + id: credential.id, + rawId: arrayBufferToBase64(credential.rawId), + type: credential.type, + response: { + clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON), + attestationObject: arrayBufferToBase64(credential.response.attestationObject), + } + }); + $input.val(credentialJson); + $form.trigger('submit'); + }) + .catch((error) => Functions.ajaxShowMessage(error, false, 'error')); +}; + +/** + * @param {JQuery} $input + * + * @return {void} + */ +const handleRequest = $input => { + const $form = $input.parents('form'); + $form.find('input[type=submit]').hide(); + + const requestOptionsJson = $input.attr('data-request-options'); + const requestOptions = JSON.parse(requestOptionsJson); + + const publicKey = requestOptions; + publicKey.challenge = base64ToUint8Array(requestOptions.challenge); + if (requestOptions.allowCredentials) { + const allowedCredentials = []; + for (let value of requestOptions.allowCredentials) { + let allowedCredential = value; + allowedCredential.id = base64ToUint8Array(value.id); + allowedCredentials.push(allowedCredential); + } + publicKey.allowCredentials = allowedCredentials; + } + + // eslint-disable-next-line compat/compat + navigator.credentials.get({ publicKey: publicKey }) + .then((credential) => { + const credentialJson = JSON.stringify({ + id: credential.id, + rawId: arrayBufferToBase64(credential.rawId), + type: credential.type, + response: { + authenticatorData: arrayBufferToBase64(credential.response.authenticatorData), + clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON), + signature: arrayBufferToBase64(credential.response.signature), + userHandle: arrayBufferToBase64(credential.response.userHandle), + } + }); + $input.val(credentialJson); + $form.trigger('submit'); + }) + .catch((error) => Functions.ajaxShowMessage(error, false, 'error')); +}; + +AJAX.registerOnload('webauthn.js', function () { + if ( + ! navigator.credentials + || ! navigator.credentials.create + || ! navigator.credentials.get + || ! window.PublicKeyCredential + ) { + Functions.ajaxShowMessage(Messages.webAuthnNotSupported, false, 'error'); + + return; + } + + const $creationInput = $('#webauthn_creation_response'); + if ($creationInput.length > 0) { + handleCreation($creationInput); + } + + const $requestInput = $('#webauthn_request_response'); + if ($requestInput.length > 0) { + handleRequest($requestInput); + } +}); diff --git a/libraries/classes/Controllers/JavaScriptMessagesController.php b/libraries/classes/Controllers/JavaScriptMessagesController.php index 6501557b1c46..d56736bea8c9 100644 --- a/libraries/classes/Controllers/JavaScriptMessagesController.php +++ b/libraries/classes/Controllers/JavaScriptMessagesController.php @@ -692,6 +692,10 @@ private function setMessages(): void // l10n: error code 4 (from U2F API) on authanticate 'strU2FErrorAuthenticate' => _pgettext('U2F error', 'Invalid security key.'), + 'webAuthnNotSupported' => __( + 'WebAuthn is not available. Please use a supported browser in a secure context (HTTPS).' + ), + /* Designer */ 'strIndexedDBNotWorking' => __( 'You can not open, save or delete your page layout, as IndexedDB is not working' diff --git a/libraries/classes/Plugins/TwoFactor/Key.php b/libraries/classes/Plugins/TwoFactor/Key.php index 60fdbb2b0d60..ad4b3eebb74f 100644 --- a/libraries/classes/Plugins/TwoFactor/Key.php +++ b/libraries/classes/Plugins/TwoFactor/Key.php @@ -215,6 +215,6 @@ public static function getName() */ public static function getDescription() { - return __('Provides authentication using hardware security tokens supporting FIDO U2F, such as a Yubikey.'); + return __('Provides authentication using hardware security tokens supporting FIDO U2F, such as a YubiKey.'); } } diff --git a/libraries/classes/Plugins/TwoFactor/WebAuthn.php b/libraries/classes/Plugins/TwoFactor/WebAuthn.php new file mode 100644 index 000000000000..2cca7e0b1300 --- /dev/null +++ b/libraries/classes/Plugins/TwoFactor/WebAuthn.php @@ -0,0 +1,246 @@ +twofactor->config['settings']['userHandle']) + || ! is_string($this->twofactor->config['settings']['userHandle']) + ) { + $this->twofactor->config['settings']['userHandle'] = ''; + } + + if ( + ! isset($this->twofactor->config['settings']['credentials']) + || ! is_array($this->twofactor->config['settings']['credentials']) + ) { + $this->twofactor->config['settings']['credentials'] = []; + } + + $this->server = $this->createServer(); + } + + private function createServer(): Server + { + return class_exists(WebauthnServer::class) ? new WebauthnLibServer($this->twofactor) : new CustomServer(); + } + + public function setServer(Server $server): void + { + $this->server = $server; + } + + public function render(): string + { + $request = $GLOBALS['request']; + $userHandle = sodium_base642bin($this->getUserHandleFromSettings(), SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); + $requestOptions = $this->server->getCredentialRequestOptions( + $this->twofactor->user, + $userHandle, + $request->getUri()->getHost(), + $this->getAllowedCredentials() + ); + $requestOptionsEncoded = json_encode($requestOptions); + $_SESSION['WebAuthnCredentialRequestOptions'] = $requestOptionsEncoded; + $this->loadScripts(); + + return $this->template->render( + 'login/twofactor/webauthn_request', + ['request_options' => $requestOptionsEncoded] + ); + } + + public function check(): bool + { + $this->provided = false; + $request = $GLOBALS['request']; + $authenticatorResponse = $request->getParsedBodyParam('webauthn_request_response', ''); + if ($authenticatorResponse === '' || ! isset($_SESSION['WebAuthnCredentialRequestOptions'])) { + return false; + } + + $this->provided = true; + + /** @var mixed $credentialRequestOptions */ + $credentialRequestOptions = $_SESSION['WebAuthnCredentialRequestOptions']; + unset($_SESSION['WebAuthnCredentialRequestOptions']); + + try { + Assert::stringNotEmpty($authenticatorResponse); + Assert::stringNotEmpty($credentialRequestOptions); + $requestOptions = json_decode($credentialRequestOptions, true); + Assert::isArray($requestOptions); + Assert::keyExists($requestOptions, 'challenge'); + Assert::stringNotEmpty($requestOptions['challenge']); + $this->server->parseAndValidateAssertionResponse( + $authenticatorResponse, + $this->getAllowedCredentials(), + $requestOptions['challenge'], + $request + ); + } catch (Throwable $exception) { + $this->message = $exception->getMessage(); + + return false; + } + + return true; + } + + public function setup(): string + { + $request = $GLOBALS['request']; + $userId = sodium_bin2base64(random_bytes(32), SODIUM_BASE64_VARIANT_ORIGINAL); + $host = $request->getUri()->getHost(); + $creationOptions = $this->server->getCredentialCreationOptions($this->twofactor->user, $userId, $host); + $creationOptionsEncoded = json_encode($creationOptions); + $_SESSION['WebAuthnCredentialCreationOptions'] = $creationOptionsEncoded; + $this->loadScripts(); + + return $this->template->render( + 'login/twofactor/webauthn_creation', + ['creation_options' => $creationOptionsEncoded] + ); + } + + public function configure(): bool + { + $this->provided = false; + $request = $GLOBALS['request']; + $authenticatorResponse = $request->getParsedBodyParam('webauthn_creation_response', ''); + if ($authenticatorResponse === '' || ! isset($_SESSION['WebAuthnCredentialCreationOptions'])) { + return false; + } + + $this->provided = true; + + /** @var mixed $credentialCreationOptions */ + $credentialCreationOptions = $_SESSION['WebAuthnCredentialCreationOptions']; + unset($_SESSION['WebAuthnCredentialCreationOptions']); + + try { + Assert::stringNotEmpty($authenticatorResponse); + Assert::stringNotEmpty($credentialCreationOptions); + $credential = $this->server->parseAndValidateAttestationResponse( + $authenticatorResponse, + $credentialCreationOptions, + $request + ); + $this->saveCredential($credential); + } catch (Throwable $exception) { + $this->message = $exception->getMessage(); + + return false; + } + + return true; + } + + public static function getName(): string + { + return __('Hardware Security Key (WebAuthn/FIDO2)'); + } + + public static function getDescription(): string + { + return __( + 'Provides authentication using hardware security tokens supporting the WebAuthn/FIDO2 protocol,' + . ' such as a YubiKey.' + ); + } + + private function loadScripts(): void + { + $response = ResponseRenderer::getInstance(); + $scripts = $response->getHeader()->getScripts(); + $scripts->addFile('webauthn.js'); + } + + /** + * @psalm-return list + */ + private function getAllowedCredentials(): array + { + $allowedCredentials = []; + /** @psalm-var array> $credentials */ + $credentials = $this->twofactor->config['settings']['credentials']; + foreach ($credentials as $credential) { + if ( + ! is_string($credential['publicKeyCredentialId']) || $credential['publicKeyCredentialId'] === '' + || ! is_string($credential['type']) || $credential['type'] === '' + ) { + continue; + } + + $allowedCredentials[] = ['type' => $credential['type'], 'id' => $credential['publicKeyCredentialId']]; + } + + return $allowedCredentials; + } + + /** + * @psalm-param mixed[] $credential + * + * @throws SodiumException + */ + private function saveCredential(array $credential): void + { + Assert::keyExists($credential, 'publicKeyCredentialId'); + Assert::stringNotEmpty($credential['publicKeyCredentialId']); + Assert::keyExists($credential, 'userHandle'); + Assert::string($credential['userHandle']); + Assert::isArray($this->twofactor->config['settings']['credentials']); + $id = sodium_bin2base64( + sodium_base642bin($credential['publicKeyCredentialId'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING), + SODIUM_BASE64_VARIANT_ORIGINAL + ); + $this->twofactor->config['settings']['credentials'][$id] = $credential; + $this->twofactor->config['settings']['userHandle'] = $credential['userHandle']; + } + + private function getUserHandleFromSettings(): string + { + Assert::string($this->twofactor->config['settings']['userHandle']); + + return $this->twofactor->config['settings']['userHandle']; + } +} diff --git a/libraries/classes/TwoFactor.php b/libraries/classes/TwoFactor.php index 8f8a9ee0ea40..da77d083f257 100644 --- a/libraries/classes/TwoFactor.php +++ b/libraries/classes/TwoFactor.php @@ -141,6 +141,8 @@ class_exists(Google2FA::class) $result[] = 'application'; } + $result[] = 'WebAuthn'; + if (class_exists(U2FServer::class)) { $result[] = 'key'; } diff --git a/libraries/classes/WebAuthn/CBORDecoder.php b/libraries/classes/WebAuthn/CBORDecoder.php new file mode 100644 index 000000000000..407fd12fc963 --- /dev/null +++ b/libraries/classes/WebAuthn/CBORDecoder.php @@ -0,0 +1,289 @@ +wellFormed($stream); + } + + /** + * @see https://www.rfc-editor.org/rfc/rfc7049#appendix-C + * + * @return mixed + * + * @throws WebAuthnException + */ + private function wellFormed(DataStream $stream) + { + // process initial bytes + $initialByte = ord($stream->take(1)); + $majorType = $initialByte >> 5; + $value = $additionalInformation = $initialByte & 0x1f; + switch ($additionalInformation) { + case 24: + if ($majorType !== 7) { + $value = ord($stream->take(1)); + } + + break; + case 25: + if ($majorType !== 7) { + $unpackedValue = unpack('n', $stream->take(2)); + Assert::isArray($unpackedValue); + Assert::keyExists($unpackedValue, 1); + Assert::integer($unpackedValue[1]); + $value = $unpackedValue[1]; + } + + break; + case 26: + if ($majorType !== 7) { + $unpackedValue = unpack('N', $stream->take(4)); + Assert::isArray($unpackedValue); + Assert::keyExists($unpackedValue, 1); + Assert::integer($unpackedValue[1]); + $value = $unpackedValue[1]; + } + + break; + case 27: + if ($majorType !== 7) { + $unpackedValue = unpack('J', $stream->take(8)); + Assert::isArray($unpackedValue); + Assert::keyExists($unpackedValue, 1); + Assert::integer($unpackedValue[1]); + $value = $unpackedValue[1]; + } + + break; + case 28: + case 29: + case 30: + case 31: + throw new WebAuthnException(); + } + + // process content + switch ($majorType) { + case 0: + return $this->getUnsignedInteger($value); + + case 1: + return $this->getNegativeInteger($value); + + case 2: + return $this->getByteString($stream, $value); + + case 3: + return $this->getTextString($stream, $value); + + case 4: + return $this->getList($stream, $value); + + case 5: + return $this->getMap($stream, $value); + + case 6: + return $this->getTag($stream); + + case 7: + return $this->getFloatNumberOrSimpleValue($stream, $value, $additionalInformation); + + default: + throw new WebAuthnException(); + } + } + + private function getUnsignedInteger(int $value): int + { + return $value; + } + + private function getNegativeInteger(int $value): int + { + return -1 - $value; + } + + /** + * @throws WebAuthnException + */ + private function getByteString(DataStream $stream, int $value): string + { + return $stream->take($value); + } + + /** + * @throws WebAuthnException + */ + private function getTextString(DataStream $stream, int $value): string + { + return $stream->take($value); + } + + /** + * @psalm-return list + * + * @throws WebAuthnException + */ + private function getList(DataStream $stream, int $value): array + { + $list = []; + for ($i = 0; $i < $value; $i++) { + /** @psalm-suppress MixedAssignment */ + $list[] = $this->wellFormed($stream); + } + + return $list; + } + + /** + * @psalm-return array + * + * @throws WebAuthnException + */ + private function getMap(DataStream $stream, int $value): array + { + $map = []; + for ($i = 0; $i < $value; $i++) { + /** @psalm-suppress MixedAssignment, MixedArrayOffset */ + $map[$this->wellFormed($stream)] = $this->wellFormed($stream); + } + + return $map; + } + + /** + * @return mixed + * + * @throws WebAuthnException + */ + private function getTag(DataStream $stream) + { + // 1 embedded data item + return $this->wellFormed($stream); + } + + /** + * @return mixed + * + * @throws WebAuthnException + */ + private function getFloatNumberOrSimpleValue(DataStream $stream, int $value, int $additionalInformation) + { + switch ($additionalInformation) { + case 20: + return true; + + case 21: + return false; + + case 22: + return null; + + case 24: + // simple value + return ord($stream->take(1)); + + case 25: + return $this->getHalfFloat($stream); + + case 26: + return $this->getSingleFloat($stream); + + case 27: + return $this->getDoubleFloat($stream); + + case 31: + // "break" stop code for indefinite-length items + throw new WebAuthnException(); + + default: + return $value; + } + } + + /** + * IEEE 754 Half-Precision Float (16 bits follow) + * + * @see https://www.rfc-editor.org/rfc/rfc7049#appendix-D + * + * @throws WebAuthnException + */ + private function getHalfFloat(DataStream $stream): float + { + $value = unpack('n', $stream->take(2)); + Assert::isArray($value); + Assert::keyExists($value, 1); + Assert::integer($value[1]); + + $half = $value[1]; + $exp = ($half >> 10) & 0x1f; + $mant = $half & 0x3ff; + + if ($exp === 0) { + $val = $mant * (2 ** -24); + } elseif ($exp !== 31) { + $val = ($mant + 1024) * (2 ** ($exp - 25)); + } else { + $val = $mant === 0 ? INF : NAN; + } + + return $half & 0x8000 ? -$val : $val; + } + + /** + * IEEE 754 Single-Precision Float (32 bits follow) + * + * @throws WebAuthnException + */ + private function getSingleFloat(DataStream $stream): float + { + $value = unpack('G', $stream->take(4)); + Assert::isArray($value); + Assert::keyExists($value, 1); + Assert::float($value[1]); + + return $value[1]; + } + + /** + * IEEE 754 Double-Precision Float (64 bits follow) + * + * @throws WebAuthnException + */ + private function getDoubleFloat(DataStream $stream): float + { + $value = unpack('E', $stream->take(8)); + Assert::isArray($value); + Assert::keyExists($value, 1); + Assert::float($value[1]); + + return $value[1]; + } +} diff --git a/libraries/classes/WebAuthn/CustomServer.php b/libraries/classes/WebAuthn/CustomServer.php new file mode 100644 index 000000000000..75a78342de9f --- /dev/null +++ b/libraries/classes/WebAuthn/CustomServer.php @@ -0,0 +1,497 @@ + $this->generateChallenge(), + 'rp' => ['name' => 'phpMyAdmin (' . $relyingPartyId . ')', 'id' => $relyingPartyId], + 'user' => ['id' => $userId, 'name' => $userName, 'displayName' => $userName], + 'pubKeyCredParams' => $this->getCredentialParameters(), + 'authenticatorSelection' => [ + 'authenticatorAttachment' => 'cross-platform', + 'userVerification' => 'discouraged', + ], + 'timeout' => 60000, + 'attestation' => 'none', + ]; + } + + public function getCredentialRequestOptions( + string $userName, + string $userId, + string $relyingPartyId, + array $allowedCredentials + ): array { + foreach ($allowedCredentials as $key => $credential) { + $allowedCredentials[$key]['id'] = sodium_bin2base64( + sodium_base642bin($credential['id'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING), + SODIUM_BASE64_VARIANT_ORIGINAL + ); + } + + return [ + 'challenge' => $this->generateChallenge(), + 'allowCredentials' => $allowedCredentials, + 'timeout' => 60000, + 'attestation' => 'none', + 'userVerification' => 'discouraged', + ]; + } + + public function parseAndValidateAssertionResponse( + string $assertionResponseJson, + array $allowedCredentials, + string $challenge, + ServerRequestInterface $request + ): void { + $assertionCredential = $this->getAssertionCredential($assertionResponseJson); + + if ($allowedCredentials !== []) { + Assert::true($this->isCredentialIdAllowed($assertionCredential['rawId'], $allowedCredentials)); + } + + $authenticatorData = $this->getAuthenticatorData($assertionCredential['response']['authenticatorData']); + + $clientData = $this->getCollectedClientData($assertionCredential['response']['clientDataJSON']); + Assert::same($clientData['type'], 'webauthn.get'); + + try { + $knownChallenge = sodium_base642bin($challenge, SODIUM_BASE64_VARIANT_ORIGINAL); + $cDataChallenge = sodium_base642bin($clientData['challenge'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); + } catch (SodiumException $exception) { + throw new WebAuthnException((string) $exception); + } + + Assert::true(hash_equals($knownChallenge, $cDataChallenge)); + + $host = $request->getUri()->getHost(); + Assert::same($host, parse_url($clientData['origin'], PHP_URL_HOST)); + + $rpIdHash = hash('sha256', $host, true); + Assert::true(hash_equals($rpIdHash, $authenticatorData['rpIdHash'])); + + $isUserPresent = (ord($authenticatorData['flags']) & 1) !== 0; + Assert::true($isUserPresent); + } + + public function parseAndValidateAttestationResponse( + string $attestationResponse, + string $credentialCreationOptions, + ServerRequestInterface $request + ): array { + try { + $attestationCredential = $this->getAttestationCredential($attestationResponse); + } catch (Throwable $exception) { + throw new WebAuthnException('Invalid authenticator response.'); + } + + $creationOptions = json_decode($credentialCreationOptions, true); + Assert::isArray($creationOptions); + Assert::keyExists($creationOptions, 'challenge'); + Assert::string($creationOptions['challenge']); + Assert::keyExists($creationOptions, 'user'); + Assert::isArray($creationOptions['user']); + Assert::keyExists($creationOptions['user'], 'id'); + Assert::string($creationOptions['user']['id']); + + $clientData = $this->getCollectedClientData($attestationCredential['response']['clientDataJSON']); + + // Verify that the value of C.type is webauthn.create. + Assert::same($clientData['type'], 'webauthn.create'); + + // Verify that the value of C.challenge equals the base64url encoding of options.challenge. + $optionsChallenge = sodium_base642bin($creationOptions['challenge'], SODIUM_BASE64_VARIANT_ORIGINAL); + $clientDataChallenge = sodium_base642bin($clientData['challenge'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); + Assert::true(hash_equals($optionsChallenge, $clientDataChallenge)); + + // Verify that the value of C.origin matches the Relying Party's origin. + $host = $request->getUri()->getHost(); + Assert::same($host, parse_url($clientData['origin'], PHP_URL_HOST), 'Invalid origin.'); + + // Perform CBOR decoding on the attestationObject field. + $attestationObject = $this->getAttestationObject($attestationCredential['response']['attestationObject']); + + $authenticatorData = $this->getAuthenticatorData($attestationObject['authData']); + Assert::notNull($authenticatorData['attestedCredentialData']); + + // Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party. + $rpIdHash = hash('sha256', $host, true); + Assert::true(hash_equals($rpIdHash, $authenticatorData['rpIdHash']), 'Invalid rpIdHash.'); + + // Verify that the User Present bit of the flags in authData is set. + $isUserPresent = (ord($authenticatorData['flags']) & 1) !== 0; + Assert::true($isUserPresent); + + Assert::same($attestationObject['fmt'], 'none'); + Assert::same($attestationObject['attStmt'], []); + + $encodedCredentialId = sodium_bin2base64( + $authenticatorData['attestedCredentialData']['credentialId'], + SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING + ); + $encodedCredentialPublicKey = sodium_bin2base64( + $authenticatorData['attestedCredentialData']['credentialPublicKey'], + SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING + ); + $userHandle = sodium_bin2base64( + sodium_base642bin($creationOptions['user']['id'], SODIUM_BASE64_VARIANT_ORIGINAL), + SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING + ); + + return [ + 'publicKeyCredentialId' => $encodedCredentialId, + 'type' => 'public-key', + 'transports' => [], + 'attestationType' => $attestationObject['fmt'], + 'aaguid' => $authenticatorData['attestedCredentialData']['aaguid'], + 'credentialPublicKey' => $encodedCredentialPublicKey, + 'userHandle' => $userHandle, + 'counter' => $authenticatorData['signCount'], + ]; + } + + /** + * In order to prevent replay attacks, the challenges MUST contain enough entropy to make guessing them infeasible. + * Challenges SHOULD therefore be at least 16 bytes long. + * + * @see https://www.w3.org/TR/webauthn-3/#sctn-cryptographic-challenges + * + * @psalm-return non-empty-string + * + * @throws WebAuthnException + */ + private function generateChallenge(): string + { + try { + return sodium_bin2base64(random_bytes(32), SODIUM_BASE64_VARIANT_ORIGINAL); + } catch (Throwable $throwable) { // @codeCoverageIgnore + throw new WebAuthnException('Error when generating challenge.'); // @codeCoverageIgnore + } + } + + /** + * @see https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data + * + * @psalm-return array{ + * rpIdHash: string, + * flags: string, + * signCount: int, + * attestedCredentialData: array{ + * aaguid: string, + * credentialId: string, + * credentialPublicKey: string, + * credentialPublicKeyDecoded: mixed[] + * }|null, + * extensions: string|null + * } + * + * @throws WebAuthnException + */ + private function getAuthenticatorData(string $authData): array + { + $authDataLength = mb_strlen($authData, '8bit'); + Assert::true($authDataLength >= 37); + $authDataStream = new DataStream($authData); + + $rpIdHash = $authDataStream->take(32); + $flags = $authDataStream->take(1); + + // 32-bit unsigned big-endian integer + $unpackedSignCount = unpack('N', $authDataStream->take(4)); + Assert::isArray($unpackedSignCount); + Assert::keyExists($unpackedSignCount, 1); + Assert::integer($unpackedSignCount[1]); + $signCount = $unpackedSignCount[1]; + + $attestedCredentialData = null; + // Bit 6: Attested credential data included (AT). + if ((ord($flags) & 64) !== 0) { + /** Authenticator Attestation GUID */ + $aaguid = $authDataStream->take(16); + + // 16-bit unsigned big-endian integer + $unpackedCredentialIdLength = unpack('n', $authDataStream->take(2)); + Assert::isArray($unpackedCredentialIdLength); + Assert::keyExists($unpackedCredentialIdLength, 1); + Assert::integer($unpackedCredentialIdLength[1]); + $credentialIdLength = $unpackedCredentialIdLength[1]; + + $credentialId = $authDataStream->take($credentialIdLength); + + $credentialPublicKeyDecoded = (new CBORDecoder())->decode($authDataStream); + Assert::isArray($credentialPublicKeyDecoded); + $credentialPublicKey = mb_substr( + $authData, + 37 + 18 + $credentialIdLength, + $authDataStream->getPosition(), + '8bit' + ); + + $attestedCredentialData = [ + 'aaguid' => $aaguid, + 'credentialId' => $credentialId, + 'credentialPublicKey' => $credentialPublicKey, + 'credentialPublicKeyDecoded' => $credentialPublicKeyDecoded, + ]; + } + + return [ + 'rpIdHash' => $rpIdHash, + 'flags' => $flags, + 'signCount' => $signCount, + 'attestedCredentialData' => $attestedCredentialData, + 'extensions' => null, + ]; + } + + /** + * @psalm-param non-empty-string $id + * @psalm-param list $allowedCredentials + * + * @throws WebAuthnException + */ + private function isCredentialIdAllowed(string $id, array $allowedCredentials): bool + { + foreach ($allowedCredentials as $credential) { + try { + $credentialId = sodium_base642bin($credential['id'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); + } catch (SodiumException $exception) { + throw new WebAuthnException(); + } + + if (hash_equals($credentialId, $id)) { + return true; + } + } + + return false; + } + + /** + * @see https://www.iana.org/assignments/cose/cose.xhtml#algorithms + * + * @psalm-return list + */ + private function getCredentialParameters(): array + { + return [ + ['alg' => -257, 'type' => 'public-key'], // RS256 + ['alg' => -259, 'type' => 'public-key'], // RS512 + ['alg' => -37, 'type' => 'public-key'], // PS256 + ['alg' => -39, 'type' => 'public-key'], // PS512 + ['alg' => -7, 'type' => 'public-key'], // ES256 + ['alg' => -36, 'type' => 'public-key'], // ES512 + ['alg' => -8, 'type' => 'public-key'], // EdDSA + ]; + } + + /** + * @psalm-param non-empty-string $assertionResponseJson + * + * @psalm-return array{ + * id: non-empty-string, + * type: 'public-key', + * rawId: non-empty-string, + * response: array{ + * clientDataJSON: non-empty-string, + * authenticatorData: non-empty-string, + * signature: non-empty-string, + * } + * } + * + * @throws SodiumException + * @throws InvalidArgumentException + */ + private function getAssertionCredential(string $assertionResponseJson): array + { + $credential = json_decode($assertionResponseJson, true); + Assert::isArray($credential); + Assert::keyExists($credential, 'id'); + Assert::stringNotEmpty($credential['id']); + Assert::keyExists($credential, 'type'); + Assert::same($credential['type'], 'public-key'); + Assert::keyExists($credential, 'rawId'); + Assert::stringNotEmpty($credential['rawId']); + Assert::keyExists($credential, 'response'); + Assert::isArray($credential['response']); + Assert::keyExists($credential['response'], 'clientDataJSON'); + Assert::stringNotEmpty($credential['response']['clientDataJSON']); + Assert::keyExists($credential['response'], 'authenticatorData'); + Assert::stringNotEmpty($credential['response']['authenticatorData']); + Assert::keyExists($credential['response'], 'signature'); + Assert::stringNotEmpty($credential['response']['signature']); + + $id = sodium_base642bin($credential['id'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); + $rawId = sodium_base642bin($credential['rawId'], SODIUM_BASE64_VARIANT_ORIGINAL); + Assert::stringNotEmpty($id); + Assert::stringNotEmpty($rawId); + Assert::true(hash_equals($rawId, $id)); + + $clientDataJSON = sodium_base642bin($credential['response']['clientDataJSON'], SODIUM_BASE64_VARIANT_ORIGINAL); + Assert::stringNotEmpty($clientDataJSON); + $authenticatorData = sodium_base642bin( + $credential['response']['authenticatorData'], + SODIUM_BASE64_VARIANT_ORIGINAL + ); + Assert::stringNotEmpty($authenticatorData); + $signature = sodium_base642bin($credential['response']['signature'], SODIUM_BASE64_VARIANT_ORIGINAL); + Assert::stringNotEmpty($signature); + + return [ + 'id' => $credential['id'], + 'type' => 'public-key', + 'rawId' => $rawId, + 'response' => [ + 'clientDataJSON' => $clientDataJSON, + 'authenticatorData' => $authenticatorData, + 'signature' => $signature, + ], + ]; + } + + /** + * @see https://www.w3.org/TR/webauthn-3/#iface-authenticatorattestationresponse + * + * @psalm-param non-empty-string $attestationResponse + * + * @psalm-return array{ + * id: non-empty-string, + * rawId: non-empty-string, + * type: 'public-key', + * response: array{clientDataJSON: non-empty-string, attestationObject: non-empty-string} + * } + * + * @throws SodiumException + * @throws InvalidArgumentException + */ + private function getAttestationCredential(string $attestationResponse): array + { + $credential = json_decode($attestationResponse, true); + Assert::isArray($credential); + Assert::keyExists($credential, 'id'); + Assert::stringNotEmpty($credential['id']); + Assert::keyExists($credential, 'rawId'); + Assert::stringNotEmpty($credential['rawId']); + Assert::keyExists($credential, 'type'); + Assert::string($credential['type']); + Assert::same($credential['type'], 'public-key'); + Assert::keyExists($credential, 'response'); + Assert::isArray($credential['response']); + Assert::keyExists($credential['response'], 'clientDataJSON'); + Assert::stringNotEmpty($credential['response']['clientDataJSON']); + Assert::keyExists($credential['response'], 'attestationObject'); + Assert::stringNotEmpty($credential['response']['attestationObject']); + + $id = sodium_base642bin($credential['id'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); + $rawId = sodium_base642bin($credential['rawId'], SODIUM_BASE64_VARIANT_ORIGINAL); + Assert::stringNotEmpty($id); + Assert::stringNotEmpty($rawId); + Assert::true(hash_equals($rawId, $id)); + + $clientDataJSON = sodium_base642bin($credential['response']['clientDataJSON'], SODIUM_BASE64_VARIANT_ORIGINAL); + Assert::stringNotEmpty($clientDataJSON); + $attestationObject = sodium_base642bin( + $credential['response']['attestationObject'], + SODIUM_BASE64_VARIANT_ORIGINAL + ); + Assert::stringNotEmpty($attestationObject); + + return [ + 'id' => $credential['id'], + 'rawId' => $rawId, + 'type' => 'public-key', + 'response' => [ + 'clientDataJSON' => $clientDataJSON, + 'attestationObject' => $attestationObject, + ], + ]; + } + + /** + * @see https://www.w3.org/TR/webauthn-3/#dictionary-client-data + * + * @psalm-param non-empty-string $clientDataJSON + * + * @return array{ + * type: 'webauthn.create'|'webauthn.get', + * challenge: non-empty-string, + * origin: non-empty-string + * } + */ + private function getCollectedClientData(string $clientDataJSON): array + { + $clientData = json_decode($clientDataJSON, true); + + Assert::isArray($clientData); + Assert::keyExists($clientData, 'type'); + Assert::stringNotEmpty($clientData['type']); + Assert::inArray($clientData['type'], ['webauthn.create', 'webauthn.get']); + Assert::keyExists($clientData, 'challenge'); + Assert::stringNotEmpty($clientData['challenge']); + Assert::keyExists($clientData, 'origin'); + Assert::stringNotEmpty($clientData['origin']); + + return [ + 'type' => $clientData['type'], + 'challenge' => $clientData['challenge'], + 'origin' => $clientData['origin'], + ]; + } + + /** + * @psalm-param non-empty-string $attestationObjectEncoded + * + * @psalm-return array{fmt: string, attStmt: mixed[], authData: string} + * + * @throws WebAuthnException + */ + private function getAttestationObject(string $attestationObjectEncoded): array + { + $decoded = (new CBORDecoder())->decode(new DataStream($attestationObjectEncoded)); + + Assert::isArray($decoded); + Assert::keyExists($decoded, 'fmt'); + Assert::string($decoded['fmt']); + Assert::keyExists($decoded, 'attStmt'); + Assert::isArray($decoded['attStmt']); + Assert::keyExists($decoded, 'authData'); + Assert::string($decoded['authData']); + + return $decoded; + } +} diff --git a/libraries/classes/WebAuthn/DataStream.php b/libraries/classes/WebAuthn/DataStream.php new file mode 100644 index 000000000000..a445b0deb8d5 --- /dev/null +++ b/libraries/classes/WebAuthn/DataStream.php @@ -0,0 +1,68 @@ +stream = $resource; + } + + /** + * @throws WebAuthnException + */ + public function take(int $length): string + { + if ($length < 0) { + throw new WebAuthnException(); + } + + if ($length === 0) { + return ''; + } + + $string = fread($this->stream, $length); + if ($string === false) { + throw new WebAuthnException(); + } + + return $string; + } + + /** + * @throws WebAuthnException + */ + public function getPosition(): int + { + $position = ftell($this->stream); + if ($position === false) { + throw new WebAuthnException(); + } + + return $position; + } +} diff --git a/libraries/classes/WebAuthn/Server.php b/libraries/classes/WebAuthn/Server.php new file mode 100644 index 000000000000..7ce6b11b9b20 --- /dev/null +++ b/libraries/classes/WebAuthn/Server.php @@ -0,0 +1,78 @@ +, + * authenticatorSelection: array, + * timeout: positive-int, + * attestation: non-empty-string + * } + * + * @throws WebAuthnException + */ + public function getCredentialCreationOptions(string $userName, string $userId, string $relyingPartyId): array; + + /** + * @psalm-param list $allowedCredentials + * + * @return array>|int|string> + * + * @throws WebAuthnException + */ + public function getCredentialRequestOptions( + string $userName, + string $userId, + string $relyingPartyId, + array $allowedCredentials + ): array; + + /** + * @see https://www.w3.org/TR/webauthn-3/#sctn-verifying-assertion + * + * @psalm-param non-empty-string $assertionResponseJson + * @psalm-param list $allowedCredentials + * @psalm-param non-empty-string $challenge + * + * @throws WebAuthnException + */ + public function parseAndValidateAssertionResponse( + string $assertionResponseJson, + array $allowedCredentials, + string $challenge, + ServerRequestInterface $request + ): void; + + /** + * @see https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential + * + * @psalm-param non-empty-string $attestationResponse + * @psalm-param non-empty-string $credentialCreationOptions + * + * @return mixed[] + * + * @throws WebAuthnException + */ + public function parseAndValidateAttestationResponse( + string $attestationResponse, + string $credentialCreationOptions, + ServerRequestInterface $request + ): array; +} diff --git a/libraries/classes/WebAuthn/WebAuthnException.php b/libraries/classes/WebAuthn/WebAuthnException.php new file mode 100644 index 000000000000..1254eef51f4f --- /dev/null +++ b/libraries/classes/WebAuthn/WebAuthnException.php @@ -0,0 +1,11 @@ +twofactor = $twofactor; + } + + public function getCredentialCreationOptions(string $userName, string $userId, string $relyingPartyId): array + { + $userEntity = new PublicKeyCredentialUserEntity($userName, $userId, $userName); + $relyingPartyEntity = new PublicKeyCredentialRpEntity('phpMyAdmin (' . $relyingPartyId . ')', $relyingPartyId); + $publicKeyCredentialSourceRepository = $this->createPublicKeyCredentialSourceRepository(); + $server = new WebauthnServer($relyingPartyEntity, $publicKeyCredentialSourceRepository); + $publicKeyCredentialCreationOptions = $server->generatePublicKeyCredentialCreationOptions( + $userEntity, + PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE, + [], + AuthenticatorSelectionCriteria::createFromArray([ + 'authenticatorAttachment' => 'cross-platform', + 'userVerification' => 'discouraged', + ]) + ); + /** @psalm-var array{ + * challenge: non-empty-string, + * rp: array{name: non-empty-string, id: non-empty-string}, + * user: array{id: non-empty-string, name: non-empty-string, displayName: non-empty-string}, + * pubKeyCredParams: list, + * authenticatorSelection: array, + * timeout: positive-int, + * attestation: non-empty-string + * } $creationOptions */ + $creationOptions = $publicKeyCredentialCreationOptions->jsonSerialize(); + $creationOptions['challenge'] = sodium_bin2base64( + sodium_base642bin($creationOptions['challenge'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING), + SODIUM_BASE64_VARIANT_ORIGINAL + ); + Assert::stringNotEmpty($creationOptions['challenge']); + + return $creationOptions; + } + + public function getCredentialRequestOptions( + string $userName, + string $userId, + string $relyingPartyId, + array $allowedCredentials + ): array { + $userEntity = new PublicKeyCredentialUserEntity($userName, $userId, $userName); + $relyingPartyEntity = new PublicKeyCredentialRpEntity('phpMyAdmin (' . $relyingPartyId . ')', $relyingPartyId); + $publicKeyCredentialSourceRepository = $this->createPublicKeyCredentialSourceRepository(); + $server = new WebauthnServer($relyingPartyEntity, $publicKeyCredentialSourceRepository); + $credentialSources = $publicKeyCredentialSourceRepository->findAllForUserEntity($userEntity); + $allowedCredentials = array_map( + static function (PublicKeyCredentialSource $credential): PublicKeyCredentialDescriptor { + return $credential->getPublicKeyCredentialDescriptor(); + }, + $credentialSources + ); + $publicKeyCredentialRequestOptions = $server->generatePublicKeyCredentialRequestOptions( + 'discouraged', + $allowedCredentials + ); + /** + * @psalm-var array{ + * challenge: string, + * allowCredentials?: list + * } $requestOptions + */ + $requestOptions = $publicKeyCredentialRequestOptions->jsonSerialize(); + $requestOptions['challenge'] = sodium_bin2base64( + sodium_base642bin($requestOptions['challenge'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING), + SODIUM_BASE64_VARIANT_ORIGINAL + ); + if (isset($requestOptions['allowCredentials'])) { + foreach ($requestOptions['allowCredentials'] as $key => $credential) { + $requestOptions['allowCredentials'][$key]['id'] = sodium_bin2base64( + sodium_base642bin($credential['id'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING), + SODIUM_BASE64_VARIANT_ORIGINAL + ); + } + } + + return $requestOptions; + } + + public function parseAndValidateAssertionResponse( + string $assertionResponseJson, + array $allowedCredentials, + string $challenge, + ServerRequestInterface $request + ): void { + Assert::string($this->twofactor->config['settings']['userHandle']); + $userHandle = sodium_base642bin( + $this->twofactor->config['settings']['userHandle'], + SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING + ); + $userEntity = new PublicKeyCredentialUserEntity( + $this->twofactor->user, + $userHandle, + $this->twofactor->user + ); + $host = $request->getUri()->getHost(); + $relyingPartyEntity = new PublicKeyCredentialRpEntity('phpMyAdmin (' . $host . ')', $host); + $publicKeyCredentialSourceRepository = $this->createPublicKeyCredentialSourceRepository(); + $server = new WebauthnServer($relyingPartyEntity, $publicKeyCredentialSourceRepository); + $requestOptions = PublicKeyCredentialRequestOptions::createFromArray([ + 'challenge' => $challenge, + 'allowCredentials' => $allowedCredentials, + 'rpId' => $host, + 'timeout' => 60000, + ]); + Assert::isInstanceOf($requestOptions, PublicKeyCredentialRequestOptions::class); + $server->loadAndCheckAssertionResponse( + $assertionResponseJson, + $requestOptions, + $userEntity, + $request + ); + } + + public function parseAndValidateAttestationResponse( + string $attestationResponse, + string $credentialCreationOptions, + ServerRequestInterface $request + ): array { + $creationOptions = json_decode($credentialCreationOptions, true); + Assert::isArray($creationOptions); + Assert::keyExists($creationOptions, 'challenge'); + Assert::keyExists($creationOptions, 'user'); + Assert::isArray($creationOptions['user']); + Assert::keyExists($creationOptions['user'], 'id'); + $host = $request->getUri()->getHost(); + $relyingPartyEntity = new PublicKeyCredentialRpEntity('phpMyAdmin (' . $host . ')', $host); + $publicKeyCredentialSourceRepository = $this->createPublicKeyCredentialSourceRepository(); + $server = new WebauthnServer($relyingPartyEntity, $publicKeyCredentialSourceRepository); + $creationOptionsArray = [ + 'rp' => ['name' => 'phpMyAdmin (' . $host . ')', 'id' => $host], + 'pubKeyCredParams' => [ + ['alg' => -257, 'type' => 'public-key'], // RS256 + ['alg' => -259, 'type' => 'public-key'], // RS512 + ['alg' => -37, 'type' => 'public-key'], // PS256 + ['alg' => -39, 'type' => 'public-key'], // PS512 + ['alg' => -7, 'type' => 'public-key'], // ES256 + ['alg' => -36, 'type' => 'public-key'], // ES512 + ['alg' => -8, 'type' => 'public-key'], // EdDSA + ], + 'challenge' => $creationOptions['challenge'], + 'attestation' => 'none', + 'user' => [ + 'name' => $this->twofactor->user, + 'id' => $creationOptions['user']['id'], + 'displayName' => $this->twofactor->user, + ], + 'authenticatorSelection' => [ + 'authenticatorAttachment' => 'cross-platform', + 'userVerification' => 'discouraged', + ], + 'timeout' => 60000, + ]; + $credentialCreationOptions = PublicKeyCredentialCreationOptions::createFromArray($creationOptionsArray); + Assert::isInstanceOf($credentialCreationOptions, PublicKeyCredentialCreationOptions::class); + $publicKeyCredentialSource = $server->loadAndCheckAttestationResponse( + $attestationResponse, + $credentialCreationOptions, + $request + ); + + return $publicKeyCredentialSource->jsonSerialize(); + } + + private function createPublicKeyCredentialSourceRepository(): PublicKeyCredentialSourceRepository + { + return new class ($this->twofactor) implements PublicKeyCredentialSourceRepository { + /** @var TwoFactor */ + private $twoFactor; + + public function __construct(TwoFactor $twoFactor) + { + $this->twoFactor = $twoFactor; + } + + public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource + { + $data = $this->read(); + if (isset($data[base64_encode($publicKeyCredentialId)])) { + return PublicKeyCredentialSource::createFromArray($data[base64_encode($publicKeyCredentialId)]); + } + + return null; + } + + /** + * @return PublicKeyCredentialSource[] + */ + public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array + { + $sources = []; + foreach ($this->read() as $data) { + $source = PublicKeyCredentialSource::createFromArray($data); + if ($source->getUserHandle() !== $publicKeyCredentialUserEntity->getId()) { + continue; + } + + $sources[] = $source; + } + + return $sources; + } + + public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void + { + $data = $this->read(); + $id = $publicKeyCredentialSource->getPublicKeyCredentialId(); + $data[base64_encode($id)] = $publicKeyCredentialSource->jsonSerialize(); + $this->write($data); + } + + /** + * @return mixed[][] + */ + private function read(): array + { + /** @psalm-var list $credentials */ + $credentials = $this->twoFactor->config['settings']['credentials']; + foreach ($credentials as &$credential) { + if (isset($credential['trustPath'])) { + continue; + } + + $credential['trustPath'] = ['type' => EmptyTrustPath::class]; + } + + return $credentials; + } + + /** + * @param mixed[] $data + */ + private function write(array $data): void + { + $this->twoFactor->config['settings']['credentials'] = $data; + } + }; + } +} diff --git a/psalm.xml b/psalm.xml index c09d3109a248..fe939fff3d0a 100644 --- a/psalm.xml +++ b/psalm.xml @@ -373,6 +373,7 @@ proc_priv: bool, querytime: float|int, read_limit: int, + request: PhpMyAdmin\Http\ServerRequest, save_on_server: bool, server: int, SESSION_KEY: string, diff --git a/scripts/create-release.sh b/scripts/create-release.sh index 3aa2dbc2dbf6..623ce8fc3cac 100755 --- a/scripts/create-release.sh +++ b/scripts/create-release.sh @@ -244,7 +244,18 @@ cleanup_composer_vendors() { vendor/phpmyadmin/shapefile/CONTRIBUTING.md \ vendor/phpmyadmin/shapefile/CODE_OF_CONDUCT.md \ vendor/phpmyadmin/sql-parser/CODE_OF_CONDUCT.md \ - vendor/phpmyadmin/sql-parser/CONTRIBUTING.md + vendor/phpmyadmin/sql-parser/CONTRIBUTING.md \ + vendor/beberlei/assert/.github/ \ + vendor/brick/math/SECURITY.md \ + vendor/brick/math/psalm-baseline.xml \ + vendor/brick/math/psalm.xml \ + vendor/ramsey/collection/SECURITY.md \ + vendor/spomky-labs/base64url/.github/ \ + vendor/spomky-labs/cbor-php/.php_cs.dist \ + vendor/spomky-labs/cbor-php/CODE_OF_CONDUCT.md \ + vendor/spomky-labs/cbor-php/infection.json.dist \ + vendor/spomky-labs/cbor-php/phpstan.neon \ + vendor/thecodingmachine/safe/generated/Exceptions/.gitkeep find vendor/tecnickcom/tcpdf/fonts/ -maxdepth 1 -type f \ -not -name 'dejavusans.*' \ -not -name 'dejavusansb.*' \ @@ -292,6 +303,10 @@ security_checkup() { echo 'TCPDF should be installed, detection failed !' exit 1; fi + if [ ! -f vendor/web-auth/webauthn-lib/src/Server.php ]; then + echo 'Webauthn-lib should be installed, detection failed !' + exit 1; + fi if [ ! -f vendor/code-lts/u2f-php-server/src/U2FServer.php ]; then echo 'U2F-server should be installed, detection failed !' exit 1; @@ -500,7 +515,7 @@ composer update --no-interaction --no-dev # Parse the required versions from composer.json PACKAGES_VERSIONS='' -PACKAGE_LIST='tecnickcom/tcpdf pragmarx/google2fa-qrcode bacon/bacon-qr-code code-lts/u2f-php-server' +PACKAGE_LIST='tecnickcom/tcpdf pragmarx/google2fa-qrcode bacon/bacon-qr-code code-lts/u2f-php-server web-auth/webauthn-lib' for PACKAGES in $PACKAGE_LIST do diff --git a/templates/login/twofactor/webauthn_creation.twig b/templates/login/twofactor/webauthn_creation.twig new file mode 100644 index 000000000000..552a96697677 --- /dev/null +++ b/templates/login/twofactor/webauthn_creation.twig @@ -0,0 +1,3 @@ +

{{ 'Please connect your WebAuthn/FIDO2 device. Then confirm registration on the device.'|trans }}

+ + diff --git a/templates/login/twofactor/webauthn_request.twig b/templates/login/twofactor/webauthn_request.twig new file mode 100644 index 000000000000..5b13e7c246c2 --- /dev/null +++ b/templates/login/twofactor/webauthn_request.twig @@ -0,0 +1,3 @@ +

{{ 'Please connect your WebAuthn/FIDO2 device. Then confirm login on the device.'|trans }}

+ + diff --git a/templates/preferences/two_factor/main.twig b/templates/preferences/two_factor/main.twig index 17ada02e25c5..9feba8833d0d 100644 --- a/templates/preferences/two_factor/main.twig +++ b/templates/preferences/two_factor/main.twig @@ -21,6 +21,17 @@ {% else %}

{% trans "Two-factor authentication is available, but not configured for this account." %}

{% endif %} + {% if missing|length > 0 %} +

+ {{ 'Please install optional dependencies to enable more authentication backends.'|trans }} + {{ 'Following composer packages are missing:'|trans }} +

+
    + {% for item in missing %} +
  • {{ item.dep }} ({{ item.class }})
  • + {% endfor %} +
+ {% endif %} {% endif %} {% else %}

{% trans "Two-factor authentication is not available, enable phpMyAdmin configuration storage to use it." %}

diff --git a/test/classes/Plugins/TwoFactor/WebAuthnTest.php b/test/classes/Plugins/TwoFactor/WebAuthnTest.php new file mode 100644 index 000000000000..8c1b52ff9739 --- /dev/null +++ b/test/classes/Plugins/TwoFactor/WebAuthnTest.php @@ -0,0 +1,299 @@ +assertSame('WebAuthn', WebAuthn::$id); + $this->assertSame('Hardware Security Key (WebAuthn/FIDO2)', WebAuthn::getName()); + $this->assertSame( + 'Provides authentication using hardware security tokens supporting the WebAuthn/FIDO2 protocol,' + . ' such as a YubiKey.', + WebAuthn::getDescription() + ); + } + + public function testRender(): void + { + $GLOBALS['lang'] = 'en'; + $GLOBALS['server'] = 1; + $GLOBALS['text_dir'] = 'ltr'; + $GLOBALS['PMA_PHP_SELF'] = 'index.php'; + + $uri = $this->createStub(UriInterface::class); + $uri->method('getHost')->willReturn('test.localhost'); + $request = $this->createStub(ServerRequest::class); + $request->method('getUri')->willReturn($uri); + $GLOBALS['request'] = $request; + + $twoFactor = $this->createStub(TwoFactor::class); + $twoFactor->user = 'test_user'; + $twoFactor->config = [ + 'backend' => 'WebAuthn', + 'settings' => [ + 'credentials' => [ + // base64 of publicKeyCredentialId1 + 'cHVibGljS2V5Q3JlZGVudGlhbElkMQ==' => [ + // base64url of publicKeyCredentialId1 + 'publicKeyCredentialId' => 'cHVibGljS2V5Q3JlZGVudGlhbElkMQ', + 'type' => 'public-key', + ], + // base64 of publicKeyCredentialId2 + 'cHVibGljS2V5Q3JlZGVudGlhbElkMg==' => ['publicKeyCredentialId' => '', 'type' => ''], + ], + ], + ]; + + $expectedRequestOptions = [ + 'challenge' => 'challenge', + 'allowCredentials' => [['type' => 'public-key', 'id' => 'cHVibGljS2V5Q3JlZGVudGlhbElkMQ']], + 'timeout' => 60000, + ]; + $server = $this->createMock(Server::class); + $server->expects($this->once())->method('getCredentialRequestOptions')->with( + $this->equalTo('test_user'), + $this->anything(), + $this->equalTo('test.localhost'), + $this->equalTo([['type' => 'public-key', 'id' => 'cHVibGljS2V5Q3JlZGVudGlhbElkMQ']]) + )->willReturn($expectedRequestOptions); + + $webAuthn = new WebAuthn($twoFactor); + $webAuthn->setServer($server); + $actual = $webAuthn->render(); + + $optionsFromSession = $_SESSION['WebAuthnCredentialRequestOptions'] ?? null; + $this->assertIsString($optionsFromSession); + $this->assertJson($optionsFromSession); + $this->assertEquals($expectedRequestOptions, json_decode($optionsFromSession, true)); + + $this->assertStringContainsString('id="webauthn_request_response"', $actual); + $this->assertStringContainsString('name="webauthn_request_response"', $actual); + $this->assertStringContainsString('value=""', $actual); + $this->assertStringContainsString('data-request-options="', $actual); + $this->assertSame('', $webAuthn->getError()); + + $files = ResponseRenderer::getInstance()->getHeader()->getScripts()->getFiles(); + $this->assertContains('webauthn.js', array_column($files, 'name')); + } + + public function testSetup(): void + { + $GLOBALS['lang'] = 'en'; + $GLOBALS['server'] = 1; + $GLOBALS['text_dir'] = 'ltr'; + $GLOBALS['PMA_PHP_SELF'] = 'index.php'; + + $uri = $this->createStub(UriInterface::class); + $uri->method('getHost')->willReturn('test.localhost'); + $request = $this->createStub(ServerRequest::class); + $request->method('getUri')->willReturn($uri); + $GLOBALS['request'] = $request; + + $twoFactor = $this->createStub(TwoFactor::class); + $twoFactor->user = 'test_user'; + + $expectedCreationOptions = [ + 'challenge' => 'challenge', + 'rp' => ['name' => 'phpMyAdmin (test.localhost)', 'id' => 'test.localhost'], + 'user' => ['id' => 'user_id', 'name' => 'test_user', 'displayName' => 'test_user'], + 'pubKeyCredParams' => [['alg' => -8, 'type' => 'public-key']], + 'authenticatorSelection' => ['authenticatorAttachment' => 'cross-platform'], + 'timeout' => 60000, + 'attestation' => 'none', + ]; + $server = $this->createMock(Server::class); + $server->expects($this->once())->method('getCredentialCreationOptions')->with( + $this->equalTo('test_user'), + $this->anything(), + $this->equalTo('test.localhost') + )->willReturn($expectedCreationOptions); + + $webAuthn = new WebAuthn($twoFactor); + $webAuthn->setServer($server); + $actual = $webAuthn->setup(); + + $optionsFromSession = $_SESSION['WebAuthnCredentialCreationOptions'] ?? null; + $this->assertIsString($optionsFromSession); + $this->assertJson($optionsFromSession); + $this->assertEquals($expectedCreationOptions, json_decode($optionsFromSession, true)); + + $this->assertStringContainsString('id="webauthn_creation_response"', $actual); + $this->assertStringContainsString('name="webauthn_creation_response"', $actual); + $this->assertStringContainsString('value=""', $actual); + $this->assertStringContainsString('data-creation-options="', $actual); + $this->assertSame('', $webAuthn->getError()); + + $files = ResponseRenderer::getInstance()->getHeader()->getScripts()->getFiles(); + $this->assertContains('webauthn.js', array_column($files, 'name')); + } + + public function testConfigure(): void + { + $_SESSION = []; + $request = $this->createStub(ServerRequest::class); + $request->method('getParsedBodyParam')->willReturnMap([['webauthn_creation_response', '', '']]); + $GLOBALS['request'] = $request; + $webAuthn = new WebAuthn($this->createStub(TwoFactor::class)); + $this->assertFalse($webAuthn->configure()); + $this->assertSame('', $webAuthn->getError()); + } + + public function testConfigure2(): void + { + $_SESSION['WebAuthnCredentialCreationOptions'] = ''; + $request = $this->createStub(ServerRequest::class); + $request->method('getParsedBodyParam')->willReturnMap([['webauthn_creation_response', '', '{}']]); + $GLOBALS['request'] = $request; + $webAuthn = new WebAuthn($this->createStub(TwoFactor::class)); + $this->assertFalse($webAuthn->configure()); + $this->assertStringContainsString('Two-factor authentication failed:', $webAuthn->getError()); + } + + public function testConfigure3(): void + { + $_SESSION['WebAuthnCredentialCreationOptions'] = '{}'; + $request = $this->createStub(ServerRequest::class); + $request->method('getParsedBodyParam')->willReturnMap([['webauthn_creation_response', '', '{}']]); + $GLOBALS['request'] = $request; + + $server = $this->createMock(Server::class); + $server->expects($this->once())->method('parseAndValidateAttestationResponse') + ->willThrowException(new WebAuthnException()); + + $webAuthn = new WebAuthn($this->createStub(TwoFactor::class)); + $webAuthn->setServer($server); + $this->assertFalse($webAuthn->configure()); + $this->assertStringContainsString('Two-factor authentication failed.', $webAuthn->getError()); + } + + public function testConfigure4(): void + { + $_SESSION['WebAuthnCredentialCreationOptions'] = '{}'; + $request = $this->createStub(ServerRequest::class); + $request->method('getParsedBodyParam')->willReturnMap([['webauthn_creation_response', '', '{}']]); + $GLOBALS['request'] = $request; + + $twoFactor = $this->createStub(TwoFactor::class); + $twoFactor->config = ['backend' => '', 'settings' => []]; + + // base64url of publicKeyCredentialId1 + $credential = ['publicKeyCredentialId' => 'cHVibGljS2V5Q3JlZGVudGlhbElkMQ', 'userHandle' => 'userHandle']; + $server = $this->createMock(Server::class); + $server->expects($this->once())->method('parseAndValidateAttestationResponse')->with( + $this->equalTo('{}'), + $this->equalTo('{}'), + $this->equalTo($request) + )->willReturn($credential); + + $webAuthn = new WebAuthn($twoFactor); + $webAuthn->setServer($server); + $this->assertTrue($webAuthn->configure()); + /** @psalm-var array{backend: string, settings: mixed[]} $config */ + $config = $twoFactor->config; + $this->assertSame( + [ + 'backend' => '', + 'settings' => [ + 'userHandle' => 'userHandle', + 'credentials' => ['cHVibGljS2V5Q3JlZGVudGlhbElkMQ==' => $credential], + ], + ], + $config + ); + } + + public function testCheck(): void + { + $_SESSION = []; + $request = $this->createStub(ServerRequest::class); + $request->method('getParsedBodyParam')->willReturnMap([['webauthn_request_response', '', '']]); + $GLOBALS['request'] = $request; + $webAuthn = new WebAuthn($this->createStub(TwoFactor::class)); + $this->assertFalse($webAuthn->check()); + $this->assertSame('', $webAuthn->getError()); + } + + public function testCheck2(): void + { + $_SESSION['WebAuthnCredentialRequestOptions'] = ''; + $request = $this->createStub(ServerRequest::class); + $request->method('getParsedBodyParam')->willReturnMap([['webauthn_request_response', '', '{}']]); + $GLOBALS['request'] = $request; + $webAuthn = new WebAuthn($this->createStub(TwoFactor::class)); + $this->assertFalse($webAuthn->check()); + $this->assertStringContainsString('Two-factor authentication failed:', $webAuthn->getError()); + } + + public function testCheck3(): void + { + $_SESSION['WebAuthnCredentialRequestOptions'] = '{"challenge":"challenge"}'; + $request = $this->createStub(ServerRequest::class); + $request->method('getParsedBodyParam')->willReturnMap([['webauthn_request_response', '', '{}']]); + $GLOBALS['request'] = $request; + + $server = $this->createMock(Server::class); + $server->expects($this->once())->method('parseAndValidateAssertionResponse') + ->willThrowException(new WebAuthnException()); + + $webAuthn = new WebAuthn($this->createStub(TwoFactor::class)); + $webAuthn->setServer($server); + $this->assertFalse($webAuthn->check()); + $this->assertStringContainsString('Two-factor authentication failed.', $webAuthn->getError()); + } + + public function testCheck4(): void + { + $_SESSION['WebAuthnCredentialRequestOptions'] = '{"challenge":"challenge"}'; + $request = $this->createStub(ServerRequest::class); + $request->method('getParsedBodyParam')->willReturnMap([['webauthn_request_response', '', '{}']]); + $GLOBALS['request'] = $request; + + $twoFactor = $this->createStub(TwoFactor::class); + $twoFactor->config = [ + 'backend' => 'WebAuthn', + 'settings' => [ + 'credentials' => [ + // base64 of publicKeyCredentialId1 + 'cHVibGljS2V5Q3JlZGVudGlhbElkMQ==' => [ + // base64url of publicKeyCredentialId1 + 'publicKeyCredentialId' => 'cHVibGljS2V5Q3JlZGVudGlhbElkMQ', + 'type' => 'public-key', + ], + ], + ], + ]; + + $server = $this->createMock(Server::class); + $server->expects($this->once())->method('parseAndValidateAssertionResponse')->with( + $this->equalTo('{}'), + $this->equalTo([['type' => 'public-key', 'id' => 'cHVibGljS2V5Q3JlZGVudGlhbElkMQ']]), + $this->equalTo('challenge'), + $this->equalTo($request) + ); + + $webAuthn = new WebAuthn($twoFactor); + $webAuthn->setServer($server); + $this->assertTrue($webAuthn->check()); + } +} diff --git a/test/classes/WebAuthn/CBORDecoderTest.php b/test/classes/WebAuthn/CBORDecoderTest.php new file mode 100644 index 000000000000..f43e43a25e97 --- /dev/null +++ b/test/classes/WebAuthn/CBORDecoderTest.php @@ -0,0 +1,198 @@ +assertNotFalse($data); + $this->assertSame($expected, $decoder->decode(new DataStream($data))); + } + + /** + * @psalm-return iterable + */ + public function dataProviderForTestDecode(): iterable + { + return [ + ['00', 0], + ['01', 1], + ['0a', 10], + ['17', 23], + ['1818', 24], + ['1819', 25], + ['1864', 100], + ['1903e8', 1000], + ['1a000f4240', 1000000], + //['1b000000e8d4a51000', 1000000000000], + //['1bffffffffffffffff', 18446744073709551615], + //['c249010000000000000000', 18446744073709551616], + //['3bffffffffffffffff', -18446744073709551616], + //['c349010000000000000000', -18446744073709551617], + ['20', -1], + ['29', -10], + ['3863', -100], + ['3903e7', -1000], + ['f90000', 0.0], + ['f98000', -0.0], + ['f93c00', 1.0], + ['fb3ff199999999999a', 1.1], + ['f93e00', 1.5], + ['f97bff', 65504.0], + ['fa47c35000', 100000.0], + ['fa7f7fffff', 3.4028234663852886e+38], + ['fb7e37e43c8800759c', 1.0e+300], + ['f90001', 5.960464477539063e-8], + ['f90400', 0.00006103515625], + ['f9c400', -4.0], + ['fbc010666666666666', -4.1], + ['f97c00', INF], + ['f9fc00', -INF], + ['fa7f800000', INF], + ['faff800000', -INF], + ['fb7ff0000000000000', INF], + ['fbfff0000000000000', -INF], + ['f4', true], + ['f5', false], + ['f6', null], + //['f7', 'undefined'], + ['f0', 16], + ['f818', 24], + ['f8ff', 255], + ['c074323031332d30332d32315432303a30343a30305a', '2013-03-21T20:04:00Z'], + ['c11a514b67b0', 1363896240], + ['c1fb41d452d9ec200000', 1363896240.5], + ['d74401020304', hex2bin('01020304')], + ['d818456449455446', hex2bin('6449455446')], + ['d82076687474703a2f2f7777772e6578616d706c652e636f6d', 'http://www.example.com'], + ['40', hex2bin('')], + ['4401020304', hex2bin('01020304')], + ['60', ''], + ['6161', 'a'], + ['6449455446', 'IETF'], + ['62225c', '"\\'], + ['62c3bc', "\u{00fc}"], + ['63e6b0b4', "\u{6c34}"], + ['64f0908591', "\u{10151}"], // "\u{d800}\u{dd51}" + ['80', []], + ['83010203', [1, 2, 3]], + ['8301820203820405', [1, [2, 3], [4, 5]]], + [ + '98190102030405060708090a0b0c0d0e0f101112131415161718181819', + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25], + ], + ['a0', []], + ['a201020304', [1 => 2, 3 => 4]], + ['a26161016162820203', ['a' => 1, 'b' => [2, 3]]], + ['826161a161626163', ['a', ['b' => 'c']]], + [ + 'a56161614161626142616361436164614461656145', + ['a' => 'A', 'b' => 'B', 'c' => 'C', 'd' => 'D', 'e' => 'E'], + ], + ['a1646e616d656441636d65', ['name' => 'Acme']], + [ + 'a462696458203082019330820138a0030201023082019330820138a003020102' + . '3082019330826469636f6e782b68747470733a2f2f706963732e6578616d706c' + . '652e636f6d2f30302f702f61426a6a6a707150622e706e67646e616d65766a6f' + . '686e70736d697468406578616d706c652e636f6d6b646973706c61794e616d65' + . '6d4a6f686e20502e20536d697468a462696458203082019330820138a0030201' + . '023082019330820138a0030201023082019330826469636f6e782b6874747073' + . '3a2f2f706963732e6578616d706c652e636f6d2f30302f702f61426a6a6a7071' + . '50622e706e67646e616d65766a6f686e70736d697468406578616d706c652e63' + . '6f6d6b646973706c61794e616d656d4a6f686e20502e20536d697468', + [ + 'id' => base64_decode('MIIBkzCCATigAwIBAjCCAZMwggE4oAMCAQIwggGTMII='), + 'icon' => 'https://pics.example.com/00/p/aBjjjpqPb.png', + 'name' => 'johnpsmith@example.com', + 'displayName' => 'John P. Smith', + ], + ], + [ + '82a263616c672664747970656a7075626C69632D6B6579a263616c6739010064747970656a7075626C69632D6B6579', + [ + ['alg' => -7, 'type' => 'public-key'], + ['alg' => -257, 'type' => 'public-key'], + ], + ], + [ + 'A501020326200121582065eda5a12577c2bae829437fe338701a10aaa375e1bb5b5de108de439c08551d' + . '2258201e52ed75701163f7f9e40ddf9f341b3dc9ba860af7e0ca7ca7e9eecd0084d19c', + [ + 1 => 2, + 3 => -7, + -1 => 1, + -2 => hex2bin('65eda5a12577c2bae829437fe338701a10aaa375e1bb5b5de108de439c08551d'), + -3 => hex2bin('1e52ed75701163f7f9e40ddf9f341b3dc9ba860af7e0ca7ca7e9eecd0084d19c'), + ], + ], + ]; + } + + public function testDecodeForNaNValues(): void + { + $decoder = new CBORDecoder(); + $nanValues = ['f97e00', 'fa7fc00000', 'fb7ff8000000000000']; + foreach ($nanValues as $value) { + $data = hex2bin($value); + $this->assertNotFalse($data); + $this->assertNan($decoder->decode(new DataStream($data))); + } + } + + /** + * @dataProvider indefiniteLengthValuesProvider + */ + public function testDecodeForNotSupportedValues(string $encoded): void + { + $decoder = new CBORDecoder(); + $data = hex2bin($encoded); + $this->assertNotFalse($data); + $this->expectException(WebAuthnException::class); + $decoder->decode(new DataStream($data)); + } + + /** + * @psalm-return iterable + */ + public function indefiniteLengthValuesProvider(): iterable + { + return [ + ['5f42010243030405ff'], // (_ h'0102', h'030405') + ['7f657374726561646d696e67ff'], // (_ "strea", "ming") + ['9fff'], // [_ ] + ['9f018202039f0405ffff'], // [_ 1, [2, 3], [_ 4, 5]] + ['9f01820203820405ff'], // [_ 1, [2, 3], [4, 5]] + ['83018202039f0405ff'], // [1, [2, 3], [_ 4, 5]] + ['83019f0203ff820405'], // [1, [_ 2, 3], [4, 5]] + // [_ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] + ['9f0102030405060708090a0b0c0d0e0f101112131415161718181819ff'], + ['bf61610161629f0203ffff'], // {_ "a": 1, "b": [_ 2, 3]} + ['826161bf61626163ff'], // ["a", {_ "b": "c"}] + ['bf6346756ef563416d7421ff'], // {_ "Fun": true, "Amt": -2} + ]; + } +} diff --git a/test/classes/WebAuthn/WebauthnLibServerTest.php b/test/classes/WebAuthn/WebauthnLibServerTest.php new file mode 100644 index 000000000000..5ff7f315bf61 --- /dev/null +++ b/test/classes/WebAuthn/WebauthnLibServerTest.php @@ -0,0 +1,83 @@ +markTestSkipped('Package "web-auth/webauthn-lib" is required.'); + } + + public function testGetCredentialCreationOptions(): void + { + $server = new WebauthnLibServer($this->createStub(TwoFactor::class)); + $options = $server->getCredentialCreationOptions('user_name', 'user_id', 'test.localhost'); + $this->assertArrayHasKey('challenge', $options); + $this->assertNotEmpty($options['challenge']); + $this->assertArrayHasKey('pubKeyCredParams', $options); + $this->assertNotEmpty($options['pubKeyCredParams']); + $this->assertArrayHasKey('attestation', $options); + $this->assertNotEmpty($options['attestation']); + $this->assertSame('phpMyAdmin (test.localhost)', $options['rp']['name']); + $this->assertSame('test.localhost', $options['rp']['id']); + $this->assertSame('user_name', $options['user']['name']); + $this->assertSame('user_name', $options['user']['displayName']); + $this->assertSame(base64_encode('user_id'), $options['user']['id']); + $this->assertArrayHasKey('authenticatorAttachment', $options['authenticatorSelection']); + $this->assertSame('cross-platform', $options['authenticatorSelection']['authenticatorAttachment']); + } + + public function testGetCredentialRequestOptions(): void + { + $twoFactor = $this->createStub(TwoFactor::class); + $twoFactor->config = [ + 'backend' => 'WebAuthn', + 'settings' => [ + 'credentials' => [ + // base64 of publicKeyCredentialId1 + 'cHVibGljS2V5Q3JlZGVudGlhbElkMQ==' => [ + // base64url for publicKeyCredentialId1 + 'publicKeyCredentialId' => 'cHVibGljS2V5Q3JlZGVudGlhbElkMQ', + 'type' => 'public-key', + 'transports' => [], + 'attestationType' => 'none', + 'trustPath' => ['type' => 'Webauthn\\TrustPath\\EmptyTrustPath'], + 'aaguid' => '00000000-0000-0000-0000-000000000000', + 'credentialPublicKey' => 'Y3JlZGVudGlhbFB1YmxpY0tleTE', // base64url for credentialPublicKey1 + 'userHandle' => 'dXNlckhhbmRsZTE=', // base64 for userHandle1 + 'counter' => 0, + 'otherUI' => null, + ], + ], + ], + ]; + + $server = new WebauthnLibServer($twoFactor); + $options = $server->getCredentialRequestOptions('user_name', 'userHandle1', 'test.localhost', []); + $this->assertNotEmpty($options['challenge']); + $this->assertSame('test.localhost', $options['rpId']); + $this->assertEquals( + [['type' => 'public-key', 'id' => 'cHVibGljS2V5Q3JlZGVudGlhbElkMQ==']], + $options['allowCredentials'] + ); + } +}