You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

597 lines
18 KiB

3 years ago
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * @copyright Copyright (c) 2016, ownCloud, Inc.
  5. *
  6. * @author Christoph Wurst <christoph@winzerhof-wurst.at>
  7. * @author Joas Schilling <coding@schilljs.com>
  8. * @author Lukas Reschke <lukas@statuscode.ch>
  9. * @author Morris Jobke <hey@morrisjobke.de>
  10. * @author Roeland Jago Douma <roeland@famdouma.nl>
  11. * @author Victor Dubiniuk <dubiniuk@owncloud.com>
  12. * @author Vincent Petry <pvince81@owncloud.com>
  13. * @author Xheni Myrtaj <myrtajxheni@gmail.com>
  14. *
  15. * @license AGPL-3.0
  16. *
  17. * This code is free software: you can redistribute it and/or modify
  18. * it under the terms of the GNU Affero General Public License, version 3,
  19. * as published by the Free Software Foundation.
  20. *
  21. * This program is distributed in the hope that it will be useful,
  22. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  23. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  24. * GNU Affero General Public License for more details.
  25. *
  26. * You should have received a copy of the GNU Affero General Public License, version 3,
  27. * along with this program. If not, see <http://www.gnu.org/licenses/>
  28. *
  29. */
  30. namespace OC\IntegrityCheck;
  31. use OC\Core\Command\Maintenance\Mimetype\GenerateMimetypeFileBuilder;
  32. use OC\IntegrityCheck\Exceptions\InvalidSignatureException;
  33. use OC\IntegrityCheck\Helpers\AppLocator;
  34. use OC\IntegrityCheck\Helpers\EnvironmentHelper;
  35. use OC\IntegrityCheck\Helpers\FileAccessHelper;
  36. use OC\IntegrityCheck\Iterator\ExcludeFileByNameFilterIterator;
  37. use OC\IntegrityCheck\Iterator\ExcludeFoldersByPathFilterIterator;
  38. use OCP\App\IAppManager;
  39. use OCP\Files\IMimeTypeDetector;
  40. use OCP\ICache;
  41. use OCP\ICacheFactory;
  42. use OCP\IConfig;
  43. use OCP\ITempManager;
  44. use phpseclib\Crypt\RSA;
  45. use phpseclib\File\X509;
  46. /**
  47. * Class Checker handles the code signing using X.509 and RSA. ownCloud ships with
  48. * a public root certificate certificate that allows to issue new certificates that
  49. * will be trusted for signing code. The CN will be used to verify that a certificate
  50. * given to a third-party developer may not be used for other applications. For
  51. * example the author of the application "calendar" would only receive a certificate
  52. * only valid for this application.
  53. *
  54. * @package OC\IntegrityCheck
  55. */
  56. class Checker {
  57. public const CACHE_KEY = 'oc.integritycheck.checker';
  58. /** @var EnvironmentHelper */
  59. private $environmentHelper;
  60. /** @var AppLocator */
  61. private $appLocator;
  62. /** @var FileAccessHelper */
  63. private $fileAccessHelper;
  64. /** @var IConfig */
  65. private $config;
  66. /** @var ICache */
  67. private $cache;
  68. /** @var IAppManager */
  69. private $appManager;
  70. /** @var ITempManager */
  71. private $tempManager;
  72. /** @var IMimeTypeDetector */
  73. private $mimeTypeDetector;
  74. /**
  75. * @param EnvironmentHelper $environmentHelper
  76. * @param FileAccessHelper $fileAccessHelper
  77. * @param AppLocator $appLocator
  78. * @param IConfig $config
  79. * @param ICacheFactory $cacheFactory
  80. * @param IAppManager $appManager
  81. * @param ITempManager $tempManager
  82. * @param IMimeTypeDetector $mimeTypeDetector
  83. */
  84. public function __construct(EnvironmentHelper $environmentHelper,
  85. FileAccessHelper $fileAccessHelper,
  86. AppLocator $appLocator,
  87. IConfig $config = null,
  88. ICacheFactory $cacheFactory,
  89. IAppManager $appManager = null,
  90. ITempManager $tempManager,
  91. IMimeTypeDetector $mimeTypeDetector) {
  92. $this->environmentHelper = $environmentHelper;
  93. $this->fileAccessHelper = $fileAccessHelper;
  94. $this->appLocator = $appLocator;
  95. $this->config = $config;
  96. $this->cache = $cacheFactory->createDistributed(self::CACHE_KEY);
  97. $this->appManager = $appManager;
  98. $this->tempManager = $tempManager;
  99. $this->mimeTypeDetector = $mimeTypeDetector;
  100. }
  101. /**
  102. * Whether code signing is enforced or not.
  103. *
  104. * @return bool
  105. */
  106. public function isCodeCheckEnforced(): bool {
  107. $notSignedChannels = [ '', 'git'];
  108. if (\in_array($this->environmentHelper->getChannel(), $notSignedChannels, true)) {
  109. return false;
  110. }
  111. /**
  112. * This config option is undocumented and supposed to be so, it's only
  113. * applicable for very specific scenarios and we should not advertise it
  114. * too prominent. So please do not add it to config.sample.php.
  115. */
  116. $isIntegrityCheckDisabled = false;
  117. if ($this->config !== null) {
  118. $isIntegrityCheckDisabled = $this->config->getSystemValue('integrity.check.disabled', false);
  119. }
  120. if ($isIntegrityCheckDisabled === true) {
  121. return false;
  122. }
  123. return true;
  124. }
  125. /**
  126. * Enumerates all files belonging to the folder. Sensible defaults are excluded.
  127. *
  128. * @param string $folderToIterate
  129. * @param string $root
  130. * @return \RecursiveIteratorIterator
  131. * @throws \Exception
  132. */
  133. private function getFolderIterator(string $folderToIterate, string $root = ''): \RecursiveIteratorIterator {
  134. $dirItr = new \RecursiveDirectoryIterator(
  135. $folderToIterate,
  136. \RecursiveDirectoryIterator::SKIP_DOTS
  137. );
  138. if ($root === '') {
  139. $root = \OC::$SERVERROOT;
  140. }
  141. $root = rtrim($root, '/');
  142. $excludeGenericFilesIterator = new ExcludeFileByNameFilterIterator($dirItr);
  143. $excludeFoldersIterator = new ExcludeFoldersByPathFilterIterator($excludeGenericFilesIterator, $root);
  144. return new \RecursiveIteratorIterator(
  145. $excludeFoldersIterator,
  146. \RecursiveIteratorIterator::SELF_FIRST
  147. );
  148. }
  149. /**
  150. * Returns an array of ['filename' => 'SHA512-hash-of-file'] for all files found
  151. * in the iterator.
  152. *
  153. * @param \RecursiveIteratorIterator $iterator
  154. * @param string $path
  155. * @return array Array of hashes.
  156. */
  157. private function generateHashes(\RecursiveIteratorIterator $iterator,
  158. string $path): array {
  159. $hashes = [];
  160. $baseDirectoryLength = \strlen($path);
  161. foreach ($iterator as $filename => $data) {
  162. /** @var \DirectoryIterator $data */
  163. if ($data->isDir()) {
  164. continue;
  165. }
  166. $relativeFileName = substr($filename, $baseDirectoryLength);
  167. $relativeFileName = ltrim($relativeFileName, '/');
  168. // Exclude signature.json files in the appinfo and root folder
  169. if ($relativeFileName === 'appinfo/signature.json') {
  170. continue;
  171. }
  172. // Exclude signature.json files in the appinfo and core folder
  173. if ($relativeFileName === 'core/signature.json') {
  174. continue;
  175. }
  176. // The .htaccess file in the root folder of ownCloud can contain
  177. // custom content after the installation due to the fact that dynamic
  178. // content is written into it at installation time as well. This
  179. // includes for example the 404 and 403 instructions.
  180. // Thus we ignore everything below the first occurrence of
  181. // "#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####" and have the
  182. // hash generated based on this.
  183. if ($filename === $this->environmentHelper->getServerRoot() . '/.htaccess') {
  184. $fileContent = file_get_contents($filename);
  185. $explodedArray = explode('#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####', $fileContent);
  186. if (\count($explodedArray) === 2) {
  187. $hashes[$relativeFileName] = hash('sha512', $explodedArray[0]);
  188. continue;
  189. }
  190. }
  191. if ($filename === $this->environmentHelper->getServerRoot() . '/core/js/mimetypelist.js') {
  192. $oldMimetypeList = new GenerateMimetypeFileBuilder();
  193. $newFile = $oldMimetypeList->generateFile($this->mimeTypeDetector->getAllAliases());
  194. if ($newFile === file_get_contents($filename)) {
  195. $hashes[$relativeFileName] = hash('sha512', $oldMimetypeList->generateFile($this->mimeTypeDetector->getOnlyDefaultAliases()));
  196. continue;
  197. }
  198. }
  199. $hashes[$relativeFileName] = hash_file('sha512', $filename);
  200. }
  201. return $hashes;
  202. }
  203. /**
  204. * Creates the signature data
  205. *
  206. * @param array $hashes
  207. * @param X509 $certificate
  208. * @param RSA $privateKey
  209. * @return array
  210. */
  211. private function createSignatureData(array $hashes,
  212. X509 $certificate,
  213. RSA $privateKey): array {
  214. ksort($hashes);
  215. $privateKey->setSignatureMode(RSA::SIGNATURE_PSS);
  216. $privateKey->setMGFHash('sha512');
  217. // See https://tools.ietf.org/html/rfc3447#page-38
  218. $privateKey->setSaltLength(0);
  219. $signature = $privateKey->sign(json_encode($hashes));
  220. return [
  221. 'hashes' => $hashes,
  222. 'signature' => base64_encode($signature),
  223. 'certificate' => $certificate->saveX509($certificate->currentCert),
  224. ];
  225. }
  226. /**
  227. * Write the signature of the app in the specified folder
  228. *
  229. * @param string $path
  230. * @param X509 $certificate
  231. * @param RSA $privateKey
  232. * @throws \Exception
  233. */
  234. public function writeAppSignature($path,
  235. X509 $certificate,
  236. RSA $privateKey) {
  237. $appInfoDir = $path . '/appinfo';
  238. try {
  239. $this->fileAccessHelper->assertDirectoryExists($appInfoDir);
  240. $iterator = $this->getFolderIterator($path);
  241. $hashes = $this->generateHashes($iterator, $path);
  242. $signature = $this->createSignatureData($hashes, $certificate, $privateKey);
  243. $this->fileAccessHelper->file_put_contents(
  244. $appInfoDir . '/signature.json',
  245. json_encode($signature, JSON_PRETTY_PRINT)
  246. );
  247. } catch (\Exception $e) {
  248. if (!$this->fileAccessHelper->is_writable($appInfoDir)) {
  249. throw new \Exception($appInfoDir . ' is not writable');
  250. }
  251. throw $e;
  252. }
  253. }
  254. /**
  255. * Write the signature of core
  256. *
  257. * @param X509 $certificate
  258. * @param RSA $rsa
  259. * @param string $path
  260. * @throws \Exception
  261. */
  262. public function writeCoreSignature(X509 $certificate,
  263. RSA $rsa,
  264. $path) {
  265. $coreDir = $path . '/core';
  266. try {
  267. $this->fileAccessHelper->assertDirectoryExists($coreDir);
  268. $iterator = $this->getFolderIterator($path, $path);
  269. $hashes = $this->generateHashes($iterator, $path);
  270. $signatureData = $this->createSignatureData($hashes, $certificate, $rsa);
  271. $this->fileAccessHelper->file_put_contents(
  272. $coreDir . '/signature.json',
  273. json_encode($signatureData, JSON_PRETTY_PRINT)
  274. );
  275. } catch (\Exception $e) {
  276. if (!$this->fileAccessHelper->is_writable($coreDir)) {
  277. throw new \Exception($coreDir . ' is not writable');
  278. }
  279. throw $e;
  280. }
  281. }
  282. /**
  283. * Verifies the signature for the specified path.
  284. *
  285. * @param string $signaturePath
  286. * @param string $basePath
  287. * @param string $certificateCN
  288. * @return array
  289. * @throws InvalidSignatureException
  290. * @throws \Exception
  291. */
  292. private function verify(string $signaturePath, string $basePath, string $certificateCN): array {
  293. if (!$this->isCodeCheckEnforced()) {
  294. return [];
  295. }
  296. $content = $this->fileAccessHelper->file_get_contents($signaturePath);
  297. $signatureData = null;
  298. if (\is_string($content)) {
  299. $signatureData = json_decode($content, true);
  300. }
  301. if (!\is_array($signatureData)) {
  302. throw new InvalidSignatureException('Signature data not found.');
  303. }
  304. $expectedHashes = $signatureData['hashes'];
  305. ksort($expectedHashes);
  306. $signature = base64_decode($signatureData['signature']);
  307. $certificate = $signatureData['certificate'];
  308. // Check if certificate is signed by Nextcloud Root Authority
  309. $x509 = new \phpseclib\File\X509();
  310. $rootCertificatePublicKey = $this->fileAccessHelper->file_get_contents($this->environmentHelper->getServerRoot().'/resources/codesigning/root.crt');
  311. $x509->loadCA($rootCertificatePublicKey);
  312. $x509->loadX509($certificate);
  313. if (!$x509->validateSignature()) {
  314. throw new InvalidSignatureException('Certificate is not valid.');
  315. }
  316. // Verify if certificate has proper CN. "core" CN is always trusted.
  317. if ($x509->getDN(X509::DN_OPENSSL)['CN'] !== $certificateCN && $x509->getDN(X509::DN_OPENSSL)['CN'] !== 'core') {
  318. throw new InvalidSignatureException(
  319. sprintf('Certificate is not valid for required scope. (Requested: %s, current: CN=%s)', $certificateCN, $x509->getDN(true)['CN'])
  320. );
  321. }
  322. // Check if the signature of the files is valid
  323. $rsa = new \phpseclib\Crypt\RSA();
  324. $rsa->loadKey($x509->currentCert['tbsCertificate']['subjectPublicKeyInfo']['subjectPublicKey']);
  325. $rsa->setSignatureMode(RSA::SIGNATURE_PSS);
  326. $rsa->setMGFHash('sha512');
  327. // See https://tools.ietf.org/html/rfc3447#page-38
  328. $rsa->setSaltLength(0);
  329. if (!$rsa->verify(json_encode($expectedHashes), $signature)) {
  330. throw new InvalidSignatureException('Signature could not get verified.');
  331. }
  332. // Fixes for the updater as shipped with ownCloud 9.0.x: The updater is
  333. // replaced after the code integrity check is performed.
  334. //
  335. // Due to this reason we exclude the whole updater/ folder from the code
  336. // integrity check.
  337. if ($basePath === $this->environmentHelper->getServerRoot()) {
  338. foreach ($expectedHashes as $fileName => $hash) {
  339. if (strpos($fileName, 'updater/') === 0) {
  340. unset($expectedHashes[$fileName]);
  341. }
  342. }
  343. }
  344. // Compare the list of files which are not identical
  345. $currentInstanceHashes = $this->generateHashes($this->getFolderIterator($basePath), $basePath);
  346. $differencesA = array_diff($expectedHashes, $currentInstanceHashes);
  347. $differencesB = array_diff($currentInstanceHashes, $expectedHashes);
  348. $differences = array_unique(array_merge($differencesA, $differencesB));
  349. $differenceArray = [];
  350. foreach ($differences as $filename => $hash) {
  351. // Check if file should not exist in the new signature table
  352. if (!array_key_exists($filename, $expectedHashes)) {
  353. $differenceArray['EXTRA_FILE'][$filename]['expected'] = '';
  354. $differenceArray['EXTRA_FILE'][$filename]['current'] = $hash;
  355. continue;
  356. }
  357. // Check if file is missing
  358. if (!array_key_exists($filename, $currentInstanceHashes)) {
  359. $differenceArray['FILE_MISSING'][$filename]['expected'] = $expectedHashes[$filename];
  360. $differenceArray['FILE_MISSING'][$filename]['current'] = '';
  361. continue;
  362. }
  363. // Check if hash does mismatch
  364. if ($expectedHashes[$filename] !== $currentInstanceHashes[$filename]) {
  365. $differenceArray['INVALID_HASH'][$filename]['expected'] = $expectedHashes[$filename];
  366. $differenceArray['INVALID_HASH'][$filename]['current'] = $currentInstanceHashes[$filename];
  367. continue;
  368. }
  369. // Should never happen.
  370. throw new \Exception('Invalid behaviour in file hash comparison experienced. Please report this error to the developers.');
  371. }
  372. return $differenceArray;
  373. }
  374. /**
  375. * Whether the code integrity check has passed successful or not
  376. *
  377. * @return bool
  378. */
  379. public function hasPassedCheck(): bool {
  380. $results = $this->getResults();
  381. if (empty($results)) {
  382. return true;
  383. }
  384. return false;
  385. }
  386. /**
  387. * @return array
  388. */
  389. public function getResults(): array {
  390. $cachedResults = $this->cache->get(self::CACHE_KEY);
  391. if (!\is_null($cachedResults)) {
  392. return json_decode($cachedResults, true);
  393. }
  394. if ($this->config !== null) {
  395. return json_decode($this->config->getAppValue('core', self::CACHE_KEY, '{}'), true);
  396. }
  397. return [];
  398. }
  399. /**
  400. * Stores the results in the app config as well as cache
  401. *
  402. * @param string $scope
  403. * @param array $result
  404. */
  405. private function storeResults(string $scope, array $result) {
  406. $resultArray = $this->getResults();
  407. unset($resultArray[$scope]);
  408. if (!empty($result)) {
  409. $resultArray[$scope] = $result;
  410. }
  411. if ($this->config !== null) {
  412. $this->config->setAppValue('core', self::CACHE_KEY, json_encode($resultArray));
  413. }
  414. $this->cache->set(self::CACHE_KEY, json_encode($resultArray));
  415. }
  416. /**
  417. *
  418. * Clean previous results for a proper rescanning. Otherwise
  419. */
  420. private function cleanResults() {
  421. $this->config->deleteAppValue('core', self::CACHE_KEY);
  422. $this->cache->remove(self::CACHE_KEY);
  423. }
  424. /**
  425. * Verify the signature of $appId. Returns an array with the following content:
  426. * [
  427. * 'FILE_MISSING' =>
  428. * [
  429. * 'filename' => [
  430. * 'expected' => 'expectedSHA512',
  431. * 'current' => 'currentSHA512',
  432. * ],
  433. * ],
  434. * 'EXTRA_FILE' =>
  435. * [
  436. * 'filename' => [
  437. * 'expected' => 'expectedSHA512',
  438. * 'current' => 'currentSHA512',
  439. * ],
  440. * ],
  441. * 'INVALID_HASH' =>
  442. * [
  443. * 'filename' => [
  444. * 'expected' => 'expectedSHA512',
  445. * 'current' => 'currentSHA512',
  446. * ],
  447. * ],
  448. * ]
  449. *
  450. * Array may be empty in case no problems have been found.
  451. *
  452. * @param string $appId
  453. * @param string $path Optional path. If none is given it will be guessed.
  454. * @return array
  455. */
  456. public function verifyAppSignature(string $appId, string $path = ''): array {
  457. try {
  458. if ($path === '') {
  459. $path = $this->appLocator->getAppPath($appId);
  460. }
  461. $result = $this->verify(
  462. $path . '/appinfo/signature.json',
  463. $path,
  464. $appId
  465. );
  466. } catch (\Exception $e) {
  467. $result = [
  468. 'EXCEPTION' => [
  469. 'class' => \get_class($e),
  470. 'message' => $e->getMessage(),
  471. ],
  472. ];
  473. }
  474. $this->storeResults($appId, $result);
  475. return $result;
  476. }
  477. /**
  478. * Verify the signature of core. Returns an array with the following content:
  479. * [
  480. * 'FILE_MISSING' =>
  481. * [
  482. * 'filename' => [
  483. * 'expected' => 'expectedSHA512',
  484. * 'current' => 'currentSHA512',
  485. * ],
  486. * ],
  487. * 'EXTRA_FILE' =>
  488. * [
  489. * 'filename' => [
  490. * 'expected' => 'expectedSHA512',
  491. * 'current' => 'currentSHA512',
  492. * ],
  493. * ],
  494. * 'INVALID_HASH' =>
  495. * [
  496. * 'filename' => [
  497. * 'expected' => 'expectedSHA512',
  498. * 'current' => 'currentSHA512',
  499. * ],
  500. * ],
  501. * ]
  502. *
  503. * Array may be empty in case no problems have been found.
  504. *
  505. * @return array
  506. */
  507. public function verifyCoreSignature(): array {
  508. try {
  509. $result = $this->verify(
  510. $this->environmentHelper->getServerRoot() . '/core/signature.json',
  511. $this->environmentHelper->getServerRoot(),
  512. 'core'
  513. );
  514. } catch (\Exception $e) {
  515. $result = [
  516. 'EXCEPTION' => [
  517. 'class' => \get_class($e),
  518. 'message' => $e->getMessage(),
  519. ],
  520. ];
  521. }
  522. $this->storeResults('core', $result);
  523. return $result;
  524. }
  525. /**
  526. * Verify the core code of the instance as well as all applicable applications
  527. * and store the results.
  528. */
  529. public function runInstanceVerification() {
  530. $this->cleanResults();
  531. $this->verifyCoreSignature();
  532. $appIds = $this->appLocator->getAllApps();
  533. foreach ($appIds as $appId) {
  534. // If an application is shipped a valid signature is required
  535. $isShipped = $this->appManager->isShipped($appId);
  536. $appNeedsToBeChecked = false;
  537. if ($isShipped) {
  538. $appNeedsToBeChecked = true;
  539. } elseif ($this->fileAccessHelper->file_exists($this->appLocator->getAppPath($appId) . '/appinfo/signature.json')) {
  540. // Otherwise only if the application explicitly ships a signature.json file
  541. $appNeedsToBeChecked = true;
  542. }
  543. if ($appNeedsToBeChecked) {
  544. $this->verifyAppSignature($appId);
  545. }
  546. }
  547. }
  548. }