Checker.php 17 KB

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