Completed
Push — master ( 38b223...19f770 )
by Lukas
20:42 queued 10:38
created

Checker::hasPassedCheck()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 5
nc 2
nop 0
dl 0
loc 8
rs 9.4285
c 0
b 0
f 0
1
<?php
2
/**
3
 * @author Lukas Reschke <[email protected]>
4
 * @author Roeland Jago Douma <[email protected]>
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
23
namespace OC\IntegrityCheck;
24
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
/**
40
 * Class Checker handles the code signing using X.509 and RSA. ownCloud ships with
41
 * a public root certificate certificate that allows to issue new certificates that
42
 * will be trusted for signing code. The CN will be used to verify that a certificate
43
 * given to a third-party developer may not be used for other applications. For
44
 * example the author of the application "calendar" would only receive a certificate
45
 * only valid for this application.
46
 *
47
 * @package OC\IntegrityCheck
48
 */
49
class Checker {
50
	const CACHE_KEY = 'oc.integritycheck.checker';
51
	/** @var EnvironmentHelper */
52
	private $environmentHelper;
53
	/** @var AppLocator */
54
	private $appLocator;
55
	/** @var FileAccessHelper */
56
	private $fileAccessHelper;
57
	/** @var IConfig */
58
	private $config;
59
	/** @var ICache */
60
	private $cache;
61
	/** @var IAppManager */
62
	private $appManager;
63
	/** @var ITempManager */
64
	private $tempManager;
65
66
	/**
67
	 * @param EnvironmentHelper $environmentHelper
68
	 * @param FileAccessHelper $fileAccessHelper
69
	 * @param AppLocator $appLocator
70
	 * @param IConfig $config
71
	 * @param ICacheFactory $cacheFactory
72
	 * @param IAppManager $appManager
73
	 * @param ITempManager $tempManager
74
	 */
75
	public function __construct(EnvironmentHelper $environmentHelper,
76
								FileAccessHelper $fileAccessHelper,
77
								AppLocator $appLocator,
78
								IConfig $config = null,
79
								ICacheFactory $cacheFactory,
80
								IAppManager $appManager = null,
81
								ITempManager $tempManager) {
82
		$this->environmentHelper = $environmentHelper;
83
		$this->fileAccessHelper = $fileAccessHelper;
84
		$this->appLocator = $appLocator;
85
		$this->config = $config;
86
		$this->cache = $cacheFactory->create(self::CACHE_KEY);
87
		$this->appManager = $appManager;
88
		$this->tempManager = $tempManager;
89
	}
90
91
	/**
92
	 * Whether code signing is enforced or not.
93
	 *
94
	 * @return bool
95
	 */
96
	public function isCodeCheckEnforced() {
97
		$signedChannels = [
98
			'daily',
99
			'testing',
100
			'stable',
101
		];
102
		if(!in_array($this->environmentHelper->getChannel(), $signedChannels, true)) {
103
			return false;
104
		}
105
106
		/**
107
		 * This config option is undocumented and supposed to be so, it's only
108
		 * applicable for very specific scenarios and we should not advertise it
109
		 * too prominent. So please do not add it to config.sample.php.
110
		 */
111
		if ($this->config !== null) {
112
			$isIntegrityCheckDisabled = $this->config->getSystemValue('integrity.check.disabled', false);
113
		} else {
114
			$isIntegrityCheckDisabled = false;
115
		}
116
		if($isIntegrityCheckDisabled === true) {
117
			return false;
118
		}
119
120
		return true;
121
	}
122
123
	/**
124
	 * Enumerates all files belonging to the folder. Sensible defaults are excluded.
125
	 *
126
	 * @param string $folderToIterate
127
	 * @param string $root
128
	 * @return \RecursiveIteratorIterator
129
	 * @throws \Exception
130
	 */
131
	private function getFolderIterator($folderToIterate, $root = '') {
132
		$dirItr = new \RecursiveDirectoryIterator(
133
			$folderToIterate,
134
			\RecursiveDirectoryIterator::SKIP_DOTS
135
		);
136
		if($root === '') {
137
			$root = \OC::$SERVERROOT;
138
		}
139
		$root = rtrim($root, '/');
140
141
		$excludeGenericFilesIterator = new ExcludeFileByNameFilterIterator($dirItr);
142
		$excludeFoldersIterator = new ExcludeFoldersByPathFilterIterator($excludeGenericFilesIterator, $root);
143
144
		return new \RecursiveIteratorIterator(
145
			$excludeFoldersIterator,
146
			\RecursiveIteratorIterator::SELF_FIRST
147
		);
148
	}
149
150
	/**
151
	 * Returns an array of ['filename' => 'SHA512-hash-of-file'] for all files found
152
	 * in the iterator.
153
	 *
154
	 * @param \RecursiveIteratorIterator $iterator
155
	 * @param string $path
156
	 * @return array Array of hashes.
157
	 */
158
	private function generateHashes(\RecursiveIteratorIterator $iterator,
159
									$path) {
160
		$hashes = [];
161
		$copiedWebserverSettingFiles = false;
162
		$tmpFolder = '';
163
164
		$baseDirectoryLength = strlen($path);
165
		foreach($iterator as $filename => $data) {
166
			/** @var \DirectoryIterator $data */
167
			if($data->isDir()) {
168
				continue;
169
			}
170
171
			$relativeFileName = substr($filename, $baseDirectoryLength);
172
			$relativeFileName = ltrim($relativeFileName, '/');
173
174
			// Exclude signature.json files in the appinfo and root folder
175
			if($relativeFileName === 'appinfo/signature.json') {
176
				continue;
177
			}
178
			// Exclude signature.json files in the appinfo and core folder
179
			if($relativeFileName === 'core/signature.json') {
180
				continue;
181
			}
182
183
			// The .user.ini and the .htaccess file of ownCloud can contain some
184
			// custom modifications such as for example the maximum upload size
185
			// to ensure that this will not lead to false positives this will
186
			// copy the file to a temporary folder and reset it to the default
187
			// values.
188
			if($filename === $this->environmentHelper->getServerRoot() . '/.htaccess'
189
				|| $filename === $this->environmentHelper->getServerRoot() . '/.user.ini') {
190
191
				if(!$copiedWebserverSettingFiles) {
192
					$tmpFolder = rtrim($this->tempManager->getTemporaryFolder(), '/');
193
					copy($this->environmentHelper->getServerRoot() . '/.htaccess', $tmpFolder . '/.htaccess');
194
					copy($this->environmentHelper->getServerRoot() . '/.user.ini', $tmpFolder . '/.user.ini');
195
					\OC_Files::setUploadLimit(
196
						\OCP\Util::computerFileSize('513MB'),
0 ignored issues
show
Documentation introduced by
\OCP\Util::computerFileSize('513MB') is of type double|false, but the function expects a integer.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
197
						[
198
							'.htaccess' => $tmpFolder . '/.htaccess',
199
							'.user.ini' => $tmpFolder . '/.user.ini',
200
						]
201
					);
202
				}
203
			}
204
205
			// The .user.ini file can contain custom modifications to the file size
206
			// as well.
207
			if($filename === $this->environmentHelper->getServerRoot() . '/.user.ini') {
208
				$fileContent = file_get_contents($tmpFolder . '/.user.ini');
209
				$hashes[$relativeFileName] = hash('sha512', $fileContent);
210
				continue;
211
			}
212
213
			// The .htaccess file in the root folder of ownCloud can contain
214
			// custom content after the installation due to the fact that dynamic
215
			// content is written into it at installation time as well. This
216
			// includes for example the 404 and 403 instructions.
217
			// Thus we ignore everything below the first occurrence of
218
			// "#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####" and have the
219
			// hash generated based on this.
220
			if($filename === $this->environmentHelper->getServerRoot() . '/.htaccess') {
221
				$fileContent = file_get_contents($tmpFolder . '/.htaccess');
222
				$explodedArray = explode('#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####', $fileContent);
223
				if(count($explodedArray) === 2) {
224
					$hashes[$relativeFileName] = hash('sha512', $explodedArray[0]);
225
					continue;
226
				}
227
			}
228
229
			$hashes[$relativeFileName] = hash_file('sha512', $filename);
230
		}
231
232
		return $hashes;
233
	}
234
235
	/**
236
	 * Creates the signature data
237
	 *
238
	 * @param array $hashes
239
	 * @param X509 $certificate
240
	 * @param RSA $privateKey
241
	 * @return string
242
	 */
243
	private function createSignatureData(array $hashes,
244
										 X509 $certificate,
245
										 RSA $privateKey) {
246
		ksort($hashes);
247
248
		$privateKey->setSignatureMode(RSA::SIGNATURE_PSS);
249
		$privateKey->setMGFHash('sha512');
250
		$signature = $privateKey->sign(json_encode($hashes));
251
252
		return [
253
				'hashes' => $hashes,
254
				'signature' => base64_encode($signature),
255
				'certificate' => $certificate->saveX509($certificate->currentCert),
256
			];
257
	}
258
259
	/**
260
	 * Write the signature of the app in the specified folder
261
	 *
262
	 * @param string $path
263
	 * @param X509 $certificate
264
	 * @param RSA $privateKey
265
	 * @throws \Exception
266
	 */
267
	public function writeAppSignature($path,
268
									  X509 $certificate,
269
									  RSA $privateKey) {
270
		if(!is_dir($path)) {
271
			throw new \Exception('Directory does not exist.');
272
		}
273
		$iterator = $this->getFolderIterator($path);
274
		$hashes = $this->generateHashes($iterator, $path);
275
		$signature = $this->createSignatureData($hashes, $certificate, $privateKey);
276
		$this->fileAccessHelper->file_put_contents(
277
				$path . '/appinfo/signature.json',
278
				json_encode($signature, JSON_PRETTY_PRINT)
279
		);
280
	}
281
282
	/**
283
	 * Write the signature of core
284
	 *
285
	 * @param X509 $certificate
286
	 * @param RSA $rsa
287
	 * @param string $path
288
	 */
289
	public function writeCoreSignature(X509 $certificate,
290
									   RSA $rsa,
291
									   $path) {
292
		$iterator = $this->getFolderIterator($path, $path);
293
		$hashes = $this->generateHashes($iterator, $path);
294
		$signatureData = $this->createSignatureData($hashes, $certificate, $rsa);
295
		$this->fileAccessHelper->file_put_contents(
296
				$path . '/core/signature.json',
297
				json_encode($signatureData, JSON_PRETTY_PRINT)
298
		);
299
	}
300
301
	/**
302
	 * Verifies the signature for the specified path.
303
	 *
304
	 * @param string $signaturePath
305
	 * @param string $basePath
306
	 * @param string $certificateCN
307
	 * @return array
308
	 * @throws InvalidSignatureException
309
	 * @throws \Exception
310
	 */
311
	private function verify($signaturePath, $basePath, $certificateCN) {
312
		if(!$this->isCodeCheckEnforced()) {
313
			return [];
314
		}
315
316
		$signatureData = json_decode($this->fileAccessHelper->file_get_contents($signaturePath), true);
317
		if(!is_array($signatureData)) {
318
			throw new InvalidSignatureException('Signature data not found.');
319
		}
320
321
		$expectedHashes = $signatureData['hashes'];
322
		ksort($expectedHashes);
323
		$signature = base64_decode($signatureData['signature']);
324
		$certificate = $signatureData['certificate'];
325
326
		// Check if certificate is signed by Nextcloud Root Authority
327
		$x509 = new \phpseclib\File\X509();
328
		$rootCertificatePublicKey = $this->fileAccessHelper->file_get_contents($this->environmentHelper->getServerRoot().'/resources/codesigning/root.crt');
329
		$x509->loadCA($rootCertificatePublicKey);
330
		$x509->loadX509($certificate);
331
		if(!$x509->validateSignature()) {
332
			// FIXME: Once Nextcloud has it's own appstore we should remove the ownCloud Root Authority from here
333
			$x509 = new \phpseclib\File\X509();
334
			$rootCertificatePublicKey = $this->fileAccessHelper->file_get_contents($this->environmentHelper->getServerRoot().'/resources/codesigning/owncloud.crt');
335
			$x509->loadCA($rootCertificatePublicKey);
336
			$x509->loadX509($certificate);
337
			if(!$x509->validateSignature()) {
338
				throw new InvalidSignatureException('Certificate is not valid.');
339
			}
340
		}
341
		// Verify if certificate has proper CN. "core" CN is always trusted.
342
		if($x509->getDN(X509::DN_OPENSSL)['CN'] !== $certificateCN && $x509->getDN(X509::DN_OPENSSL)['CN'] !== 'core') {
343
			throw new InvalidSignatureException(
344
					sprintf('Certificate is not valid for required scope. (Requested: %s, current: %s)', $certificateCN, $x509->getDN(true))
345
			);
346
		}
347
348
		// Check if the signature of the files is valid
349
		$rsa = new \phpseclib\Crypt\RSA();
350
		$rsa->loadKey($x509->currentCert['tbsCertificate']['subjectPublicKeyInfo']['subjectPublicKey']);
351
		$rsa->setSignatureMode(RSA::SIGNATURE_PSS);
352
		$rsa->setMGFHash('sha512');
353
		if(!$rsa->verify(json_encode($expectedHashes), $signature)) {
354
			throw new InvalidSignatureException('Signature could not get verified.');
355
		}
356
357
		// Fixes for the updater as shipped with ownCloud 9.0.x: The updater is
358
		// replaced after the code integrity check is performed.
359
		//
360
		// Due to this reason we exclude the whole updater/ folder from the code
361
		// integrity check.
362
		if($basePath === $this->environmentHelper->getServerRoot()) {
363
			foreach($expectedHashes as $fileName => $hash) {
364
				if(strpos($fileName, 'updater/') === 0) {
365
					unset($expectedHashes[$fileName]);
366
				}
367
			}
368
		}
369
370
		// Compare the list of files which are not identical
371
		$currentInstanceHashes = $this->generateHashes($this->getFolderIterator($basePath), $basePath);
372
		$differencesA = array_diff($expectedHashes, $currentInstanceHashes);
373
		$differencesB = array_diff($currentInstanceHashes, $expectedHashes);
374
		$differences = array_unique(array_merge($differencesA, $differencesB));
375
		$differenceArray = [];
376
		foreach($differences as $filename => $hash) {
377
			// Check if file should not exist in the new signature table
378 View Code Duplication
			if(!array_key_exists($filename, $expectedHashes)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
379
				$differenceArray['EXTRA_FILE'][$filename]['expected'] = '';
380
				$differenceArray['EXTRA_FILE'][$filename]['current'] = $hash;
381
				continue;
382
			}
383
384
			// Check if file is missing
385 View Code Duplication
			if(!array_key_exists($filename, $currentInstanceHashes)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
386
				$differenceArray['FILE_MISSING'][$filename]['expected'] = $expectedHashes[$filename];
387
				$differenceArray['FILE_MISSING'][$filename]['current'] = '';
388
				continue;
389
			}
390
391
			// Check if hash does mismatch
392
			if($expectedHashes[$filename] !== $currentInstanceHashes[$filename]) {
393
				$differenceArray['INVALID_HASH'][$filename]['expected'] = $expectedHashes[$filename];
394
				$differenceArray['INVALID_HASH'][$filename]['current'] = $currentInstanceHashes[$filename];
395
				continue;
396
			}
397
398
			// Should never happen.
399
			throw new \Exception('Invalid behaviour in file hash comparison experienced. Please report this error to the developers.');
400
		}
401
402
		return $differenceArray;
403
	}
404
405
	/**
406
	 * Whether the code integrity check has passed successful or not
407
	 *
408
	 * @return bool
409
	 */
410
	public function hasPassedCheck() {
411
		$results = $this->getResults();
412
		if(empty($results)) {
413
			return true;
414
		}
415
416
		return false;
417
	}
418
419
	/**
420
	 * @return array
421
	 */
422
	public function getResults() {
423
		$cachedResults = $this->cache->get(self::CACHE_KEY);
424
		if(!is_null($cachedResults)) {
425
			return json_decode($cachedResults, true);
426
		}
427
428
		if ($this->config !== null) {
429
			return json_decode($this->config->getAppValue('core', self::CACHE_KEY, '{}'), true);
430
		}
431
		return [];
432
	}
433
434
	/**
435
	 * Stores the results in the app config as well as cache
436
	 *
437
	 * @param string $scope
438
	 * @param array $result
439
	 */
440
	private function storeResults($scope, array $result) {
441
		$resultArray = $this->getResults();
442
		unset($resultArray[$scope]);
443
		if(!empty($result)) {
444
			$resultArray[$scope] = $result;
445
		}
446
		if ($this->config !== null) {
447
			$this->config->setAppValue('core', self::CACHE_KEY, json_encode($resultArray));
448
		}
449
		$this->cache->set(self::CACHE_KEY, json_encode($resultArray));
450
	}
451
452
	/**
453
	 *
454
	 * Clean previous results for a proper rescanning. Otherwise
455
	 */
456
	private function cleanResults() {
457
		$this->config->deleteAppValue('core', self::CACHE_KEY);
458
		$this->cache->remove(self::CACHE_KEY);
459
	}
460
461
	/**
462
	 * Verify the signature of $appId. Returns an array with the following content:
463
	 * [
464
	 * 	'FILE_MISSING' =>
465
	 * 	[
466
	 * 		'filename' => [
467
	 * 			'expected' => 'expectedSHA512',
468
	 * 			'current' => 'currentSHA512',
469
	 * 		],
470
	 * 	],
471
	 * 	'EXTRA_FILE' =>
472
	 * 	[
473
	 * 		'filename' => [
474
	 * 			'expected' => 'expectedSHA512',
475
	 * 			'current' => 'currentSHA512',
476
	 * 		],
477
	 * 	],
478
	 * 	'INVALID_HASH' =>
479
	 * 	[
480
	 * 		'filename' => [
481
	 * 			'expected' => 'expectedSHA512',
482
	 * 			'current' => 'currentSHA512',
483
	 * 		],
484
	 * 	],
485
	 * ]
486
	 *
487
	 * Array may be empty in case no problems have been found.
488
	 *
489
	 * @param string $appId
490
	 * @param string $path Optional path. If none is given it will be guessed.
491
	 * @return array
492
	 */
493
	public function verifyAppSignature($appId, $path = '') {
494
		try {
495
			if($path === '') {
496
				$path = $this->appLocator->getAppPath($appId);
497
			}
498
			$result = $this->verify(
499
					$path . '/appinfo/signature.json',
500
					$path,
501
					$appId
502
			);
503
		} catch (\Exception $e) {
504
			$result = [
505
					'EXCEPTION' => [
506
							'class' => get_class($e),
507
							'message' => $e->getMessage(),
508
					],
509
			];
510
		}
511
		$this->storeResults($appId, $result);
512
513
		return $result;
514
	}
515
516
	/**
517
	 * Verify the signature of core. Returns an array with the following content:
518
	 * [
519
	 * 	'FILE_MISSING' =>
520
	 * 	[
521
	 * 		'filename' => [
522
	 * 			'expected' => 'expectedSHA512',
523
	 * 			'current' => 'currentSHA512',
524
	 * 		],
525
	 * 	],
526
	 * 	'EXTRA_FILE' =>
527
	 * 	[
528
	 * 		'filename' => [
529
	 * 			'expected' => 'expectedSHA512',
530
	 * 			'current' => 'currentSHA512',
531
	 * 		],
532
	 * 	],
533
	 * 	'INVALID_HASH' =>
534
	 * 	[
535
	 * 		'filename' => [
536
	 * 			'expected' => 'expectedSHA512',
537
	 * 			'current' => 'currentSHA512',
538
	 * 		],
539
	 * 	],
540
	 * ]
541
	 *
542
	 * Array may be empty in case no problems have been found.
543
	 *
544
	 * @return array
545
	 */
546
	public function verifyCoreSignature() {
547
		try {
548
			$result = $this->verify(
549
					$this->environmentHelper->getServerRoot() . '/core/signature.json',
550
					$this->environmentHelper->getServerRoot(),
551
					'core'
552
			);
553
		} catch (\Exception $e) {
554
			$result = [
555
					'EXCEPTION' => [
556
							'class' => get_class($e),
557
							'message' => $e->getMessage(),
558
					],
559
			];
560
		}
561
		$this->storeResults('core', $result);
562
563
		return $result;
564
	}
565
566
	/**
567
	 * Verify the core code of the instance as well as all applicable applications
568
	 * and store the results.
569
	 */
570
	public function runInstanceVerification() {
571
		$this->cleanResults();
572
		$this->verifyCoreSignature();
573
		$appIds = $this->appLocator->getAllApps();
574
		foreach($appIds as $appId) {
575
			// If an application is shipped a valid signature is required
576
			$isShipped = $this->appManager->isShipped($appId);
577
			$appNeedsToBeChecked = false;
578
			if ($isShipped) {
579
				$appNeedsToBeChecked = true;
580
			} elseif ($this->fileAccessHelper->file_exists($this->appLocator->getAppPath($appId) . '/appinfo/signature.json')) {
581
				// Otherwise only if the application explicitly ships a signature.json file
582
				$appNeedsToBeChecked = true;
583
			}
584
585
			if($appNeedsToBeChecked) {
586
				$this->verifyAppSignature($appId);
587
			}
588
		}
589
	}
590
}
591