Completed
Pull Request — master (#29317)
by Individual IT
09:25
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
 * @author Thomas Müller <[email protected]>
6
 * @author Victor Dubiniuk <[email protected]>
7
 * @author Vincent Petry <[email protected]>
8
 *
9
 * @copyright Copyright (c) 2017, ownCloud GmbH
10
 * @license AGPL-3.0
11
 *
12
 * This code is free software: you can redistribute it and/or modify
13
 * it under the terms of the GNU Affero General Public License, version 3,
14
 * as published by the Free Software Foundation.
15
 *
16
 * This program is distributed in the hope that it will be useful,
17
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
18
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
 * GNU Affero General Public License for more details.
20
 *
21
 * You should have received a copy of the GNU Affero General Public License, version 3,
22
 * along with this program.  If not, see <http://www.gnu.org/licenses/>
23
 *
24
 */
25
26
namespace OC\IntegrityCheck;
27
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
/**
43
 * Class Checker handles the code signing using X.509 and RSA. ownCloud ships with
44
 * a public root certificate certificate that allows to issue new certificates that
45
 * will be trusted for signing code. The CN will be used to verify that a certificate
46
 * given to a third-party developer may not be used for other applications. For
47
 * example the author of the application "calendar" would only receive a certificate
48
 * only valid for this application.
49
 *
50
 * @package OC\IntegrityCheck
51
 */
52
class Checker {
53
	const CACHE_KEY = 'oc.integritycheck.checker';
54
	/** @var EnvironmentHelper */
55
	private $environmentHelper;
56
	/** @var AppLocator */
57
	private $appLocator;
58
	/** @var FileAccessHelper */
59
	private $fileAccessHelper;
60
	/** @var IConfig */
61
	private $config;
62
	/** @var ICache */
63
	private $cache;
64
	/** @var IAppManager */
65
	private $appManager;
66
	/** @var ITempManager */
67
	private $tempManager;
68
69
	/**
70
	 * @param EnvironmentHelper $environmentHelper
71
	 * @param FileAccessHelper $fileAccessHelper
72
	 * @param AppLocator $appLocator
73
	 * @param IConfig $config
74
	 * @param ICacheFactory $cacheFactory
75
	 * @param IAppManager $appManager
76
	 * @param ITempManager $tempManager
77
	 */
78
	public function __construct(EnvironmentHelper $environmentHelper,
79
								FileAccessHelper $fileAccessHelper,
80
								AppLocator $appLocator,
81
								IConfig $config = null,
82
								ICacheFactory $cacheFactory,
83
								IAppManager $appManager = null,
84
								ITempManager $tempManager) {
85
		$this->environmentHelper = $environmentHelper;
86
		$this->fileAccessHelper = $fileAccessHelper;
87
		$this->appLocator = $appLocator;
88
		$this->config = $config;
89
		$this->cache = $cacheFactory->create(self::CACHE_KEY);
90
		$this->appManager = $appManager;
91
		$this->tempManager = $tempManager;
92
	}
93
94
	/**
95
	 * Whether code signing is enforced or not.
96
	 *
97
	 * @return bool
98
	 */
99
	public function isCodeCheckEnforced() {
100
		$notSignedChannels = [ '', 'git'];
101
		if (in_array($this->environmentHelper->getChannel(), $notSignedChannels, true)) {
102
			return false;
103
		}
104
105
		/**
106
		 * This config option is undocumented and supposed to be so, it's only
107
		 * applicable for very specific scenarios and we should not advertise it
108
		 * too prominent. So please do not add it to config.sample.php.
109
		 */
110
		if ($this->config !== null) {
111
			$isIntegrityCheckDisabled = $this->config->getSystemValue('integrity.check.disabled', false);
112
		} else {
113
			$isIntegrityCheckDisabled = false;
114
		}
115
		if ($isIntegrityCheckDisabled === true) {
116
			return false;
117
		}
118
119
		return true;
120
	}
121
122
	/**
123
	 * Enumerates all files belonging to the folder. Sensible defaults are excluded.
124
	 *
125
	 * @param string $folderToIterate
126
	 * @param string $root
127
	 * @return \RecursiveIteratorIterator
128
	 * @throws \Exception
129
	 */
130
	private function getFolderIterator($folderToIterate, $root = '') {
131
		$dirItr = new \RecursiveDirectoryIterator(
132
			$folderToIterate,
133
			\RecursiveDirectoryIterator::SKIP_DOTS
134
		);
135
		if($root === '') {
136
			$root = \OC::$SERVERROOT;
137
		}
138
		$root = rtrim($root, '/');
139
140
		$excludeGenericFilesIterator = new ExcludeFileByNameFilterIterator($dirItr);
141
		$excludeFoldersIterator = new ExcludeFoldersByPathFilterIterator($excludeGenericFilesIterator, $root);
142
143
		return new \RecursiveIteratorIterator(
144
			$excludeFoldersIterator,
145
			\RecursiveIteratorIterator::SELF_FIRST
146
		);
147
	}
148
149
	/**
150
	 * Returns an array of ['filename' => 'SHA512-hash-of-file'] for all files found
151
	 * in the iterator.
152
	 *
153
	 * @param \RecursiveIteratorIterator $iterator
154
	 * @param string $path
155
	 * @return array Array of hashes.
156
	 */
157
	private function generateHashes(\RecursiveIteratorIterator $iterator,
158
									$path) {
159
		$hashes = [];
160
		$copiedWebserverSettingFiles = false;
161
		$tmpFolder = '';
162
163
		$baseDirectoryLength = strlen($path);
164
		foreach($iterator as $filename => $data) {
165
			/** @var \DirectoryIterator $data */
166
			if($data->isDir()) {
167
				continue;
168
			}
169
170
			$relativeFileName = substr($filename, $baseDirectoryLength);
171
			$relativeFileName = ltrim($relativeFileName, '/');
172
173
			// Exclude signature.json files in the appinfo and root folder
174
			if($relativeFileName === 'appinfo/signature.json') {
175
				continue;
176
			}
177
			// Exclude signature.json files in the appinfo and core folder
178
			if($relativeFileName === 'core/signature.json') {
179
				continue;
180
			}
181
182
			// The .user.ini and the .htaccess file of ownCloud can contain some
183
			// custom modifications such as for example the maximum upload size
184
			// to ensure that this will not lead to false positives this will
185
			// copy the file to a temporary folder and reset it to the default
186
			// values.
187
			if($filename === $this->environmentHelper->getServerRoot() . '/.htaccess'
188
				|| $filename === $this->environmentHelper->getServerRoot() . '/.user.ini') {
189
190
				if(!$copiedWebserverSettingFiles) {
191
					$tmpFolder = rtrim($this->tempManager->getTemporaryFolder(), '/');
192
					copy($this->environmentHelper->getServerRoot() . '/.htaccess', $tmpFolder . '/.htaccess');
193
					copy($this->environmentHelper->getServerRoot() . '/.user.ini', $tmpFolder . '/.user.ini');
194
				}
195
			}
196
197
			// The .user.ini file can contain custom modifications to the file size
198
			// as well.
199
			if($filename === $this->environmentHelper->getServerRoot() . '/.user.ini') {
200
				$fileContent = file_get_contents($tmpFolder . '/.user.ini');
201
				$hashes[$relativeFileName] = hash('sha512', $fileContent);
202
				continue;
203
			}
204
205
			// The .htaccess file in the root folder of ownCloud can contain
206
			// custom content after the installation due to the fact that dynamic
207
			// content is written into it at installation time as well. This
208
			// includes for example the 404 and 403 instructions.
209
			// Thus we ignore everything below the first occurrence of
210
			// "#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####" and have the
211
			// hash generated based on this.
212
			if($filename === $this->environmentHelper->getServerRoot() . '/.htaccess') {
213
				$fileContent = file_get_contents($tmpFolder . '/.htaccess');
214
				$explodedArray = explode('#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####', $fileContent);
215
				if(count($explodedArray) === 2) {
216
					$hashes[$relativeFileName] = hash('sha512', $explodedArray[0]);
217
					continue;
218
				}
219
			}
220
221
			$hashes[$relativeFileName] = hash_file('sha512', $filename);
222
		}
223
224
		return $hashes;
225
	}
226
227
	/**
228
	 * Creates the signature data
229
	 *
230
	 * @param array $hashes
231
	 * @param X509 $certificate
232
	 * @param RSA $privateKey
233
	 * @return string
234
	 */
235
	private function createSignatureData(array $hashes,
236
										 X509 $certificate,
237
										 RSA $privateKey) {
238
		ksort($hashes);
239
240
		$privateKey->setSignatureMode(RSA::SIGNATURE_PSS);
241
		$privateKey->setMGFHash('sha512');
242
		$privateKey->setSaltLength(0);
243
		$signature = $privateKey->sign(json_encode($hashes));
244
245
		return [
246
				'hashes' => $hashes,
247
				'signature' => base64_encode($signature),
248
				'certificate' => $certificate->saveX509($certificate->currentCert),
249
			];
250
	}
251
252
	/**
253
	 * Write the signature of the app in the specified folder
254
	 *
255
	 * @param string $path
256
	 * @param X509 $certificate
257
	 * @param RSA $privateKey
258
	 * @throws \Exception
259
	 */
260 View Code Duplication
	public function writeAppSignature($path,
261
									  X509 $certificate,
262
									  RSA $privateKey) {
263
		$appInfoDir = $path . '/appinfo';
264
		$this->fileAccessHelper->assertDirectoryExists($path);
265
		$this->fileAccessHelper->assertDirectoryExists($appInfoDir);
266
267
		$iterator = $this->getFolderIterator($path);
268
		$hashes = $this->generateHashes($iterator, $path);
269
		$signature = $this->createSignatureData($hashes, $certificate, $privateKey);
270
		try {
271
			$this->fileAccessHelper->file_put_contents(
272
				$appInfoDir . '/signature.json',
273
				json_encode($signature, JSON_PRETTY_PRINT)
274
			);
275
		} catch (\Exception $e){
276
			if (!$this->fileAccessHelper->is_writeable($appInfoDir)){
277
				throw new \Exception($appInfoDir . ' is not writable');
278
			}
279
			throw $e;
280
		}
281
	}
282
283
	/**
284
	 * Write the signature of core
285
	 *
286
	 * @param X509 $certificate
287
	 * @param RSA $rsa
288
	 * @param string $path
289
	 * @throws \Exception
290
	 */
291 View Code Duplication
	public function writeCoreSignature(X509 $certificate,
292
									   RSA $rsa,
293
									   $path) {
294
		$coreDir = $path . '/core';
295
		$this->fileAccessHelper->assertDirectoryExists($path);
296
		$this->fileAccessHelper->assertDirectoryExists($coreDir);
297
298
		$iterator = $this->getFolderIterator($path, $path);
299
		$hashes = $this->generateHashes($iterator, $path);
300
		$signatureData = $this->createSignatureData($hashes, $certificate, $rsa);
301
		try {
302
			$this->fileAccessHelper->file_put_contents(
303
				$coreDir . '/signature.json',
304
				json_encode($signatureData, JSON_PRETTY_PRINT)
305
			);
306
		} catch (\Exception $e){
307
			if (!$this->fileAccessHelper->is_writeable($coreDir)){
308
				throw new \Exception($coreDir . ' is not writable');
309
			}
310
			throw $e;
311
		}
312
	}
313
314
	/**
315
	 * Verifies the signature for the specified path.
316
	 *
317
	 * @param string $signaturePath
318
	 * @param string $basePath
319
	 * @param string $certificateCN
320
	 * @param boolean $force
321
	 * @return array
322
	 * @throws InvalidSignatureException
323
	 * @throws \Exception
324
	 */
325
	private function verify($signaturePath, $basePath, $certificateCN, $force = false) {
326
		if(!$force && !$this->isCodeCheckEnforced()) {
327
			return [];
328
		}
329
330
		$signatureData = json_decode($this->fileAccessHelper->file_get_contents($signaturePath), true);
331
		if(!is_array($signatureData)) {
332
			throw new InvalidSignatureException('Signature data not found.');
333
		}
334
335
		$expectedHashes = $signatureData['hashes'];
336
		ksort($expectedHashes);
337
		$signature = base64_decode($signatureData['signature']);
338
		$certificate = $signatureData['certificate'];
339
340
		// Check if certificate is signed by ownCloud Root Authority
341
		$x509 = new \phpseclib\File\X509();
342
		$rootCertificatePublicKey = $this->fileAccessHelper->file_get_contents($this->environmentHelper->getServerRoot().'/resources/codesigning/root.crt');
343
		$x509->loadCA($rootCertificatePublicKey);
344
		$loadedCertificate = $x509->loadX509($certificate);
345
		if(!$x509->validateSignature()) {
346
			throw new InvalidSignatureException('App Certificate is not valid.');
347
		}
348
349
		// Check if the certificate has been revoked
350
		$crlFileContent = $this->fileAccessHelper->file_get_contents($this->environmentHelper->getServerRoot().'/resources/codesigning/intermediate.crl.pem');
351
		if ($crlFileContent && strlen($crlFileContent) > 0) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $crlFileContent of type false|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
352
			$crl = new \phpseclib\File\X509();
353
			$crl->loadCA($rootCertificatePublicKey);
354
			$crl->loadCRL($crlFileContent);
355
			if(!$crl->validateSignature()) {
356
				throw new InvalidSignatureException('Certificate Revocation List is not valid.');
357
			}
358
			// Get the certificate's serial number.
359
			$csn = $loadedCertificate['tbsCertificate']['serialNumber']->toString();
360
361
			// Check certificate revocation status.
362
			$revoked = $crl->getRevoked($csn);
363
			if ($revoked) {
364
				throw new InvalidSignatureException('Certificate has been revoked.');
365
			}
366
		}
367
368
		// Verify if certificate has proper CN. "core" CN is always trusted.
369
		if($x509->getDN(X509::DN_OPENSSL)['CN'] !== $certificateCN && $x509->getDN(X509::DN_OPENSSL)['CN'] !== 'core') {
370
			$cn = $x509->getDN(true)['CN'];
371
			throw new InvalidSignatureException(
372
					"Certificate is not valid for required scope. (Requested: $certificateCN, current: CN=$cn)");
373
		}
374
375
		// Check if the signature of the files is valid
376
		$rsa = new \phpseclib\Crypt\RSA();
377
		$rsa->loadKey($x509->currentCert['tbsCertificate']['subjectPublicKeyInfo']['subjectPublicKey']);
378
		$rsa->setSignatureMode(RSA::SIGNATURE_PSS);
379
		$rsa->setSaltLength(0);
380
		$rsa->setMGFHash('sha512');
381
		if(!$rsa->verify(json_encode($expectedHashes), $signature)) {
382
			throw new InvalidSignatureException('Signature could not get verified.');
383
		}
384
385
		// Compare the list of files which are not identical
386
		$currentInstanceHashes = $this->generateHashes($this->getFolderIterator($basePath), $basePath);
387
		$differencesA = array_diff($expectedHashes, $currentInstanceHashes);
388
		$differencesB = array_diff($currentInstanceHashes, $expectedHashes);
389
		$differences = array_unique(array_merge($differencesA, $differencesB));
390
		$differenceArray = [];
391
		foreach($differences as $filename => $hash) {
392
			// Check if file should not exist in the new signature table
393 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...
394
				$differenceArray['EXTRA_FILE'][$filename]['expected'] = '';
395
				$differenceArray['EXTRA_FILE'][$filename]['current'] = $hash;
396
				continue;
397
			}
398
399
			// Check if file is missing
400 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...
401
				$differenceArray['FILE_MISSING'][$filename]['expected'] = $expectedHashes[$filename];
402
				$differenceArray['FILE_MISSING'][$filename]['current'] = '';
403
				continue;
404
			}
405
406
			// Check if hash does mismatch
407
			if($expectedHashes[$filename] !== $currentInstanceHashes[$filename]) {
408
				$differenceArray['INVALID_HASH'][$filename]['expected'] = $expectedHashes[$filename];
409
				$differenceArray['INVALID_HASH'][$filename]['current'] = $currentInstanceHashes[$filename];
410
				continue;
411
			}
412
413
			// Should never happen.
414
			throw new \Exception('Invalid behaviour in file hash comparison experienced. Please report this error to the developers.');
415
		}
