Checker.php 18 KB

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