* @license GNU General Public License version 2 or later; see LICENSE.txt */ namespace Joomla\Plugin\System\Webauthn; use Exception; use InvalidArgumentException; use Joomla\CMS\Date\Date; use Joomla\CMS\Encrypt\Aes; use Joomla\CMS\Factory; use Joomla\CMS\Language\Text; use Joomla\CMS\User\UserFactoryInterface; use Joomla\Database\DatabaseAwareInterface; use Joomla\Database\DatabaseAwareTrait; use Joomla\Database\DatabaseDriver; use Joomla\Database\DatabaseInterface; use Joomla\Plugin\System\Webauthn\Extension\Webauthn; use Joomla\Registry\Registry; use JsonException; use RuntimeException; use Throwable; use Webauthn\PublicKeyCredentialSource; use Webauthn\PublicKeyCredentialSourceRepository; use Webauthn\PublicKeyCredentialUserEntity; // phpcs:disable PSR1.Files.SideEffects \defined('_JEXEC') or die; // phpcs:enable PSR1.Files.SideEffects /** * Handles the storage of WebAuthn credentials in the database * * @since 4.0.0 */ final class CredentialRepository implements PublicKeyCredentialSourceRepository, DatabaseAwareInterface { use DatabaseAwareTrait; /** * Public constructor. * * @param DatabaseInterface|null $db The database driver object to use for persistence. * * @since 4.2.0 */ public function __construct(DatabaseInterface $db = null) { $this->setDatabase($db); } /** * Returns a PublicKeyCredentialSource object given the public key credential ID * * @param string $publicKeyCredentialId The identified of the public key credential we're searching for * * @return PublicKeyCredentialSource|null * * @since 4.0.0 */ public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource { /** @var DatabaseDriver $db */ $db = $this->getDatabase(); $credentialId = base64_encode($publicKeyCredentialId); $query = $db->getQuery(true) ->select($db->qn('credential')) ->from($db->qn('#__webauthn_credentials')) ->where($db->qn('id') . ' = :credentialId') ->bind(':credentialId', $credentialId); $encrypted = $db->setQuery($query)->loadResult(); if (empty($encrypted)) { return null; } $json = $this->decryptCredential($encrypted); try { return PublicKeyCredentialSource::createFromArray(json_decode($json, true)); } catch (Throwable $e) { return null; } } /** * Returns all PublicKeyCredentialSource objects given a user entity. We only use the `id` property of the user * entity, cast to integer, as the Joomla user ID by which records are keyed in the database table. * * @param PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity Public key credential user entity record * * @return PublicKeyCredentialSource[] * * @since 4.0.0 */ public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array { /** @var DatabaseDriver $db */ $db = $this->getDatabase(); $userHandle = $publicKeyCredentialUserEntity->getId(); $query = $db->getQuery(true) ->select('*') ->from($db->qn('#__webauthn_credentials')) ->where($db->qn('user_id') . ' = :user_id') ->bind(':user_id', $userHandle); try { $records = $db->setQuery($query)->loadAssocList(); } catch (Exception $e) { return []; } /** * Converts invalid credential records to PublicKeyCredentialSource objects, or null if they * are invalid. * * This closure is defined as a variable to prevent PHP-CS from getting a stoke trying to * figure out the correct indentation :) * * @param array $record The record to convert * * @return PublicKeyCredentialSource|null */ $recordsMapperClosure = function ($record) { try { $json = $this->decryptCredential($record['credential']); $data = json_decode($json, true); } catch (JsonException $e) { return null; } if (empty($data)) { return null; } try { return PublicKeyCredentialSource::createFromArray($data); } catch (InvalidArgumentException $e) { return null; } }; $records = array_map($recordsMapperClosure, $records); /** * Filters the list of records to only keep valid entries. * * Only array members that are PublicKeyCredentialSource objects survive the filter. * * This closure is defined as a variable to prevent PHP-CS from getting a stoke trying to * figure out the correct indentation :) * * @param PublicKeyCredentialSource|mixed $record The record to filter * * @return boolean */ $filterClosure = function ($record) { return !\is_null($record) && \is_object($record) && ($record instanceof PublicKeyCredentialSource); }; return array_filter($records, $filterClosure); } /** * Add or update an attested credential for a given user. * * @param PublicKeyCredentialSource $publicKeyCredentialSource The public key credential * source to store * * @return void * * @throws Exception * @since 4.0.0 */ public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void { // Default values for saving a new credential source /** @var Webauthn $plugin */ $plugin = Factory::getApplication()->bootPlugin('webauthn', 'system'); $knownAuthenticators = $plugin->getAuthenticationHelper()->getKnownAuthenticators(); $aaguid = (string) ($publicKeyCredentialSource->getAaguid() ?? ''); $defaultName = ($knownAuthenticators[$aaguid] ?? $knownAuthenticators[''])->description; $credentialId = base64_encode($publicKeyCredentialSource->getPublicKeyCredentialId()); $user = Factory::getApplication()->getIdentity(); $o = (object) [ 'id' => $credentialId, 'user_id' => $this->getHandleFromUserId($user->id), 'label' => Text::sprintf( 'PLG_SYSTEM_WEBAUTHN_LBL_DEFAULT_AUTHENTICATOR_LABEL', $defaultName, $this->formatDate('now') ), 'credential' => json_encode($publicKeyCredentialSource), ]; $update = false; /** @var DatabaseDriver $db */ $db = $this->getDatabase(); // Try to find an existing record try { $query = $db->getQuery(true) ->select('*') ->from($db->qn('#__webauthn_credentials')) ->where($db->qn('id') . ' = :credentialId') ->bind(':credentialId', $credentialId); $oldRecord = $db->setQuery($query)->loadObject(); if (\is_null($oldRecord)) { throw new Exception('This is a new record'); } /** * Sanity check. The existing credential source must have the same user handle as the one I am trying to * save. Otherwise something fishy is going on. */ if ($oldRecord->user_id != $publicKeyCredentialSource->getUserHandle()) { throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREDENTIAL_ID_ALREADY_IN_USE')); } $o->user_id = $oldRecord->user_id; $o->label = $oldRecord->label; $update = true; } catch (Exception $e) { } $o->credential = $this->encryptCredential($o->credential); if ($update) { $db->updateObject('#__webauthn_credentials', $o, ['id']); return; } /** * This check is deliberately skipped for updates. When logging in the underlying library will try to save the * credential source. This is necessary to update the last known authenticator signature counter which prevents * replay attacks. When we are saving a new record, though, we have to make sure we are not a guest user. Hence * the check below. */ if ((\is_null($user) || $user->guest)) { throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CANT_STORE_FOR_GUEST')); } $db->insertObject('#__webauthn_credentials', $o); } /** * Get all credential information for a given user ID. This is meant to only be used for displaying records. * * @param int $userId The user ID * * @return array * * @since 4.0.0 */ public function getAll(int $userId): array { /** @var DatabaseDriver $db */ $db = $this->getDatabase(); $userHandle = $this->getHandleFromUserId($userId); $query = $db->getQuery(true) ->select('*') ->from($db->qn('#__webauthn_credentials')) ->where($db->qn('user_id') . ' = :user_id') ->bind(':user_id', $userHandle); try { $results = $db->setQuery($query)->loadAssocList(); } catch (Exception $e) { return []; } if (empty($results)) { return []; } /** * Decodes the credentials on each record. * * @param array $record The record to convert * * @return array * @since 4.2.0 */ $recordsMapperClosure = function ($record) { try { $json = $this->decryptCredential($record['credential']); $data = json_decode($json, true); } catch (JsonException $e) { $record['credential'] = null; return $record; } if (empty($data)) { $record['credential'] = null; return $record; } try { $record['credential'] = PublicKeyCredentialSource::createFromArray($data); return $record; } catch (InvalidArgumentException $e) { $record['credential'] = null; return $record; } }; return array_map($recordsMapperClosure, $results); } /** * Do we have stored credentials under the specified Credential ID? * * @param string $credentialId The ID of the credential to check for existence * * @return boolean * * @since 4.0.0 */ public function has(string $credentialId): bool { /** @var DatabaseDriver $db */ $db = $this->getDatabase(); $credentialId = base64_encode($credentialId); $query = $db->getQuery(true) ->select('COUNT(*)') ->from($db->qn('#__webauthn_credentials')) ->where($db->qn('id') . ' = :credentialId') ->bind(':credentialId', $credentialId); try { $count = $db->setQuery($query)->loadResult(); return $count > 0; } catch (Exception $e) { return false; } } /** * Update the human readable label of a credential * * @param string $credentialId The credential ID * @param string $label The human readable label to set * * @return void * * @since 4.0.0 */ public function setLabel(string $credentialId, string $label): void { /** @var DatabaseDriver $db */ $db = $this->getDatabase(); $credentialId = base64_encode($credentialId); $o = (object) [ 'id' => $credentialId, 'label' => $label, ]; $db->updateObject('#__webauthn_credentials', $o, ['id'], false); } /** * Remove stored credentials * * @param string $credentialId The credentials ID to remove * * @return void * * @since 4.0.0 */ public function remove(string $credentialId): void { if (!$this->has($credentialId)) { return; } /** @var DatabaseDriver $db */ $db = $this->getDatabase(); $credentialId = base64_encode($credentialId); $query = $db->getQuery(true) ->delete($db->qn('#__webauthn_credentials')) ->where($db->qn('id') . ' = :credentialId') ->bind(':credentialId', $credentialId); $db->setQuery($query)->execute(); } /** * Return the user handle for the stored credential given its ID. * * The user handle must not be personally identifiable. Per https://w3c.github.io/webauthn/#user-handle it is * acceptable to have a salted hash with a salt private to our server, e.g. Joomla's secret. The only immutable * information in Joomla is the user ID so that's what we will be using. * * @param string $credentialId The credential ID to get the user handle for * * @return string * * @since 4.0.0 */ public function getUserHandleFor(string $credentialId): string { $publicKeyCredentialSource = $this->findOneByCredentialId($credentialId); if (empty($publicKeyCredentialSource)) { return ''; } return $publicKeyCredentialSource->getUserHandle(); } /** * Return a user handle given an integer Joomla user ID. We use the HMAC-SHA-256 of the user ID with the site's * secret as the key. Using it instead of SHA-512 is on purpose! WebAuthn only allows user handles up to 64 bytes * long. * * @param int $id The user ID to convert * * @return string The user handle (HMAC-SHA-256 of the user ID) * * @since 4.0.0 */ public function getHandleFromUserId(int $id): string { $key = $this->getEncryptionKey(); $data = sprintf('%010u', $id); return hash_hmac('sha256', $data, $key, false); } /** * Get the user ID from the user handle * * This is a VERY inefficient method. Since the user handle is an HMAC-SHA-256 of the user ID we can't just go * directly from a handle back to an ID. We have to iterate all user IDs, calculate their handles and compare them * to the given handle. * * To prevent a lengthy infinite loop in case of an invalid user handle we don't iterate the entire 2+ billion valid * 32-bit integer range. We load the user IDs of active users (not blocked, not pending activation) and iterate * through them. * * To avoid memory outage on large sites with thousands of active user records we load up to 10000 users at a time. * Each block of 10,000 user IDs takes about 60-80 msec to iterate. On a site with 200,000 active users this method * will take less than 1.5 seconds. This is slow but not impractical, even on crowded shared hosts with a quarter of * the performance of my test subject (a mid-range, shared hosting server). * * @param string|null $userHandle The user handle which will be converted to a user ID. * * @return integer|null * @since 4.2.0 */ public function getUserIdFromHandle(?string $userHandle): ?int { if (empty($userHandle)) { return null; } /** @var DatabaseDriver $db */ $db = $this->getDatabase(); // Check that the userHandle does exist in the database $query = $db->getQuery(true) ->select('COUNT(*)') ->from($db->qn('#__webauthn_credentials')) ->where($db->qn('user_id') . ' = ' . $db->q($userHandle)); try { $numRecords = $db->setQuery($query)->loadResult(); } catch (Exception $e) { return null; } if (is_null($numRecords) || ($numRecords < 1)) { return null; } // Prepare the query $query = $db->getQuery(true) ->select([$db->qn('id')]) ->from($db->qn('#__users')) ->where($db->qn('block') . ' = 0') ->where( '(' . $db->qn('activation') . ' IS NULL OR ' . $db->qn('activation') . ' = 0 OR ' . $db->qn('activation') . ' = ' . $db->q('') . ')' ); $key = $this->getEncryptionKey(); $start = 0; $limit = 10000; while (true) { try { $ids = $db->setQuery($query, $start, $limit)->loadColumn(); } catch (Exception $e) { return null; } if (empty($ids)) { return null; } foreach ($ids as $userId) { $data = sprintf('%010u', $userId); $thisHandle = hash_hmac('sha256', $data, $key, false); if ($thisHandle == $userHandle) { return $userId; } } $start += $limit; } } /** * Encrypt the credential source before saving it to the database * * @param string $credential The unencrypted, JSON-encoded credential source * * @return string The encrypted credential source, base64 encoded * * @since 4.0.0 */ private function encryptCredential(string $credential): string { $key = $this->getEncryptionKey(); if (empty($key)) { return $credential; } $aes = new Aes($key, 256); return $aes->encryptString($credential); } /** * Decrypt the credential source if it was already encrypted in the database * * @param string $credential The encrypted credential source, base64 encoded * * @return string The decrypted, JSON-encoded credential source * * @since 4.0.0 */ private function decryptCredential(string $credential): string { $key = $this->getEncryptionKey(); if (empty($key)) { return $credential; } // Was the credential stored unencrypted (e.g. the site's secret was empty)? if ((strpos($credential, '{') !== false) && (strpos($credential, '"publicKeyCredentialId"') !== false)) { return $credential; } $aes = new Aes($key, 256); return $aes->decryptString($credential); } /** * Get the site's secret, used as an encryption key * * @return string * * @since 4.0.0 */ private function getEncryptionKey(): string { try { $app = Factory::getApplication(); /** @var Registry $config */ $config = $app->getConfig(); $secret = $config->get('secret', ''); } catch (Exception $e) { $secret = ''; } return $secret; } /** * Format a date for display. * * The $tzAware parameter defines whether the formatted date will be timezone-aware. If set to false the formatted * date will be rendered in the UTC timezone. If set to true the code will automatically try to use the logged in * user's timezone or, if none is set, the site's default timezone (Server Timezone). If set to a positive integer * the same thing will happen but for the specified user ID instead of the currently logged in user. * * @param string|\DateTime $date The date to format * @param string|null $format The format string, default is Joomla's DATE_FORMAT_LC6 (usually "Y-m-d * H:i:s") * @param bool $tzAware Should the format be timezone aware? See notes above. * * @return string * @since 4.2.0 */ private function formatDate($date, ?string $format = null, bool $tzAware = true): string { $utcTimeZone = new \DateTimeZone('UTC'); $jDate = new Date($date, $utcTimeZone); // Which timezone should I use? $tz = null; if ($tzAware !== false) { $userId = is_bool($tzAware) ? null : (int) $tzAware; try { $tzDefault = Factory::getApplication()->get('offset'); } catch (\Exception $e) { $tzDefault = 'GMT'; } $user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId ?? 0); $tz = $user->getParam('timezone', $tzDefault); } if (!empty($tz)) { try { $userTimeZone = new \DateTimeZone($tz); $jDate->setTimezone($userTimeZone); } catch (\Exception $e) { // Nothing. Fall back to UTC. } } if (empty($format)) { $format = Text::_('DATE_FORMAT_LC6'); } return $jDate->format($format, true); } }