<?php
/**
* @package Joomla.Plugin
* @subpackage System.Webauthn
*
* @copyright (C) 2020 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Plugin\System\Webauthn\Helper;
// Protect from unauthorized access
\defined('_JEXEC') or die();
use CBOR\Decoder;
use CBOR\OtherObject\OtherObjectManager;
use CBOR\Tag\TagObjectManager;
use Cose\Algorithm\Manager;
use Cose\Algorithm\Signature\ECDSA;
use Cose\Algorithm\Signature\EdDSA;
use Cose\Algorithm\Signature\RSA;
use Cose\Algorithms;
use Exception;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Crypt\Crypt;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\User;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\Plugin\System\Webauthn\CredentialRepository;
use Laminas\Diactoros\ServerRequestFactory;
use RuntimeException;
use Webauthn\AttestationStatement\AndroidKeyAttestationStatementSupport;
use Webauthn\AttestationStatement\AttestationObjectLoader;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\FidoU2FAttestationStatementSupport;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\AttestationStatement\PackedAttestationStatementSupport;
use Webauthn\AttestationStatement\TPMAttestationStatementSupport;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
use Webauthn\AuthenticatorAttestationResponse;
use Webauthn\AuthenticatorAttestationResponseValidator;
use Webauthn\AuthenticatorSelectionCriteria;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialDescriptor;
use Webauthn\PublicKeyCredentialLoader;
use Webauthn\PublicKeyCredentialParameters;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialUserEntity;
use Webauthn\TokenBinding\TokenBindingNotSupportedHandler;
/**
* Helper class to aid in credentials creation (link an authenticator to a user account)
*
* @since 4.0.0
*/
abstract class CredentialsCreation
{
/**
* Create a public key for credentials creation. The result is a JSON string which can be used in Javascript code
* with navigator.credentials.create().
*
* @param User $user The Joomla user to create the public key for
*
* @return string
*
* @since 4.0.0
*/
public static function createPublicKey(User $user): string
{
/** @var CMSApplication $app */
try
{
$app = Factory::getApplication();
$siteName = $app->getConfig()->get('sitename', 'Joomla! Site');
}
catch (Exception $e)
{
$siteName = 'Joomla! Site';
}
// Credentials repository
$repository = new CredentialRepository;
// Relaying Party -- Our site
$rpEntity = new PublicKeyCredentialRpEntity(
$siteName,
Uri::getInstance()->toString(['host']),
self::getSiteIcon()
);
// User Entity
$userEntity = new PublicKeyCredentialUserEntity(
$user->username,
$repository->getHandleFromUserId($user->id),
$user->name
);
// Challenge
try
{
$challenge = random_bytes(32);
}
catch (Exception $e)
{
$challenge = Crypt::genRandomBytes(32);
}
// Public Key Credential Parameters
$publicKeyCredentialParametersList = [
new PublicKeyCredentialParameters('public-key', Algorithms::COSE_ALGORITHM_ES256),
];
// Timeout: 60 seconds (given in milliseconds)
$timeout = 60000;
// Devices to exclude (already set up authenticators)
$excludedPublicKeyDescriptors = [];
$records = $repository->findAllForUserEntity($userEntity);
/** @var PublicKeyCredentialSource $record */
foreach ($records as $record)
{
$excludedPublicKeyDescriptors[] = new PublicKeyCredentialDescriptor($record->getType(), $record->getCredentialPublicKey());
}
// Authenticator Selection Criteria (we used default values)
$authenticatorSelectionCriteria = new AuthenticatorSelectionCriteria;
// Extensions (not yet supported by the library)
$extensions = new AuthenticationExtensionsClientInputs;
// Attestation preference
$attestationPreference = PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE;
// Public key credential creation options
$publicKeyCredentialCreationOptions = new PublicKeyCredentialCreationOptions(
$rpEntity,
$userEntity,
$challenge,
$publicKeyCredentialParametersList,
$timeout,
$excludedPublicKeyDescriptors,
$authenticatorSelectionCriteria,
$attestationPreference,
$extensions
);
// Save data in the session
Joomla::setSessionVar('publicKeyCredentialCreationOptions',
base64_encode(serialize($publicKeyCredentialCreationOptions)),
'plg_system_webauthn'
);
Joomla::setSessionVar('registration_user_id', $user->id, 'plg_system_webauthn');
return json_encode($publicKeyCredentialCreationOptions, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
/**
* Validate the authentication data returned by the device and return the public key credential source on success.
*
* An exception will be returned on error. Also, under very rare conditions, you may receive NULL instead of
* a PublicKeyCredentialSource object which means that something was off in the returned data from the browser.
*
* @param string $data The JSON-encoded data returned by the browser during the authentication flow
*
* @return PublicKeyCredentialSource|null
*
* @since 4.0.0
*/
public static function validateAuthenticationData(string $data): ?PublicKeyCredentialSource
{
// Retrieve the PublicKeyCredentialCreationOptions object created earlier and perform sanity checks
$encodedOptions = Joomla::getSessionVar('publicKeyCredentialCreationOptions', null, 'plg_system_webauthn');
if (empty($encodedOptions))
{
throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_NO_PK'));
}
try
{
$publicKeyCredentialCreationOptions = unserialize(base64_decode($encodedOptions));
}
catch (Exception $e)
{
$publicKeyCredentialCreationOptions = null;
}
if (!\is_object($publicKeyCredentialCreationOptions) || !($publicKeyCredentialCreationOptions instanceof PublicKeyCredentialCreationOptions))
{
throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_NO_PK'));
}
// Retrieve the stored user ID and make sure it's the same one in the request.
$storedUserId = Joomla::getSessionVar('registration_user_id', 0, 'plg_system_webauthn');
try
{
$myUser = Factory::getApplication()->getIdentity();
}
catch (Exception $e)
{
$dummyUserId = 0;
$myUser = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($dummyUserId);
}
$myUserId = $myUser->id;
if (($myUser->guest) || ($myUserId != $storedUserId))
{
throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_USER'));
}
// Cose Algorithm Manager
$coseAlgorithmManager = new Manager;
$coseAlgorithmManager->add(new ECDSA\ES256);
$coseAlgorithmManager->add(new ECDSA\ES512);
$coseAlgorithmManager->add(new EdDSA\EdDSA);
$coseAlgorithmManager->add(new RSA\RS1);
$coseAlgorithmManager->add(new RSA\RS256);
$coseAlgorithmManager->add(new RSA\RS512);
// Create a CBOR Decoder object
$otherObjectManager = new OtherObjectManager;
$tagObjectManager = new TagObjectManager;
$decoder = new Decoder($tagObjectManager, $otherObjectManager);
// The token binding handler
$tokenBindingHandler = new TokenBindingNotSupportedHandler;
// Attestation Statement Support Manager
$attestationStatementSupportManager = new AttestationStatementSupportManager;
$attestationStatementSupportManager->add(new NoneAttestationStatementSupport);
$attestationStatementSupportManager->add(new FidoU2FAttestationStatementSupport($decoder));
/**
$attestationStatementSupportManager->add(
new AndroidSafetyNetAttestationStatementSupport(HttpFactory::getHttp(),
'GOOGLE_SAFETYNET_API_KEY',
new RequestFactory
)
);
*/
$attestationStatementSupportManager->add(new AndroidKeyAttestationStatementSupport($decoder));
$attestationStatementSupportManager->add(new TPMAttestationStatementSupport);
$attestationStatementSupportManager->add(new PackedAttestationStatementSupport($decoder, $coseAlgorithmManager));
// Attestation Object Loader
$attestationObjectLoader = new AttestationObjectLoader($attestationStatementSupportManager, $decoder);
// Public Key Credential Loader
$publicKeyCredentialLoader = new PublicKeyCredentialLoader($attestationObjectLoader, $decoder);
// Credential Repository
$credentialRepository = new CredentialRepository;
// Extension output checker handler
$extensionOutputCheckerHandler = new ExtensionOutputCheckerHandler;
// Authenticator Attestation Response Validator
$authenticatorAttestationResponseValidator = new AuthenticatorAttestationResponseValidator(
$attestationStatementSupportManager,
$credentialRepository,
$tokenBindingHandler,
$extensionOutputCheckerHandler
);
// Any Throwable from this point will bubble up to the GUI
// We init the PSR-7 request object using Diactoros
$request = ServerRequestFactory::fromGlobals();
// Load the data
$publicKeyCredential = $publicKeyCredentialLoader->load(base64_decode($data));
$response = $publicKeyCredential->getResponse();
// Check if the response is an Authenticator Attestation Response
if (!$response instanceof AuthenticatorAttestationResponse)
{
throw new RuntimeException('Not an authenticator attestation response');
}
// Check the response against the request
$authenticatorAttestationResponseValidator->check($response, $publicKeyCredentialCreationOptions, $request);
/**
* Everything is OK here. You can get the Public Key Credential Source. This object should be persisted using
* the Public Key Credential Source repository.
*/
return PublicKeyCredentialSource::createFromPublicKeyCredential(
$publicKeyCredential,
$publicKeyCredentialCreationOptions->getUser()->getId()
);
}
/**
* Try to find the site's favicon in the site's root, images, media, templates or current template directory.
*
* @return string|null
*
* @since 4.0.0
*/
protected static function getSiteIcon(): ?string
{
$filenames = [
'apple-touch-icon.png',
'apple_touch_icon.png',
'favicon.ico',
'favicon.png',
'favicon.gif',
'favicon.bmp',
'favicon.jpg',
'favicon.svg',
];
try
{
$paths = [
'/',
'/images/',
'/media/',
'/templates/',
'/templates/' . Factory::getApplication()->getTemplate(),
];
}
catch (Exception $e)
{
return null;
}
foreach ($paths as $path)
{
foreach ($filenames as $filename)
{
$relFile = $path . $filename;
$filePath = JPATH_BASE . $relFile;
if (is_file($filePath))
{
break 2;
}
$relFile = null;
}
}
if (!isset($relFile) || \is_null($relFile))
{
return null;
}
return rtrim(Uri::base(), '/') . '/' . ltrim($relFile, '/');
}
}