123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556 |
- <?php
- declare(strict_types=1);
- /**
- * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
- * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
- * SPDX-License-Identifier: AGPL-3.0-only
- */
- namespace OC\IntegrityCheck;
- use OC\Core\Command\Maintenance\Mimetype\GenerateMimetypeFileBuilder;
- use OC\IntegrityCheck\Exceptions\InvalidSignatureException;
- use OC\IntegrityCheck\Helpers\AppLocator;
- use OC\IntegrityCheck\Helpers\EnvironmentHelper;
- use OC\IntegrityCheck\Helpers\FileAccessHelper;
- use OC\IntegrityCheck\Iterator\ExcludeFileByNameFilterIterator;
- use OC\IntegrityCheck\Iterator\ExcludeFoldersByPathFilterIterator;
- use OCP\App\IAppManager;
- use OCP\Files\IMimeTypeDetector;
- use OCP\IAppConfig;
- use OCP\ICache;
- use OCP\ICacheFactory;
- use OCP\IConfig;
- use phpseclib\Crypt\RSA;
- use phpseclib\File\X509;
- /**
- * Class Checker handles the code signing using X.509 and RSA. ownCloud ships with
- * a public root certificate certificate that allows to issue new certificates that
- * will be trusted for signing code. The CN will be used to verify that a certificate
- * given to a third-party developer may not be used for other applications. For
- * example the author of the application "calendar" would only receive a certificate
- * only valid for this application.
- *
- * @package OC\IntegrityCheck
- */
- class Checker {
- public const CACHE_KEY = 'oc.integritycheck.checker';
- private ICache $cache;
- public function __construct(
- private EnvironmentHelper $environmentHelper,
- private FileAccessHelper $fileAccessHelper,
- private AppLocator $appLocator,
- private ?IConfig $config,
- private ?IAppConfig $appConfig,
- ICacheFactory $cacheFactory,
- private IAppManager $appManager,
- private IMimeTypeDetector $mimeTypeDetector,
- ) {
- $this->cache = $cacheFactory->createDistributed(self::CACHE_KEY);
- }
- /**
- * Whether code signing is enforced or not.
- *
- * @return bool
- */
- public function isCodeCheckEnforced(): bool {
- $notSignedChannels = [ '', 'git'];
- if (\in_array($this->environmentHelper->getChannel(), $notSignedChannels, true)) {
- return false;
- }
- /**
- * This config option is undocumented and supposed to be so, it's only
- * applicable for very specific scenarios and we should not advertise it
- * too prominent. So please do not add it to config.sample.php.
- */
- return !($this->config?->getSystemValueBool('integrity.check.disabled', false) ?? false);
- }
- /**
- * Enumerates all files belonging to the folder. Sensible defaults are excluded.
- *
- * @param string $folderToIterate
- * @param string $root
- * @return \RecursiveIteratorIterator
- * @throws \Exception
- */
- private function getFolderIterator(string $folderToIterate, string $root = ''): \RecursiveIteratorIterator {
- $dirItr = new \RecursiveDirectoryIterator(
- $folderToIterate,
- \RecursiveDirectoryIterator::SKIP_DOTS
- );
- if ($root === '') {
- $root = \OC::$SERVERROOT;
- }
- $root = rtrim($root, '/');
- $excludeGenericFilesIterator = new ExcludeFileByNameFilterIterator($dirItr);
- $excludeFoldersIterator = new ExcludeFoldersByPathFilterIterator($excludeGenericFilesIterator, $root);
- return new \RecursiveIteratorIterator(
- $excludeFoldersIterator,
- \RecursiveIteratorIterator::SELF_FIRST
- );
- }
- /**
- * Returns an array of ['filename' => 'SHA512-hash-of-file'] for all files found
- * in the iterator.
- *
- * @param \RecursiveIteratorIterator $iterator
- * @param string $path
- * @return array Array of hashes.
- */
- private function generateHashes(\RecursiveIteratorIterator $iterator,
- string $path): array {
- $hashes = [];
- $baseDirectoryLength = \strlen($path);
- foreach ($iterator as $filename => $data) {
- /** @var \DirectoryIterator $data */
- if ($data->isDir()) {
- continue;
- }
- $relativeFileName = substr($filename, $baseDirectoryLength);
- $relativeFileName = ltrim($relativeFileName, '/');
- // Exclude signature.json files in the appinfo and root folder
- if ($relativeFileName === 'appinfo/signature.json') {
- continue;
- }
- // Exclude signature.json files in the appinfo and core folder
- if ($relativeFileName === 'core/signature.json') {
- continue;
- }
- // The .htaccess file in the root folder of ownCloud can contain
- // custom content after the installation due to the fact that dynamic
- // content is written into it at installation time as well. This
- // includes for example the 404 and 403 instructions.
- // Thus we ignore everything below the first occurrence of
- // "#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####" and have the
- // hash generated based on this.
- if ($filename === $this->environmentHelper->getServerRoot() . '/.htaccess') {
- $fileContent = file_get_contents($filename);
- $explodedArray = explode('#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####', $fileContent);
- if (\count($explodedArray) === 2) {
- $hashes[$relativeFileName] = hash('sha512', $explodedArray[0]);
- continue;
- }
- }
- if ($filename === $this->environmentHelper->getServerRoot() . '/core/js/mimetypelist.js') {
- $oldMimetypeList = new GenerateMimetypeFileBuilder();
- $newFile = $oldMimetypeList->generateFile($this->mimeTypeDetector->getAllAliases());
- $oldFile = $this->fileAccessHelper->file_get_contents($filename);
- if ($newFile === $oldFile) {
- $hashes[$relativeFileName] = hash('sha512', $oldMimetypeList->generateFile($this->mimeTypeDetector->getOnlyDefaultAliases()));
- continue;
- }
- }
- $hashes[$relativeFileName] = hash_file('sha512', $filename);
- }
- return $hashes;
- }
- /**
- * Creates the signature data
- *
- * @param array $hashes
- * @param X509 $certificate
- * @param RSA $privateKey
- * @return array
- */
- private function createSignatureData(array $hashes,
- X509 $certificate,
- RSA $privateKey): array {
- ksort($hashes);
- $privateKey->setSignatureMode(RSA::SIGNATURE_PSS);
- $privateKey->setMGFHash('sha512');
- // See https://tools.ietf.org/html/rfc3447#page-38
- $privateKey->setSaltLength(0);
- $signature = $privateKey->sign(json_encode($hashes));
- return [
- 'hashes' => $hashes,
- 'signature' => base64_encode($signature),
- 'certificate' => $certificate->saveX509($certificate->currentCert),
- ];
- }
- /**
- * Write the signature of the app in the specified folder
- *
- * @param string $path
- * @param X509 $certificate
- * @param RSA $privateKey
- * @throws \Exception
- */
- public function writeAppSignature($path,
- X509 $certificate,
- RSA $privateKey) {
- $appInfoDir = $path . '/appinfo';
- try {
- $this->fileAccessHelper->assertDirectoryExists($appInfoDir);
- $iterator = $this->getFolderIterator($path);
- $hashes = $this->generateHashes($iterator, $path);
- $signature = $this->createSignatureData($hashes, $certificate, $privateKey);
- $this->fileAccessHelper->file_put_contents(
- $appInfoDir . '/signature.json',
- json_encode($signature, JSON_PRETTY_PRINT)
- );
- } catch (\Exception $e) {
- if (!$this->fileAccessHelper->is_writable($appInfoDir)) {
- throw new \Exception($appInfoDir . ' is not writable');
- }
- throw $e;
- }
- }
- /**
- * Write the signature of core
- *
- * @param X509 $certificate
- * @param RSA $rsa
- * @param string $path
- * @throws \Exception
- */
- public function writeCoreSignature(X509 $certificate,
- RSA $rsa,
- $path) {
- $coreDir = $path . '/core';
- try {
- $this->fileAccessHelper->assertDirectoryExists($coreDir);
- $iterator = $this->getFolderIterator($path, $path);
- $hashes = $this->generateHashes($iterator, $path);
- $signatureData = $this->createSignatureData($hashes, $certificate, $rsa);
- $this->fileAccessHelper->file_put_contents(
- $coreDir . '/signature.json',
- json_encode($signatureData, JSON_PRETTY_PRINT)
- );
- } catch (\Exception $e) {
- if (!$this->fileAccessHelper->is_writable($coreDir)) {
- throw new \Exception($coreDir . ' is not writable');
- }
- throw $e;
- }
- }
- /**
- * Split the certificate file in individual certs
- *
- * @param string $cert
- * @return string[]
- */
- private function splitCerts(string $cert): array {
- preg_match_all('([\-]{3,}[\S\ ]+?[\-]{3,}[\S\s]+?[\-]{3,}[\S\ ]+?[\-]{3,})', $cert, $matches);
- return $matches[0];
- }
- /**
- * Verifies the signature for the specified path.
- *
- * @param string $signaturePath
- * @param string $basePath
- * @param string $certificateCN
- * @param bool $forceVerify
- * @return array
- * @throws InvalidSignatureException
- * @throws \Exception
- */
- private function verify(string $signaturePath, string $basePath, string $certificateCN, bool $forceVerify = false): array {
- if (!$forceVerify && !$this->isCodeCheckEnforced()) {
- return [];
- }
- $content = $this->fileAccessHelper->file_get_contents($signaturePath);
- $signatureData = null;
- if (\is_string($content)) {
- $signatureData = json_decode($content, true);
- }
- if (!\is_array($signatureData)) {
- throw new InvalidSignatureException('Signature data not found.');
- }
- $expectedHashes = $signatureData['hashes'];
- ksort($expectedHashes);
- $signature = base64_decode($signatureData['signature']);
- $certificate = $signatureData['certificate'];
- // Check if certificate is signed by Nextcloud Root Authority
- $x509 = new \phpseclib\File\X509();
- $rootCertificatePublicKey = $this->fileAccessHelper->file_get_contents($this->environmentHelper->getServerRoot().'/resources/codesigning/root.crt');
- $rootCerts = $this->splitCerts($rootCertificatePublicKey);
- foreach ($rootCerts as $rootCert) {
- $x509->loadCA($rootCert);
- }
- $x509->loadX509($certificate);
- if (!$x509->validateSignature()) {
- throw new InvalidSignatureException('Certificate is not valid.');
- }
- // Verify if certificate has proper CN. "core" CN is always trusted.
- if ($x509->getDN(X509::DN_OPENSSL)['CN'] !== $certificateCN && $x509->getDN(X509::DN_OPENSSL)['CN'] !== 'core') {
- throw new InvalidSignatureException(
- sprintf('Certificate is not valid for required scope. (Requested: %s, current: CN=%s)', $certificateCN, $x509->getDN(true)['CN'])
- );
- }
- // Check if the signature of the files is valid
- $rsa = new \phpseclib\Crypt\RSA();
- $rsa->loadKey($x509->currentCert['tbsCertificate']['subjectPublicKeyInfo']['subjectPublicKey']);
- $rsa->setSignatureMode(RSA::SIGNATURE_PSS);
- $rsa->setMGFHash('sha512');
- // See https://tools.ietf.org/html/rfc3447#page-38
- $rsa->setSaltLength(0);
- if (!$rsa->verify(json_encode($expectedHashes), $signature)) {
- throw new InvalidSignatureException('Signature could not get verified.');
- }
- // Fixes for the updater as shipped with ownCloud 9.0.x: The updater is
- // replaced after the code integrity check is performed.
- //
- // Due to this reason we exclude the whole updater/ folder from the code
- // integrity check.
- if ($basePath === $this->environmentHelper->getServerRoot()) {
- foreach ($expectedHashes as $fileName => $hash) {
- if (str_starts_with($fileName, 'updater/')) {
- unset($expectedHashes[$fileName]);
- }
- }
- }
- // Compare the list of files which are not identical
- $currentInstanceHashes = $this->generateHashes($this->getFolderIterator($basePath), $basePath);
- $differencesA = array_diff($expectedHashes, $currentInstanceHashes);
- $differencesB = array_diff($currentInstanceHashes, $expectedHashes);
- $differences = array_unique(array_merge($differencesA, $differencesB));
- $differenceArray = [];
- foreach ($differences as $filename => $hash) {
- // Check if file should not exist in the new signature table
- if (!array_key_exists($filename, $expectedHashes)) {
- $differenceArray['EXTRA_FILE'][$filename]['expected'] = '';
- $differenceArray['EXTRA_FILE'][$filename]['current'] = $hash;
- continue;
- }
- // Check if file is missing
- if (!array_key_exists($filename, $currentInstanceHashes)) {
- $differenceArray['FILE_MISSING'][$filename]['expected'] = $expectedHashes[$filename];
- $differenceArray['FILE_MISSING'][$filename]['current'] = '';
- continue;
- }
- // Check if hash does mismatch
- if ($expectedHashes[$filename] !== $currentInstanceHashes[$filename]) {
- $differenceArray['INVALID_HASH'][$filename]['expected'] = $expectedHashes[$filename];
- $differenceArray['INVALID_HASH'][$filename]['current'] = $currentInstanceHashes[$filename];
- continue;
- }
- // Should never happen.
- throw new \Exception('Invalid behaviour in file hash comparison experienced. Please report this error to the developers.');
- }
- return $differenceArray;
- }
- /**
- * Whether the code integrity check has passed successful or not
- *
- * @return bool
- */
- public function hasPassedCheck(): bool {
- $results = $this->getResults();
- if ($results !== null && empty($results)) {
- return true;
- }
- return false;
- }
- /**
- * @return array|null Either the results or null if no results available
- */
- public function getResults(): array|null {
- $cachedResults = $this->cache->get(self::CACHE_KEY);
- if (!\is_null($cachedResults) and $cachedResults !== false) {
- return json_decode($cachedResults, true);
- }
- if ($this->appConfig?->hasKey('core', self::CACHE_KEY, lazy: true)) {
- return $this->appConfig->getValueArray('core', self::CACHE_KEY, lazy: true);
- }
- // No results available
- return null;
- }
- /**
- * Stores the results in the app config as well as cache
- *
- * @param string $scope
- * @param array $result
- */
- private function storeResults(string $scope, array $result) {
- $resultArray = $this->getResults() ?? [];
- unset($resultArray[$scope]);
- if (!empty($result)) {
- $resultArray[$scope] = $result;
- }
- $this->appConfig?->setValueArray('core', self::CACHE_KEY, $resultArray, lazy: true);
- $this->cache->set(self::CACHE_KEY, json_encode($resultArray));
- }
- /**
- *
- * Clean previous results for a proper rescanning. Otherwise
- */
- private function cleanResults() {
- $this->appConfig->deleteKey('core', self::CACHE_KEY);
- $this->cache->remove(self::CACHE_KEY);
- }
- /**
- * Verify the signature of $appId. Returns an array with the following content:
- * [
- * 'FILE_MISSING' =>
- * [
- * 'filename' => [
- * 'expected' => 'expectedSHA512',
- * 'current' => 'currentSHA512',
- * ],
- * ],
- * 'EXTRA_FILE' =>
- * [
- * 'filename' => [
- * 'expected' => 'expectedSHA512',
- * 'current' => 'currentSHA512',
- * ],
- * ],
- * 'INVALID_HASH' =>
- * [
- * 'filename' => [
- * 'expected' => 'expectedSHA512',
- * 'current' => 'currentSHA512',
- * ],
- * ],
- * ]
- *
- * Array may be empty in case no problems have been found.
- *
- * @param string $appId
- * @param string $path Optional path. If none is given it will be guessed.
- * @param bool $forceVerify
- * @return array
- */
- public function verifyAppSignature(string $appId, string $path = '', bool $forceVerify = false): array {
- try {
- if ($path === '') {
- $path = $this->appLocator->getAppPath($appId);
- }
- $result = $this->verify(
- $path . '/appinfo/signature.json',
- $path,
- $appId,
- $forceVerify
- );
- } catch (\Exception $e) {
- $result = [
- 'EXCEPTION' => [
- 'class' => \get_class($e),
- 'message' => $e->getMessage(),
- ],
- ];
- }
- $this->storeResults($appId, $result);
- return $result;
- }
- /**
- * Verify the signature of core. Returns an array with the following content:
- * [
- * 'FILE_MISSING' =>
- * [
- * 'filename' => [
- * 'expected' => 'expectedSHA512',
- * 'current' => 'currentSHA512',
- * ],
- * ],
- * 'EXTRA_FILE' =>
- * [
- * 'filename' => [
- * 'expected' => 'expectedSHA512',
- * 'current' => 'currentSHA512',
- * ],
- * ],
- * 'INVALID_HASH' =>
- * [
- * 'filename' => [
- * 'expected' => 'expectedSHA512',
- * 'current' => 'currentSHA512',
- * ],
- * ],
- * ]
- *
- * Array may be empty in case no problems have been found.
- *
- * @return array
- */
- public function verifyCoreSignature(): array {
- try {
- $result = $this->verify(
- $this->environmentHelper->getServerRoot() . '/core/signature.json',
- $this->environmentHelper->getServerRoot(),
- 'core'
- );
- } catch (\Exception $e) {
- $result = [
- 'EXCEPTION' => [
- 'class' => \get_class($e),
- 'message' => $e->getMessage(),
- ],
- ];
- }
- $this->storeResults('core', $result);
- return $result;
- }
- /**
- * Verify the core code of the instance as well as all applicable applications
- * and store the results.
- */
- public function runInstanceVerification() {
- $this->cleanResults();
- $this->verifyCoreSignature();
- $appIds = $this->appManager->getAllAppsInAppsFolders();
- foreach ($appIds as $appId) {
- // If an application is shipped a valid signature is required
- $isShipped = $this->appManager->isShipped($appId);
- $appNeedsToBeChecked = false;
- if ($isShipped) {
- $appNeedsToBeChecked = true;
- } elseif ($this->fileAccessHelper->file_exists($this->appLocator->getAppPath($appId) . '/appinfo/signature.json')) {
- // Otherwise only if the application explicitly ships a signature.json file
- $appNeedsToBeChecked = true;
- }
- if ($appNeedsToBeChecked) {
- $this->verifyAppSignature($appId);
- }
- }
- }
- }
|