416
417
		return $differenceArray;
418
	}
419
420
	/**
421
	 * Whether the code integrity check has passed successful or not
422
	 *
423
	 * @return bool
424
	 */
425
	public function hasPassedCheck() {
426
		$results = $this->getResults();
427
		if(empty($results)) {
428
			return true;
429
		}
430
431
		return false;
432
	}
433
434
	/**
435
	 * @return array
436
	 */
437
	public function getResults() {
438
		$cachedResults = $this->cache->get(self::CACHE_KEY);
439
		if(!is_null($cachedResults)) {
440
			return json_decode($cachedResults, true);
441
		}
442
443
		if ($this->config !== null) {
444
			return json_decode($this->config->getAppValue('core', self::CACHE_KEY, '{}'), true);
445
		}
446
		return [];
447
	}
448
449
	/**
450
	 * Stores the results in the app config as well as cache
451
	 *
452
	 * @param string $scope
453
	 * @param array $result
454
	 */
455
	private function storeResults($scope, array $result) {
456
		$resultArray = $this->getResults();
457
		unset($resultArray[$scope]);
458
		if(!empty($result)) {
459
			$resultArray[$scope] = $result;
460
		}
461
		if ($this->config !== null) {
462
			$this->config->setAppValue('core', self::CACHE_KEY, json_encode($resultArray));
463
		}
464
		$this->cache->set(self::CACHE_KEY, json_encode($resultArray));
465
	}
