Checker.php 17 KB

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