Passed
Push — master ( daee22...bbb168 )
by Morris
13:11 queued 10s
created

Checker   C

Complexity

Total Complexity 55

Size/Duplication

Total Lines 521
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 199
dl 0
loc 521
rs 6
c 0
b 0
f 0
wmc 55

15 Methods

Rating   Name   Duplication   Size   Complexity  
A getFolderIterator() 0 16 2
A __construct() 0 14 1
A isCodeCheckEnforced() 0 20 4
C verify() 0 92 15
A getResults() 0 10 3
A cleanResults() 0 3 1
B generateHashes() 0 43 7
A writeCoreSignature() 0 19 3
A writeAppSignature() 0 19 3
A verifyAppSignature() 0 21 3
A storeResults() 0 10 3
A createSignatureData() 0 15 1
A hasPassedCheck() 0 7 2
A runInstanceVerification() 0 17 5
A verifyCoreSignature() 0 18 2

How to fix   Complexity   

Complex Class

Complex classes like Checker often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Checker, and based on these observations, apply Extract Interface, too.

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