Checker.php 18 KB

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