466
467
	/**
468
	 *
469
	 * Clean previous results for a proper rescanning. Otherwise
470
	 */
471
	private function cleanResults() {
472
		$this->config->deleteAppValue('core', self::CACHE_KEY);
473
		$this->cache->remove(self::CACHE_KEY);
474
	}
475
476
	/**
477
	 * Verify the signature of $appId. Returns an array with the following content:
478
	 * [
479
	 * 	'FILE_MISSING' =>
480
	 * 	[
481
	 * 		'filename' => [
482
	 * 			'expected' => 'expectedSHA512',
483
	 * 			'current' => 'currentSHA512',
484
	 * 		],
485
	 * 	],
486
	 * 	'EXTRA_FILE' =>
487
	 * 	[
488
	 * 		'filename' => [
489
	 * 			'expected' => 'expectedSHA512',
490
	 * 			'current' => 'currentSHA512',
491
	 * 		],
492
	 * 	],
493
	 * 	'INVALID_HASH' =>
494
	 * 	[
495
	 * 		'filename' => [
496
	 * 			'expected' => 'expectedSHA512',
497
	 * 			'current' => 'currentSHA512',
498
	 * 		],
499
	 * 	],
500
	 * ]
501
	 *
502
	 * Array may be empty in case no problems have been found.
503
	 *
504
	 * @param string $appId
505
	 * @param string $path Optional path. If none is given it will be guessed.
506
	 * @param boolean $force force check even if disabled
507
	 * @return array
508
	 */
