You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

Checker.php 17KB

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