aboutsummaryrefslogtreecommitdiffstats
path: root/lib/private/IntegrityCheck/Checker.php
diff options
context:
space:
mode:
Diffstat (limited to 'lib/private/IntegrityCheck/Checker.php')
-rw-r--r--lib/private/IntegrityCheck/Checker.php558
1 files changed, 558 insertions, 0 deletions
diff --git a/lib/private/IntegrityCheck/Checker.php b/lib/private/IntegrityCheck/Checker.php
new file mode 100644
index 00000000000..2bd6e426b79
--- /dev/null
+++ b/lib/private/IntegrityCheck/Checker.php
@@ -0,0 +1,558 @@
+<?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 OCP\ServerVersion;
+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 ServerVersion $serverVersion,
+ 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->serverVersion->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(), $this->mimeTypeDetector->getAllNamings());
+ $oldFile = $this->fileAccessHelper->file_get_contents($filename);
+ if ($newFile === $oldFile) {
+ $hashes[$relativeFileName] = hash('sha512', $oldMimetypeList->generateFile($this->mimeTypeDetector->getOnlyDefaultAliases(), $this->mimeTypeDetector->getAllNamings()));
+ 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_assoc($expectedHashes, $currentInstanceHashes);
+ $differencesB = array_diff_assoc($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 {
+ $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);
+ }
+ }
+ }
+}