509
	public function verifyAppSignature($appId, $path = '', $force = false) {
510
		try {
511
			if($path === '') {
512
				$path = $this->appLocator->getAppPath($appId);
513
			}
514
			$result = $this->verify(
515
					$path . '/appinfo/signature.json',
516
					$path,
517
					$appId,
518
					$force
519
			);
520
		} catch (\Exception $e) {
521
			$result = [
522
					'EXCEPTION' => [
523
							'class' => get_class($e),
524
							'message' => $e->getMessage(),
525
					],
526
			];
527
		}
528
		$this->storeResults($appId, $result);
529
530
		return $result;
531
	}
532
533
	/**
534
	 * Verify the signature of core. Returns an array with the following content:
535
	 * [
536
	 * 	'FILE_MISSING' =>
537
	 * 	[
538
	 * 		'filename' => [
539
	 * 			'expected' => 'expectedSHA512',
540
	 * 			'current' => 'currentSHA512',
541
	 * 		],
542
	 * 	],
543
	 * 	'EXTRA_FILE' =>
544
	 * 	[
545
	 * 		'filename' => [
546
	 * 			'expected' => 'expectedSHA512',
547
	 * 			'current' => 'currentSHA512',
548
	 * 		],
549
	 * 	],
550
	 * 	'INVALID_HASH' =>
551
	 * 	[
552
	 * 		'filename' => [
553
	 * 			'expected' => 'expectedSHA512',
554
	 * 			'current' => 'currentSHA512',
555
	 * 		],
556
	 * 	],
557
	 * ]
558
	 *
559
	 * Array may be empty in case no problems have been found.
560
	 *
561
	 * @return array
562
	 */
