mirror of
https://github.com/nextcloud/server.git
synced 2024-12-29 00:18:42 +00:00
606241caeb
Signed-off-by: Julius Knorr <jus@bitgrid.net>
559 lines
17 KiB
PHP
559 lines
17 KiB
PHP
<?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());
|
|
$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 {
|
|
$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);
|
|
}
|
|
}
|
|
}
|
|
}
|