Checker.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. <?php
  2. /**
  3. * @copyright Copyright (c) 2016, ownCloud, Inc.
  4. *
  5. * @author Lukas Reschke <lukas@statuscode.ch>
  6. * @author Roeland Jago Douma <roeland@famdouma.nl>
  7. * @author Vincent Petry <pvince81@owncloud.com>
  8. *
  9. * @license AGPL-3.0
  10. *
  11. * This code is free software: you can redistribute it and/or modify
  12. * it under the terms of the GNU Affero General Public License, version 3,
  13. * as published by the Free Software Foundation.
  14. *
  15. * This program is distributed in the hope that it will be useful,
  16. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  17. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  18. * GNU Affero General Public License for more details.
  19. *
  20. * You should have received a copy of the GNU Affero General Public License, version 3,
  21. * along with this program. If not, see <http://www.gnu.org/licenses/>
  22. *
  23. */
  24. namespace OC\IntegrityCheck;
  25. use OC\IntegrityCheck\Exceptions\InvalidSignatureException;
  26. use OC\IntegrityCheck\Helpers\AppLocator;
  27. use OC\IntegrityCheck\Helpers\EnvironmentHelper;
  28. use OC\IntegrityCheck\Helpers\FileAccessHelper;
  29. use OC\IntegrityCheck\Iterator\ExcludeFileByNameFilterIterator;
  30. use OC\IntegrityCheck\Iterator\ExcludeFoldersByPathFilterIterator;
  31. use OCP\App\IAppManager;
  32. use OCP\ICache;
  33. use OCP\ICacheFactory;
  34. use OCP\IConfig;
  35. use OCP\ITempManager;
  36. use phpseclib\Crypt\RSA;
  37. use phpseclib\File\X509;
  38. /**
  39. * Class Checker handles the code signing using X.509 and RSA. ownCloud ships with
  40. * a public root certificate certificate that allows to issue new certificates that
  41. * will be trusted for signing code. The CN will be used to verify that a certificate
  42. * given to a third-party developer may not be used for other applications. For
  43. * example the author of the application "calendar" would only receive a certificate
  44. * only valid for this application.
  45. *
  46. * @package OC\IntegrityCheck
  47. */
  48. class Checker {
  49. const CACHE_KEY = 'oc.integritycheck.checker';
  50. /** @var EnvironmentHelper */
  51. private $environmentHelper;
  52. /** @var AppLocator */
  53. private $appLocator;
  54. /** @var FileAccessHelper */
  55. private $fileAccessHelper;
  56. /** @var IConfig */
  57. private $config;
  58. /** @var ICache */
  59. private $cache;
  60. /** @var IAppManager */
  61. private $appManager;
  62. /** @var ITempManager */
  63. private $tempManager;
  64. /**
  65. * @param EnvironmentHelper $environmentHelper
  66. * @param FileAccessHelper $fileAccessHelper
  67. * @param AppLocator $appLocator
  68. * @param IConfig $config
  69. * @param ICacheFactory $cacheFactory
  70. * @param IAppManager $appManager
  71. * @param ITempManager $tempManager
  72. */
  73. public function __construct(EnvironmentHelper $environmentHelper,
  74. FileAccessHelper $fileAccessHelper,
  75. AppLocator $appLocator,
  76. IConfig $config = null,
  77. ICacheFactory $cacheFactory,
  78. IAppManager $appManager = null,
  79. ITempManager $tempManager) {
  80. $this->environmentHelper = $environmentHelper;
  81. $this->fileAccessHelper = $fileAccessHelper;
  82. $this->appLocator = $appLocator;
  83. $this->config = $config;
  84. $this->cache = $cacheFactory->create(self::CACHE_KEY);
  85. $this->appManager = $appManager;
  86. $this->tempManager = $tempManager;
  87. }
  88. /**
  89. * Whether code signing is enforced or not.
  90. *
  91. * @return bool
  92. */
  93. public function isCodeCheckEnforced() {
  94. $signedChannels = [
  95. 'daily',
  96. 'testing',
  97. 'stable',
  98. ];
  99. if(!in_array($this->environmentHelper->getChannel(), $signedChannels, true)) {
  100. return false;
  101. }
  102. /**
  103. * This config option is undocumented and supposed to be so, it's only
  104. * applicable for very specific scenarios and we should not advertise it
  105. * too prominent. So please do not add it to config.sample.php.
  106. */
  107. if ($this->config !== null) {
  108. $isIntegrityCheckDisabled = $this->config->getSystemValue('integrity.check.disabled', false);
  109. } else {
  110. $isIntegrityCheckDisabled = false;
  111. }
  112. if($isIntegrityCheckDisabled === true) {
  113. return false;
  114. }
  115. return true;
  116. }
  117. /**
  118. * Enumerates all files belonging to the folder. Sensible defaults are excluded.
  119. *
  120. * @param string $folderToIterate
  121. * @param string $root
  122. * @return \RecursiveIteratorIterator
  123. * @throws \Exception
  124. */
  125. private function getFolderIterator($folderToIterate, $root = '') {
  126. $dirItr = new \RecursiveDirectoryIterator(
  127. $folderToIterate,
  128. \RecursiveDirectoryIterator::SKIP_DOTS
  129. );
  130. if($root === '') {
  131. $root = \OC::$SERVERROOT;
  132. }
  133. $root = rtrim($root, '/');
  134. $excludeGenericFilesIterator = new ExcludeFileByNameFilterIterator($dirItr);
  135. $excludeFoldersIterator = new ExcludeFoldersByPathFilterIterator($excludeGenericFilesIterator, $root);
  136. return new \RecursiveIteratorIterator(
  137. $excludeFoldersIterator,
  138. \RecursiveIteratorIterator::SELF_FIRST
  139. );
  140. }
  141. /**
  142. * Returns an array of ['filename' => 'SHA512-hash-of-file'] for all files found
  143. * in the iterator.
  144. *
  145. * @param \RecursiveIteratorIterator $iterator
  146. * @param string $path
  147. * @return array Array of hashes.
  148. */
  149. private function generateHashes(\RecursiveIteratorIterator $iterator,
  150. $path) {
  151. $hashes = [];
  152. $copiedWebserverSettingFiles = false;
  153. $tmpFolder = '';
  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 .user.ini and the .htaccess file of ownCloud can contain some
  171. // custom modifications such as for example the maximum upload size
  172. // to ensure that this will not lead to false positives this will
  173. // copy the file to a temporary folder and reset it to the default
  174. // values.
  175. if($filename === $this->environmentHelper->getServerRoot() . '/.htaccess'
  176. || $filename === $this->environmentHelper->getServerRoot() . '/.user.ini') {
  177. if(!$copiedWebserverSettingFiles) {
  178. $tmpFolder = rtrim($this->tempManager->getTemporaryFolder(), '/');
  179. copy($this->environmentHelper->getServerRoot() . '/.htaccess', $tmpFolder . '/.htaccess');
  180. copy($this->environmentHelper->getServerRoot() . '/.user.ini', $tmpFolder . '/.user.ini');
  181. \OC_Files::setUploadLimit(
  182. \OCP\Util::computerFileSize('511MB'),
  183. [
  184. '.htaccess' => $tmpFolder . '/.htaccess',
  185. '.user.ini' => $tmpFolder . '/.user.ini',
  186. ]
  187. );
  188. }
  189. }
  190. // The .user.ini file can contain custom modifications to the file size
  191. // as well.
  192. if($filename === $this->environmentHelper->getServerRoot() . '/.user.ini') {
  193. $fileContent = file_get_contents($tmpFolder . '/.user.ini');
  194. $hashes[$relativeFileName] = hash('sha512', $fileContent);
  195. continue;
  196. }
  197. // The .htaccess file in the root folder of ownCloud can contain
  198. // custom content after the installation due to the fact that dynamic
  199. // content is written into it at installation time as well. This
  200. // includes for example the 404 and 403 instructions.
  201. // Thus we ignore everything below the first occurrence of
  202. // "#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####" and have the
  203. // hash generated based on this.
  204. if($filename === $this->environmentHelper->getServerRoot() . '/.htaccess') {
  205. $fileContent = file_get_contents($tmpFolder . '/.htaccess');
  206. $explodedArray = explode('#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####', $fileContent);
  207. if(count($explodedArray) === 2) {
  208. $hashes[$relativeFileName] = hash('sha512', $explodedArray[0]);
  209. continue;
  210. }
  211. }
  212. $hashes[$relativeFileName] = hash_file('sha512', $filename);
  213. }
  214. return $hashes;
  215. }
  216. /**
  217. * Creates the signature data
  218. *
  219. * @param array $hashes
  220. * @param X509 $certificate
  221. * @param RSA $privateKey
  222. * @return string
  223. */
  224. private function createSignatureData(array $hashes,
  225. X509 $certificate,
  226. RSA $privateKey) {
  227. ksort($hashes);
  228. $privateKey->setSignatureMode(RSA::SIGNATURE_PSS);
  229. $privateKey->setMGFHash('sha512');
  230. // See https://tools.ietf.org/html/rfc3447#page-38
  231. $privateKey->setSaltLength(0);
  232. $signature = $privateKey->sign(json_encode($hashes));
  233. return [
  234. 'hashes' => $hashes,
  235. 'signature' => base64_encode($signature),
  236. 'certificate' => $certificate->saveX509($certificate->currentCert),
  237. ];
  238. }
  239. /**
  240. * Write the signature of the app in the specified folder
  241. *
  242. * @param string $path
  243. * @param X509 $certificate
  244. * @param RSA $privateKey
  245. * @throws \Exception
  246. */
  247. public function writeAppSignature($path,
  248. X509 $certificate,
  249. RSA $privateKey) {
  250. if(!is_dir($path)) {
  251. throw new \Exception('Directory does not exist.');
  252. }
  253. $iterator = $this->getFolderIterator($path);
  254. $hashes = $this->generateHashes($iterator, $path);
  255. $signature = $this->createSignatureData($hashes, $certificate, $privateKey);
  256. $this->fileAccessHelper->file_put_contents(
  257. $path . '/appinfo/signature.json',
  258. json_encode($signature, JSON_PRETTY_PRINT)
  259. );
  260. }
  261. /**
  262. * Write the signature of core
  263. *
  264. * @param X509 $certificate
  265. * @param RSA $rsa
  266. * @param string $path
  267. */
  268. public function writeCoreSignature(X509 $certificate,
  269. RSA $rsa,
  270. $path) {
  271. $iterator = $this->getFolderIterator($path, $path);
  272. $hashes = $this->generateHashes($iterator, $path);
  273. $signatureData = $this->createSignatureData($hashes, $certificate, $rsa);
  274. $this->fileAccessHelper->file_put_contents(
  275. $path . '/core/signature.json',
  276. json_encode($signatureData, JSON_PRETTY_PRINT)
  277. );
  278. }
  279. /**
  280. * Verifies the signature for the specified path.
  281. *
  282. * @param string $signaturePath
  283. * @param string $basePath
  284. * @param string $certificateCN
  285. * @return array
  286. * @throws InvalidSignatureException
  287. * @throws \Exception
  288. */
  289. private function verify($signaturePath, $basePath, $certificateCN) {
  290. if(!$this->isCodeCheckEnforced()) {
  291. return [];
  292. }
  293. $signatureData = json_decode($this->fileAccessHelper->file_get_contents($signaturePath), true);
  294. if(!is_array($signatureData)) {
  295. throw new InvalidSignatureException('Signature data not found.');
  296. }
  297. $expectedHashes = $signatureData['hashes'];
  298. ksort($expectedHashes);
  299. $signature = base64_decode($signatureData['signature']);
  300. $certificate = $signatureData['certificate'];
  301. // Check if certificate is signed by Nextcloud Root Authority
  302. $x509 = new \phpseclib\File\X509();
  303. $rootCertificatePublicKey = $this->fileAccessHelper->file_get_contents($this->environmentHelper->getServerRoot().'/resources/codesigning/root.crt');
  304. $x509->loadCA($rootCertificatePublicKey);
  305. $x509->loadX509($certificate);
  306. if(!$x509->validateSignature()) {
  307. // FIXME: Once Nextcloud has it's own appstore we should remove the ownCloud Root Authority from here
  308. $x509 = new \phpseclib\File\X509();
  309. $rootCertificatePublicKey = $this->fileAccessHelper->file_get_contents($this->environmentHelper->getServerRoot().'/resources/codesigning/owncloud.crt');
  310. $x509->loadCA($rootCertificatePublicKey);
  311. $x509->loadX509($certificate);
  312. if(!$x509->validateSignature()) {
  313. throw new InvalidSignatureException('Certificate is not valid.');
  314. }
  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() {
  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() {
  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($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($appId, $path = '') {
  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() {
  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. }