lib/private/IntegrityCheck/Checker.php
<?php
/**
* @author Lukas Reschke <lukas@statuscode.ch>
* @author Roeland Jago Douma <rullzer@owncloud.com>
* @author Thomas Müller <thomas.mueller@tmit.eu>
* @author Victor Dubiniuk <dubiniuk@owncloud.com>
* @author Vincent Petry <pvince81@owncloud.com>
*
* @copyright Copyright (c) 2018, ownCloud GmbH
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OC\IntegrityCheck;
use OC\IntegrityCheck\Exceptions\InvalidSignatureException;
use OC\IntegrityCheck\Exceptions\MissingSignatureException;
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\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\ITempManager;
use phpseclib3\Crypt\RSA;
use phpseclib3\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';
/** @var EnvironmentHelper */
private $environmentHelper;
/** @var AppLocator */
private $appLocator;
/** @var FileAccessHelper */
private $fileAccessHelper;
/** @var IConfig */
private $config;
/** @var ICache */
private $cache;
/** @var IAppManager */
private $appManager;
/** @var ITempManager */
private $tempManager;
/**
* @param EnvironmentHelper $environmentHelper
* @param FileAccessHelper $fileAccessHelper
* @param AppLocator $appLocator
* @param IConfig $config
* @param ICacheFactory $cacheFactory
* @param IAppManager $appManager
* @param ITempManager $tempManager
*/
public function __construct(
EnvironmentHelper $environmentHelper,
FileAccessHelper $fileAccessHelper,
AppLocator $appLocator,
IConfig $config = null,
ICacheFactory $cacheFactory,
IAppManager $appManager = null,
ITempManager $tempManager
) {
$this->environmentHelper = $environmentHelper;
$this->fileAccessHelper = $fileAccessHelper;
$this->appLocator = $appLocator;
$this->config = $config;
$this->cache = $cacheFactory->create(self::CACHE_KEY);
$this->appManager = $appManager;
$this->tempManager = $tempManager;
}
/**
* Whether code signing is enforced or not.
*
* @return bool
*/
public function isCodeCheckEnforced() {
$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.
*/
$isIntegrityCheckDisabled = $this->getSystemValue('integrity.check.disabled', false);
if ($isIntegrityCheckDisabled === true) {
return false;
}
return true;
}
/**
* Enumerates all files belonging to the folder. Sensible defaults are excluded.
*
* @param string $folderToIterate
* @param string $root
* @return \RecursiveIteratorIterator
* @throws \Exception
*/
private function getFolderIterator($folderToIterate, $root = '') {
$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,
$path
) {
$hashes = [];
$copiedWebserverSettingFiles = false;
$tmpFolder = '';
$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 .user.ini and the .htaccess file of ownCloud can contain some
// custom modifications such as for example the maximum upload size
// to ensure that this will not lead to false positives this will
// copy the file to a temporary folder and reset it to the default
// values.
if ($filename === $this->environmentHelper->getServerRoot() . '/.htaccess'
|| $filename === $this->environmentHelper->getServerRoot() . '/.user.ini') {
if (!$copiedWebserverSettingFiles) {
$tmpFolder = \rtrim($this->tempManager->getTemporaryFolder(), '/');
\copy($this->environmentHelper->getServerRoot() . '/.htaccess', $tmpFolder . '/.htaccess');
\copy($this->environmentHelper->getServerRoot() . '/.user.ini', $tmpFolder . '/.user.ini');
}
}
// The .user.ini file can contain custom modifications to the file size
// as well.
if ($filename === $this->environmentHelper->getServerRoot() . '/.user.ini') {
$fileContent = \file_get_contents($tmpFolder . '/.user.ini');
$hashes[$relativeFileName] = \hash('sha512', $fileContent);
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($tmpFolder . '/.htaccess');
$explodedArray = \explode('#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####', $fileContent);
if (\count($explodedArray) === 2) {
$hashes[$relativeFileName] = \hash('sha512', $explodedArray[0]);
continue;
}
}
$hashes[$relativeFileName] = \hash_file('sha512', $filename);
}
return $hashes;
}
/**
* Creates the signature data
*
* @param array $hashes
* @param array $certificate
* @param RSA $privateKey
* @param X509 $x509,
* @return array
*/
private function createSignatureData(
array $hashes,
array $certificate,
RSA $privateKey,
X509 $x509
) {
\ksort($hashes);
$privateKey = $privateKey
->withMGFHash('sha512')
->withSaltLength(0)
->withPadding(RSA::SIGNATURE_PSS);
$signature = $privateKey->sign(\json_encode($hashes));
return [
'hashes' => $hashes,
'signature' => \base64_encode($signature),
'certificate' => $x509->saveX509($certificate),
];
}
/**
* Write the signature of the app in the specified folder
*
* @param string $path
* @param array $certificate
* @param X509 $x509
* @param RSA $privateKey
* @throws \Exception
*/
public function writeAppSignature(
string $path,
array $certificate,
X509 $x509,
RSA $privateKey
) {
$appInfoDir = $path . '/appinfo';
$this->fileAccessHelper->assertDirectoryExists($path);
$this->fileAccessHelper->assertDirectoryExists($appInfoDir);
$iterator = $this->getFolderIterator($path);
$hashes = $this->generateHashes($iterator, $path);
$signature = $this->createSignatureData($hashes, $certificate, $privateKey, $x509);
try {
$this->fileAccessHelper->file_put_contents(
$appInfoDir . '/signature.json',
\json_encode($signature, JSON_PRETTY_PRINT)
);
} catch (\Exception $e) {
if (!$this->fileAccessHelper->is_writeable($appInfoDir)) {
throw new \Exception($appInfoDir . ' is not writable');
}
throw $e;
}
}
/**
* Write the signature of core
*
* @param string $path
* @param array $certificate
* @param X509 $x509
* @param RSA $privateKey
* @throws \Exception
*/
public function writeCoreSignature(
string $path,
array $certificate,
X509 $x509,
RSA $privateKey
) {
$coreDir = $path . '/core';
$this->fileAccessHelper->assertDirectoryExists($path);
$this->fileAccessHelper->assertDirectoryExists($coreDir);
$iterator = $this->getFolderIterator($path, $path);
$hashes = $this->generateHashes($iterator, $path);
$signatureData = $this->createSignatureData($hashes, $certificate, $privateKey, $x509);
try {
$this->fileAccessHelper->file_put_contents(
$coreDir . '/signature.json',
\json_encode($signatureData, JSON_PRETTY_PRINT)
);
} catch (\Exception $e) {
if (!$this->fileAccessHelper->is_writeable($coreDir)) {
throw new \Exception($coreDir . ' is not writable');
}
throw $e;
}
}
/**
* Verifies the signature for the specified path.
*
* @param string $signaturePath
* @param string $basePath
* @param string $certificateCN
* @param boolean $force
* @return array
* @throws InvalidSignatureException
* @throws MissingSignatureException
* @throws \Exception
*/
private function verify($signaturePath, $basePath, $certificateCN, $force = false) {
if (!$force && !$this->isCodeCheckEnforced()) {
return [];
}
$signatureData = \json_decode($this->fileAccessHelper->file_get_contents($signaturePath), true);
if (!\is_array($signatureData)) {
throw new MissingSignatureException('Signature data not found.');
}
$expectedHashes = $signatureData['hashes'];
\ksort($expectedHashes);
$signature = \base64_decode($signatureData['signature']);
$certificate = $signatureData['certificate'];
// Check if certificate is signed by ownCloud Root Authority
$x509 = new \phpseclib3\File\X509();
$rootCertificatePublicKey = $this->fileAccessHelper->file_get_contents($this->environmentHelper->getServerRoot().'/resources/codesigning/root.crt');
$x509->loadCA($rootCertificatePublicKey);
$loadedCertificate = $x509->loadX509($certificate);
if (!$x509->validateSignature()) {
throw new InvalidSignatureException('App Certificate is not valid.');
}
// Check if the certificate has been revoked
$crlFileContent = $this->fileAccessHelper->file_get_contents($this->environmentHelper->getServerRoot().'/resources/codesigning/intermediate.crl.pem');
if ($crlFileContent && \strlen($crlFileContent) > 0) {
$crl = new \phpseclib3\File\X509();
$crl->loadCA($rootCertificatePublicKey);
$crl->loadCRL($crlFileContent);
if (!$crl->validateSignature()) {
throw new InvalidSignatureException('Certificate Revocation List is not valid.');
}
// Get the certificate's serial number.
$csn = $loadedCertificate['tbsCertificate']['serialNumber']->toString();
// Check certificate revocation status.
$revoked = $crl->getRevoked($csn);
if ($revoked) {
throw new InvalidSignatureException('Certificate has been revoked.');
}
}
// 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') {
$cn = $x509->getDN(true)['CN'];
throw new InvalidSignatureException(
"Certificate is not valid for required scope. (Requested: $certificateCN, current: CN=$cn)"
);
}
/** @phan-suppress-next-line PhanUndeclaredMethod */
$rsa = RSA::load($loadedCertificate['tbsCertificate']['subjectPublicKeyInfo']['subjectPublicKey'])
->withHash('sha1')
->withMGFHash('sha512')
->withPadding(RSA::SIGNATURE_PSS)
->withSaltLength(0);
if (!$rsa->verify(\json_encode($expectedHashes), $signature)) {
throw new InvalidSignatureException('Signature could not get verified.');
}
//Exclude files which shouldn't fall for comparison
$excludeFiles = $this->getSystemValue('integrity.excluded.files', []);
// 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_merge($differencesA, $differencesB);
$differenceArray = [];
foreach ($differences as $filename => $hash) {
//If filename in exclude files list, then ignore it
if (\in_array($filename, $excludeFiles, true)) {
continue;
}
// 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() {
$results = $this->getResults();
if (empty($results)) {
return true;
}
return false;
}
/**
* @return array
*/
public function getResults() {
$cachedResults = $this->cache->get(self::CACHE_KEY);
if ($cachedResults !== null) {
return \json_decode($cachedResults, true);
}
return \json_decode($this->getAppValue(self::CACHE_KEY, '{}'), true);
}
/**
* Stores the results in the app config as well as cache
*
* @param string $scope
* @param array $result
*/
private function storeResults($scope, array $result) {
$resultArray = $this->getResults();
unset($resultArray[$scope]);
if (!empty($result)) {
$resultArray[$scope] = $result;
}
$this->setAppValue(self::CACHE_KEY, \json_encode($resultArray));
//Set cache for each app
$this->cache->set($scope, \json_encode($resultArray));
$this->cache->set(self::CACHE_KEY, \json_encode($resultArray));
}
/**
*
* Clean previous results for a proper rescanning. Otherwise
*/
private function cleanResults() {
$this->deleteAppValue(self::CACHE_KEY);
$this->cache->remove(self::CACHE_KEY);
}
/**
* Sanity wrapper for getSystemValue
* @param string $key
* @param string $default
* @return string
*/
private function getSystemValue($key, $default = '') {
if ($this->config !== null) {
return $this->config->getSystemValue($key, $default);
}
return $default;
}
/**
* Get a list of apps that are allowed to have no signature.json
* @return string[]
*/
private function getIgnoredUnsignedApps() {
$ignoredUnsignedApps = $this->getSystemValue(
'integrity.ignore.missing.app.signature',
[]
);
if (\is_array($ignoredUnsignedApps)===false) {
$ignoredUnsignedApps = [];
}
return $ignoredUnsignedApps;
}
/**
* Sanity wrapper for getAppValue
* @param string $key
* @param string $default
* @return string
*/
private function getAppValue($key, $default = '') {
if ($this->config !== null) {
return $this->config->getAppValue('core', $key, $default);
}
return $default;
}
/**
* Sanity wrapper for setAppValue
* @param string $key
* @param string $value
*/
private function setAppValue($key, $value) {
if ($this->config !== null) {
$this->config->setAppValue('core', $key, $value);
}
}
/**
* Sanity wrapper for deleteAppValue
* @param string $key
*/
private function deleteAppValue($key) {
if ($this->config !== null) {
$this->config->deleteAppValue('core', $key);
}
}
/**
* Get the verified apps from the cache, if the result is cached.
* If the app result is not cached, then verification result will get cached
* and then returned.
*
* The reason for introducing this method:
* verifyAppSignature() internally calls verify() which does call phpseclib
* routines like validateSignature(). validateSignature is taking lot of memory.
* Hence its better to cache the results to avoid huge memory consumption.
*
* @param string $appId
* @param string $path Optional path. If none is given it will be guessed.
* @param bool $force force check even if disabled
* @return array
*/
public function getVerifiedAppsFromCache($appId, $path = '', $force = false) {
$cacheVal = $this->cache->get($appId);
if ($cacheVal !== null) {
return $cacheVal;
}
return $this->verifyAppSignature($appId, $path, $force);
}
/**
* 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 boolean $force force check even if disabled
* @return array
*/
public function verifyAppSignature($appId, $path = '', $force = false) {
try {
if ($path === '') {
$path = $this->appLocator->getAppPath($appId);
}
$result = $this->verify(
$path . '/appinfo/signature.json',
$path,
$appId,
$force
);
} catch (MissingSignatureException $e) {
if (!\in_array($appId, $this->getIgnoredUnsignedApps())) {
$result = [
'EXCEPTION' => [
'class' => \get_class($e),
'message' => $e->getMessage(),
],
];
} else {
$result = [];
}
} 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() {
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();
// FIXME: appManager === null means ownCloud is not installed. We check all apps in this case
$forceCheckAllApps = $this->appManager === null;
$appIds = $this->appLocator->getAllApps();
foreach ($appIds as $appId) {
// If an application is shipped a valid signature is required
$isShipped = $forceCheckAllApps || $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);
}
}
}
}