diff --git a/administrator/components/com_admin/sql/updates/mysql/4.2.0-2022-03-20.sql b/administrator/components/com_admin/sql/updates/mysql/4.2.0-2022-03-20.sql new file mode 100644 index 0000000000000..ff16bbaef2dfd --- /dev/null +++ b/administrator/components/com_admin/sql/updates/mysql/4.2.0-2022-03-20.sql @@ -0,0 +1,18 @@ +-- +-- Table structure for table `#__tuf_metadata` +-- + +CREATE TABLE IF NOT EXISTS `#__tuf_metadata` ( + `id` int NOT NULL AUTO_INCREMENT, + `extension_id` int DEFAULT 0, + `root_json` text DEFAULT NULL, + `target_json` text DEFAULT NULL, + `snapshot_json` text DEFAULT NULL, + `timestamp_json` text DEFAULT NULL, + `mirrors_json` text DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci COMMENT='Secure TUF Updates'; + +-- -------------------------------------------------------- +INSERT INTO `#__tuf_metadata` (`extension_id`, `root_json`) +SELECT `extension_id`, '{"keytype": "ed25519", "scheme": "ed25519", "keyid": "02c3130c26fb3fe13fda279d578f3bc251f2ca3a42e5878de063e0ee345533c9", "keyid_hash_algorithms": ["sha256", "sha512"], "keyval": {"public": "f813a2882b305389cac36a9b8ebee7576ba7a7de671d2617074b03c12fb003aa", "private": "b7cb4fab28bae035a6fc5d46736e6f2d10ea4ef943e6aace8c637c1fd141ac72"}}' FROM `#__extensions` WHERE `type`='file' AND `element`='joomla'; diff --git a/composer.json b/composer.json index b36efc65d95b3..423c707d04e9a 100644 --- a/composer.json +++ b/composer.json @@ -94,6 +94,7 @@ "web-auth/webauthn-lib": "2.1.*", "composer/ca-bundle": "^1.2", "dragonmantank/cron-expression": "^3.1", + "symfony/validator": "^5.4", "enshrined/svg-sanitize": "^0.15.4" }, "require-dev": { diff --git a/installation/sql/mysql/base.sql b/installation/sql/mysql/base.sql index c0efa2026b9b9..3631993813fd7 100644 --- a/installation/sql/mysql/base.sql +++ b/installation/sql/mysql/base.sql @@ -843,6 +843,29 @@ CREATE TABLE IF NOT EXISTS `#__updates` ( -- -------------------------------------------------------- +-- +-- Table structure for table `#__tuf_updates` +-- + +CREATE TABLE IF NOT EXISTS `#__tuf_metadata` ( + `id` int NOT NULL AUTO_INCREMENT, + `extension_id` int DEFAULT 0, + `root_json` text DEFAULT NULL, + `targets_json` text DEFAULT NULL, + `snapshot_json` text DEFAULT NULL, + `timestamp_json` text DEFAULT NULL, + `mirrors_json` text DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci COMMENT='Secure TUF Updates'; + +-- +-- Dumping data for table `#__tuf_metadata` +-- +INSERT INTO `#__tuf_metadata` (`extension_id`, `root_json`) +SELECT `extension_id`, '{"keytype": "ed25519", "scheme": "ed25519", "keyid": "02c3130c26fb3fe13fda279d578f3bc251f2ca3a42e5878de063e0ee345533c9", "keyid_hash_algorithms": ["sha256", "sha512"], "keyval": {"public": "f813a2882b305389cac36a9b8ebee7576ba7a7de671d2617074b03c12fb003aa", "private": "b7cb4fab28bae035a6fc5d46736e6f2d10ea4ef943e6aace8c637c1fd141ac72"}}' FROM `#__extensions` WHERE `type`='file' AND `element`='joomla'; + +-- -------------------------------------------------------- + -- -- Table structure for table `#__update_sites` -- diff --git a/libraries/src/TUF/DatabaseStorage.php b/libraries/src/TUF/DatabaseStorage.php new file mode 100644 index 0000000000000..253cec551ae69 --- /dev/null +++ b/libraries/src/TUF/DatabaseStorage.php @@ -0,0 +1,109 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\CMS\TUF; + +use Joomla\CMS\Table\Table; +use Joomla\CMS\Table\Tuf; +use Joomla\CMS\TUF\Exception\RoleNotFoundException; +use Joomla\Database\DatabaseDriver; + +\defined('JPATH_PLATFORM') or die; + +/** + * @since __DEPLOY_VERSION__ + */ +class DatabaseStorage implements \ArrayAccess +{ + /** + * The Tuf table object + * + * @var Table + */ + protected Table $table; + + /** + * Initialize the DatabaseStorage class + * + * @param DatabaseDriver $db + * @param integer $extensionId + */ + public function __construct(DatabaseDriver $db, int $extensionId) + { + $this->table = new Tuf($db); + + $this->table->load($extensionId); + } + + /** + * {@inheritdoc} + */ + public function offsetExists(mixed $offset): bool + { + $column = $this->getCleanColumn($offset); + + return substr($offset, -5) === '_json' && $this->table->hasField($column) && strlen($this->table->$column); + } + + /** + * {@inheritdoc} + */ + public function offsetGet($offset): mixed + { + if (!$this->offsetExists($offset)) + { + throw new RoleNotFoundException; + } + + $column = $this->getCleanColumn($offset); + + return $this->table->$column; + } + + /** + * {@inheritdoc} + */ + public function offsetSet($offset, $value): void + { + if (!$this->offsetExists($offset)) + { + throw new RoleNotFoundException; + } + + $this->table->$offset = $value; + + $this->table->store(); + } + + /** + * {@inheritdoc} + */ + public function offsetUnset($offset): void + { + if (!$this->offsetExists($offset)) + { + throw new RoleNotFoundException; + } + + $this->table->$offset = ''; + + $this->table->store(); + } + + /** + * Convert file names to table columns + * + * @param string $name + * + * @return string + */ + protected function getCleanColumn($name): string + { + return str_replace('.', '_', $name); + } +} diff --git a/libraries/src/TUF/Exception/RoleNotFoundException.php b/libraries/src/TUF/Exception/RoleNotFoundException.php new file mode 100644 index 0000000000000..fa90a25015843 --- /dev/null +++ b/libraries/src/TUF/Exception/RoleNotFoundException.php @@ -0,0 +1,20 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\CMS\TUF\Exception; + +\defined('JPATH_PLATFORM') or die; + +/** + * Exception class defining that the Role could not be found + * + * @since __DEPLOY_VERSION__ + */ +class RoleNotFoundException extends \Exception +{ +} diff --git a/libraries/src/TUF/TufValidation.php b/libraries/src/TUF/TufValidation.php new file mode 100644 index 0000000000000..521d11561f8b7 --- /dev/null +++ b/libraries/src/TUF/TufValidation.php @@ -0,0 +1,151 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\CMS\TUF; + +use Joomla\CMS\Factory; +use Joomla\Database\DatabaseDriver; +use Joomla\Database\ParameterType; +use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; +use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Tuf\Client\GuzzleFileFetcher; +use Tuf\Client\Updater; +use Tuf\Exception\Attack\FreezeAttackException; +use Tuf\Exception\Attack\RollbackAttackException; +use Tuf\Exception\Attack\SignatureThresholdException; +use Tuf\Exception\MetadataException; +use Tuf\JsonNormalizer; + +\defined('JPATH_PLATFORM') or die; + +/** + * @since __DEPLOY_VERSION__ + */ +class TufValidation +{ + /** + * The id of the extension to be updated + * + * @var integer + */ + private int $extensionId; + + /** + * The params of the validator + * + * @var mixed + */ + private mixed $params; + + /** + * Validating updates with TUF + * + * @param integer $extensionId The ID of the extension to be checked + * @param mixed $params The parameters containing the Base-URI, the Metadata- and Targets-Path and mirrors for + * the update + */ + public function __construct(int $extensionId, mixed $params) + { + $this->extensionId = $extensionId; + + $resolver = new OptionsResolver; + + try + { + $this->configureTufOptions($resolver); + } + catch (\Exception) + { + } + + try + { + $params = $resolver->resolve($params); + } + catch (\Exception $e) + { + if ($e instanceof UndefinedOptionsException || $e instanceof InvalidOptionsException) + { + throw $e; + } + } + + $this->params = $params; + } + + /** + * Configures default values or pass arguments to params + * + * @param OptionsResolver $resolver The OptionsResolver for the params + * @return void + */ + protected function configureTufOptions(OptionsResolver $resolver) + { + $resolver->setDefaults( + [ + 'url_prefix' => 'https://raw.githubusercontent.com', + 'metadata_path' => '/joomla/updates/test/repository/', + 'targets_path' => '/targets/', + 'mirrors' => [], + ] + ) + ->setAllowedTypes('url_prefix', 'string') + ->setAllowedTypes('metadata_path', 'string') + ->setAllowedTypes('targets_path', 'string') + ->setAllowedTypes('mirrors', 'array'); + } + + /** + * Checks for updates and writes it into the database if they are valid. Then it gets the targets.json content and + * returns it + * + * @return mixed Returns the targets.json if the validation is successful, otherwise null + */ + public function getValidUpdate(): mixed + { + $db = Factory::getContainer()->get(DatabaseDriver::class); + + // $db = Factory::getDbo(); + + $fileFetcher = GuzzleFileFetcher::createFromUri($this->params['url_prefix'], $this->params['metadata_path'], $this->params['targets_path']); + $updater = new Updater( + $fileFetcher, + $this->params['mirrors'], + new DatabaseStorage($db, $this->extensionId) + ); + + try + { + // Refresh the data if needed, it will be written inside the DB, then we fetch it afterwards and return it to + // the caller + $updater->refresh(); + $query = $db->getQuery(true) + ->select('targets_json') + ->from($db->quoteName('#__tuf_metadata', 'map')) + ->where($db->quoteName('map.id') . ' = :id') + ->bind(':id', $this->extensionId, ParameterType::INTEGER); + $db->setQuery($query); + + $resultArray = (array) $db->loadObject(); + + return JsonNormalizer::decode($resultArray['targets_json']); + } + catch (FreezeAttackException | MetadataException | SignatureThresholdException | RollbackAttackException $e) + { + // When the validation fails, for example when one file is written but the others don't, we roll back everything + // and cancel the update + $query = $db->getQuery(true) + ->delete('#__tuf_metadata') + ->columns(['snapshot_json', 'targets_json', 'timestamp_json']); + $db->setQuery($query); + + return null; + } + } +} diff --git a/libraries/src/Table/Tuf.php b/libraries/src/Table/Tuf.php new file mode 100644 index 0000000000000..0d4b7f9d17890 --- /dev/null +++ b/libraries/src/Table/Tuf.php @@ -0,0 +1,31 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\CMS\Table; + +\defined('JPATH_PLATFORM') or die; + +/** + * TUF map table + * + * @since __DEPLOY_VERSION__ + */ +class Tuf extends Table +{ + /** + * Constructor + * + * @param \Joomla\Database\DatabaseDriver $db A database connector object + * + * @since __DEPLOY_VERSION__ + */ + public function __construct($db) + { + parent::__construct('#__tuf_metadata', 'id', $db); + } +}