563
	public function verifyCoreSignature() {
564
		try {
565
			$result = $this->verify(
566
					$this->environmentHelper->getServerRoot() . '/core/signature.json',
567
					$this->environmentHelper->getServerRoot(),
568
					'core'
569
			);
570
		} catch (\Exception $e) {
571
			$result = [
572
					'EXCEPTION' => [
573
							'class' => get_class($e),
574
							'message' => $e->getMessage(),
575
					],
576
			];
577
		}
578
		$this->storeResults('core', $result);
579
580
		return $result;
581
	}
582
583
	/**
584
	 * Verify the core code of the instance as well as all applicable applications
585
	 * and store the results.
586
	 */
587
	public function runInstanceVerification() {
588
		$this->cleanResults();
589
		$this->verifyCoreSignature();
590
		$appIds = $this->appLocator->getAllApps();
591
		foreach($appIds as $appId) {
592
			// If an application is shipped a valid signature is required
593
			$isShipped = $this->appManager->isShipped($appId);
594
			$appNeedsToBeChecked = false;
595
			if ($isShipped) {
596
				$appNeedsToBeChecked = true;
597
			} elseif ($this->fileAccessHelper->file_exists($this->appLocator->getAppPath($appId) . '/appinfo/signature.json')) {
598
				// Otherwise only if the application explicitly ships a signature.json file
599
				$appNeedsToBeChecked = true;
600
			}
601
602
			if($appNeedsToBeChecked) {
603
				$this->verifyAppSignature($appId);
604
			}
605
		}
606
	}
607
}
608