Passed
Push — master ( 53957c...c7bb54 )
by Blizzz
14:59 queued 12s
created
lib/private/IntegrityCheck/Checker.php 1 patch
Indentation   +550 added lines, -550 removed lines patch added patch discarded remove patch
@@ -57,554 +57,554 @@
 block discarded – undo
57 57
  * @package OC\IntegrityCheck
58 58
  */
59 59
 class Checker {
60
-	public const CACHE_KEY = 'oc.integritycheck.checker';
61
-	/** @var EnvironmentHelper */
62
-	private $environmentHelper;
63
-	/** @var AppLocator */
64
-	private $appLocator;
65
-	/** @var FileAccessHelper */
66
-	private $fileAccessHelper;
67
-	/** @var IConfig|null */
68
-	private $config;
69
-	/** @var ICache */
70
-	private $cache;
71
-	/** @var IAppManager|null */
72
-	private $appManager;
73
-	/** @var IMimeTypeDetector */
74
-	private $mimeTypeDetector;
75
-
76
-	/**
77
-	 * @param EnvironmentHelper $environmentHelper
78
-	 * @param FileAccessHelper $fileAccessHelper
79
-	 * @param AppLocator $appLocator
80
-	 * @param IConfig|null $config
81
-	 * @param ICacheFactory $cacheFactory
82
-	 * @param IAppManager|null $appManager
83
-	 * @param IMimeTypeDetector $mimeTypeDetector
84
-	 */
85
-	public function __construct(EnvironmentHelper $environmentHelper,
86
-								FileAccessHelper $fileAccessHelper,
87
-								AppLocator $appLocator,
88
-								?IConfig $config,
89
-								ICacheFactory $cacheFactory,
90
-								?IAppManager $appManager,
91
-								IMimeTypeDetector $mimeTypeDetector) {
92
-		$this->environmentHelper = $environmentHelper;
93
-		$this->fileAccessHelper = $fileAccessHelper;
94
-		$this->appLocator = $appLocator;
95
-		$this->config = $config;
96
-		$this->cache = $cacheFactory->createDistributed(self::CACHE_KEY);
97
-		$this->appManager = $appManager;
98
-		$this->mimeTypeDetector = $mimeTypeDetector;
99
-	}
100
-
101
-	/**
102
-	 * Whether code signing is enforced or not.
103
-	 *
104
-	 * @return bool
105
-	 */
106
-	public function isCodeCheckEnforced(): bool {
107
-		$notSignedChannels = [ '', 'git'];
108
-		if (\in_array($this->environmentHelper->getChannel(), $notSignedChannels, true)) {
109
-			return false;
110
-		}
111
-
112
-		/**
113
-		 * This config option is undocumented and supposed to be so, it's only
114
-		 * applicable for very specific scenarios and we should not advertise it
115
-		 * too prominent. So please do not add it to config.sample.php.
116
-		 */
117
-		$isIntegrityCheckDisabled = false;
118
-		if ($this->config !== null) {
119
-			$isIntegrityCheckDisabled = $this->config->getSystemValueBool('integrity.check.disabled', false);
120
-		}
121
-		if ($isIntegrityCheckDisabled) {
122
-			return false;
123
-		}
124
-
125
-		return true;
126
-	}
127
-
128
-	/**
129
-	 * Enumerates all files belonging to the folder. Sensible defaults are excluded.
130
-	 *
131
-	 * @param string $folderToIterate
132
-	 * @param string $root
133
-	 * @return \RecursiveIteratorIterator
134
-	 * @throws \Exception
135
-	 */
136
-	private function getFolderIterator(string $folderToIterate, string $root = ''): \RecursiveIteratorIterator {
137
-		$dirItr = new \RecursiveDirectoryIterator(
138
-			$folderToIterate,
139
-			\RecursiveDirectoryIterator::SKIP_DOTS
140
-		);
141
-		if ($root === '') {
142
-			$root = \OC::$SERVERROOT;
143
-		}
144
-		$root = rtrim($root, '/');
145
-
146
-		$excludeGenericFilesIterator = new ExcludeFileByNameFilterIterator($dirItr);
147
-		$excludeFoldersIterator = new ExcludeFoldersByPathFilterIterator($excludeGenericFilesIterator, $root);
148
-
149
-		return new \RecursiveIteratorIterator(
150
-			$excludeFoldersIterator,
151
-			\RecursiveIteratorIterator::SELF_FIRST
152
-		);
153
-	}
154
-
155
-	/**
156
-	 * Returns an array of ['filename' => 'SHA512-hash-of-file'] for all files found
157
-	 * in the iterator.
158
-	 *
159
-	 * @param \RecursiveIteratorIterator $iterator
160
-	 * @param string $path
161
-	 * @return array Array of hashes.
162
-	 */
163
-	private function generateHashes(\RecursiveIteratorIterator $iterator,
164
-									string $path): array {
165
-		$hashes = [];
166
-
167
-		$baseDirectoryLength = \strlen($path);
168
-		foreach ($iterator as $filename => $data) {
169
-			/** @var \DirectoryIterator $data */
170
-			if ($data->isDir()) {
171
-				continue;
172
-			}
173
-
174
-			$relativeFileName = substr($filename, $baseDirectoryLength);
175
-			$relativeFileName = ltrim($relativeFileName, '/');
176
-
177
-			// Exclude signature.json files in the appinfo and root folder
178
-			if ($relativeFileName === 'appinfo/signature.json') {
179
-				continue;
180
-			}
181
-			// Exclude signature.json files in the appinfo and core folder
182
-			if ($relativeFileName === 'core/signature.json') {
183
-				continue;
184
-			}
185
-
186
-			// The .htaccess file in the root folder of ownCloud can contain
187
-			// custom content after the installation due to the fact that dynamic
188
-			// content is written into it at installation time as well. This
189
-			// includes for example the 404 and 403 instructions.
190
-			// Thus we ignore everything below the first occurrence of
191
-			// "#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####" and have the
192
-			// hash generated based on this.
193
-			if ($filename === $this->environmentHelper->getServerRoot() . '/.htaccess') {
194
-				$fileContent = file_get_contents($filename);
195
-				$explodedArray = explode('#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####', $fileContent);
196
-				if (\count($explodedArray) === 2) {
197
-					$hashes[$relativeFileName] = hash('sha512', $explodedArray[0]);
198
-					continue;
199
-				}
200
-			}
201
-			if ($filename === $this->environmentHelper->getServerRoot() . '/core/js/mimetypelist.js') {
202
-				$oldMimetypeList = new GenerateMimetypeFileBuilder();
203
-				$newFile = $oldMimetypeList->generateFile($this->mimeTypeDetector->getAllAliases());
204
-				$oldFile = $this->fileAccessHelper->file_get_contents($filename);
205
-				if ($newFile === $oldFile) {
206
-					$hashes[$relativeFileName] = hash('sha512', $oldMimetypeList->generateFile($this->mimeTypeDetector->getOnlyDefaultAliases()));
207
-					continue;
208
-				}
209
-			}
210
-
211
-			$hashes[$relativeFileName] = hash_file('sha512', $filename);
212
-		}
213
-
214
-		return $hashes;
215
-	}
216
-
217
-	/**
218
-	 * Creates the signature data
219
-	 *
220
-	 * @param array $hashes
221
-	 * @param X509 $certificate
222
-	 * @param RSA $privateKey
223
-	 * @return array
224
-	 */
225
-	private function createSignatureData(array $hashes,
226
-										 X509 $certificate,
227
-										 RSA $privateKey): array {
228
-		ksort($hashes);
229
-
230
-		$privateKey->setSignatureMode(RSA::SIGNATURE_PSS);
231
-		$privateKey->setMGFHash('sha512');
232
-		// See https://tools.ietf.org/html/rfc3447#page-38
233
-		$privateKey->setSaltLength(0);
234
-		$signature = $privateKey->sign(json_encode($hashes));
235
-
236
-		return [
237
-			'hashes' => $hashes,
238
-			'signature' => base64_encode($signature),
239
-			'certificate' => $certificate->saveX509($certificate->currentCert),
240
-		];
241
-	}
242
-
243
-	/**
244
-	 * Write the signature of the app in the specified folder
245
-	 *
246
-	 * @param string $path
247
-	 * @param X509 $certificate
248
-	 * @param RSA $privateKey
249
-	 * @throws \Exception
250
-	 */
251
-	public function writeAppSignature($path,
252
-									  X509 $certificate,
253
-									  RSA $privateKey) {
254
-		$appInfoDir = $path . '/appinfo';
255
-		try {
256
-			$this->fileAccessHelper->assertDirectoryExists($appInfoDir);
257
-
258
-			$iterator = $this->getFolderIterator($path);
259
-			$hashes = $this->generateHashes($iterator, $path);
260
-			$signature = $this->createSignatureData($hashes, $certificate, $privateKey);
261
-			$this->fileAccessHelper->file_put_contents(
262
-				$appInfoDir . '/signature.json',
263
-				json_encode($signature, JSON_PRETTY_PRINT)
264
-			);
265
-		} catch (\Exception $e) {
266
-			if (!$this->fileAccessHelper->is_writable($appInfoDir)) {
267
-				throw new \Exception($appInfoDir . ' is not writable');
268
-			}
269
-			throw $e;
270
-		}
271
-	}
272
-
273
-	/**
274
-	 * Write the signature of core
275
-	 *
276
-	 * @param X509 $certificate
277
-	 * @param RSA $rsa
278
-	 * @param string $path
279
-	 * @throws \Exception
280
-	 */
281
-	public function writeCoreSignature(X509 $certificate,
282
-									   RSA $rsa,
283
-									   $path) {
284
-		$coreDir = $path . '/core';
285
-		try {
286
-			$this->fileAccessHelper->assertDirectoryExists($coreDir);
287
-			$iterator = $this->getFolderIterator($path, $path);
288
-			$hashes = $this->generateHashes($iterator, $path);
289
-			$signatureData = $this->createSignatureData($hashes, $certificate, $rsa);
290
-			$this->fileAccessHelper->file_put_contents(
291
-				$coreDir . '/signature.json',
292
-				json_encode($signatureData, JSON_PRETTY_PRINT)
293
-			);
294
-		} catch (\Exception $e) {
295
-			if (!$this->fileAccessHelper->is_writable($coreDir)) {
296
-				throw new \Exception($coreDir . ' is not writable');
297
-			}
298
-			throw $e;
299
-		}
300
-	}
301
-
302
-	/**
303
-	 * Split the certificate file in individual certs
304
-	 *
305
-	 * @param string $cert
306
-	 * @return string[]
307
-	 */
308
-	private function splitCerts(string $cert): array {
309
-		preg_match_all('([\-]{3,}[\S\ ]+?[\-]{3,}[\S\s]+?[\-]{3,}[\S\ ]+?[\-]{3,})', $cert, $matches);
310
-
311
-		return $matches[0];
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 bool $forceVerify
321
-	 * @return array
322
-	 * @throws InvalidSignatureException
323
-	 * @throws \Exception
324
-	 */
325
-	private function verify(string $signaturePath, string $basePath, string $certificateCN, bool $forceVerify = false): array {
326
-		if (!$forceVerify && !$this->isCodeCheckEnforced()) {
327
-			return [];
328
-		}
329
-
330
-		$content = $this->fileAccessHelper->file_get_contents($signaturePath);
331
-		$signatureData = null;
332
-
333
-		if (\is_string($content)) {
334
-			$signatureData = json_decode($content, true);
335
-		}
336
-		if (!\is_array($signatureData)) {
337
-			throw new InvalidSignatureException('Signature data not found.');
338
-		}
339
-
340
-		$expectedHashes = $signatureData['hashes'];
341
-		ksort($expectedHashes);
342
-		$signature = base64_decode($signatureData['signature']);
343
-		$certificate = $signatureData['certificate'];
344
-
345
-		// Check if certificate is signed by Nextcloud Root Authority
346
-		$x509 = new \phpseclib\File\X509();
347
-		$rootCertificatePublicKey = $this->fileAccessHelper->file_get_contents($this->environmentHelper->getServerRoot().'/resources/codesigning/root.crt');
348
-
349
-		$rootCerts = $this->splitCerts($rootCertificatePublicKey);
350
-		foreach ($rootCerts as $rootCert) {
351
-			$x509->loadCA($rootCert);
352
-		}
353
-		$x509->loadX509($certificate);
354
-		if (!$x509->validateSignature()) {
355
-			throw new InvalidSignatureException('Certificate is not valid.');
356
-		}
357
-		// Verify if certificate has proper CN. "core" CN is always trusted.
358
-		if ($x509->getDN(X509::DN_OPENSSL)['CN'] !== $certificateCN && $x509->getDN(X509::DN_OPENSSL)['CN'] !== 'core') {
359
-			throw new InvalidSignatureException(
360
-				sprintf('Certificate is not valid for required scope. (Requested: %s, current: CN=%s)', $certificateCN, $x509->getDN(true)['CN'])
361
-			);
362
-		}
363
-
364
-		// Check if the signature of the files is valid
365
-		$rsa = new \phpseclib\Crypt\RSA();
366
-		$rsa->loadKey($x509->currentCert['tbsCertificate']['subjectPublicKeyInfo']['subjectPublicKey']);
367
-		$rsa->setSignatureMode(RSA::SIGNATURE_PSS);
368
-		$rsa->setMGFHash('sha512');
369
-		// See https://tools.ietf.org/html/rfc3447#page-38
370
-		$rsa->setSaltLength(0);
371
-		if (!$rsa->verify(json_encode($expectedHashes), $signature)) {
372
-			throw new InvalidSignatureException('Signature could not get verified.');
373
-		}
374
-
375
-		// Fixes for the updater as shipped with ownCloud 9.0.x: The updater is
376
-		// replaced after the code integrity check is performed.
377
-		//
378
-		// Due to this reason we exclude the whole updater/ folder from the code
379
-		// integrity check.
380
-		if ($basePath === $this->environmentHelper->getServerRoot()) {
381
-			foreach ($expectedHashes as $fileName => $hash) {
382
-				if (strpos($fileName, 'updater/') === 0) {
383
-					unset($expectedHashes[$fileName]);
384
-				}
385
-			}
386
-		}
387
-
388
-		// Compare the list of files which are not identical
389
-		$currentInstanceHashes = $this->generateHashes($this->getFolderIterator($basePath), $basePath);
390
-		$differencesA = array_diff($expectedHashes, $currentInstanceHashes);
391
-		$differencesB = array_diff($currentInstanceHashes, $expectedHashes);
392
-		$differences = array_unique(array_merge($differencesA, $differencesB));
393
-		$differenceArray = [];
394
-		foreach ($differences as $filename => $hash) {
395
-			// Check if file should not exist in the new signature table
396
-			if (!array_key_exists($filename, $expectedHashes)) {
397
-				$differenceArray['EXTRA_FILE'][$filename]['expected'] = '';
398
-				$differenceArray['EXTRA_FILE'][$filename]['current'] = $hash;
399
-				continue;
400
-			}
401
-
402
-			// Check if file is missing
403
-			if (!array_key_exists($filename, $currentInstanceHashes)) {
404
-				$differenceArray['FILE_MISSING'][$filename]['expected'] = $expectedHashes[$filename];
405
-				$differenceArray['FILE_MISSING'][$filename]['current'] = '';
406
-				continue;
407
-			}
408
-
409
-			// Check if hash does mismatch
410
-			if ($expectedHashes[$filename] !== $currentInstanceHashes[$filename]) {
411
-				$differenceArray['INVALID_HASH'][$filename]['expected'] = $expectedHashes[$filename];
412
-				$differenceArray['INVALID_HASH'][$filename]['current'] = $currentInstanceHashes[$filename];
413
-				continue;
414
-			}
415
-
416
-			// Should never happen.
417
-			throw new \Exception('Invalid behaviour in file hash comparison experienced. Please report this error to the developers.');
418
-		}
419
-
420
-		return $differenceArray;
421
-	}
422
-
423
-	/**
424
-	 * Whether the code integrity check has passed successful or not
425
-	 *
426
-	 * @return bool
427
-	 */
428
-	public function hasPassedCheck(): bool {
429
-		$results = $this->getResults();
430
-		if (empty($results)) {
431
-			return true;
432
-		}
433
-
434
-		return false;
435
-	}
436
-
437
-	/**
438
-	 * @return array
439
-	 */
440
-	public function getResults(): array {
441
-		$cachedResults = $this->cache->get(self::CACHE_KEY);
442
-		if (!\is_null($cachedResults) and $cachedResults !== false) {
443
-			return json_decode($cachedResults, true);
444
-		}
445
-
446
-		if ($this->config !== null) {
447
-			return json_decode($this->config->getAppValue('core', self::CACHE_KEY, '{}'), true);
448
-		}
449
-		return [];
450
-	}
451
-
452
-	/**
453
-	 * Stores the results in the app config as well as cache
454
-	 *
455
-	 * @param string $scope
456
-	 * @param array $result
457
-	 */
458
-	private function storeResults(string $scope, array $result) {
459
-		$resultArray = $this->getResults();
460
-		unset($resultArray[$scope]);
461
-		if (!empty($result)) {
462
-			$resultArray[$scope] = $result;
463
-		}
464
-		if ($this->config !== null) {
465
-			$this->config->setAppValue('core', self::CACHE_KEY, json_encode($resultArray));
466
-		}
467
-		$this->cache->set(self::CACHE_KEY, json_encode($resultArray));
468
-	}
469
-
470
-	/**
471
-	 *
472
-	 * Clean previous results for a proper rescanning. Otherwise
473
-	 */
474
-	private function cleanResults() {
475
-		$this->config->deleteAppValue('core', self::CACHE_KEY);
476
-		$this->cache->remove(self::CACHE_KEY);
477
-	}
478
-
479
-	/**
480
-	 * Verify the signature of $appId. Returns an array with the following content:
481
-	 * [
482
-	 * 	'FILE_MISSING' =>
483
-	 * 	[
484
-	 * 		'filename' => [
485
-	 * 			'expected' => 'expectedSHA512',
486
-	 * 			'current' => 'currentSHA512',
487
-	 * 		],
488
-	 * 	],
489
-	 * 	'EXTRA_FILE' =>
490
-	 * 	[
491
-	 * 		'filename' => [
492
-	 * 			'expected' => 'expectedSHA512',
493
-	 * 			'current' => 'currentSHA512',
494
-	 * 		],
495
-	 * 	],
496
-	 * 	'INVALID_HASH' =>
497
-	 * 	[
498
-	 * 		'filename' => [
499
-	 * 			'expected' => 'expectedSHA512',
500
-	 * 			'current' => 'currentSHA512',
501
-	 * 		],
502
-	 * 	],
503
-	 * ]
504
-	 *
505
-	 * Array may be empty in case no problems have been found.
506
-	 *
507
-	 * @param string $appId
508
-	 * @param string $path Optional path. If none is given it will be guessed.
509
-	 * @param bool $forceVerify
510
-	 * @return array
511
-	 */
512
-	public function verifyAppSignature(string $appId, string $path = '', bool $forceVerify = false): array {
513
-		try {
514
-			if ($path === '') {
515
-				$path = $this->appLocator->getAppPath($appId);
516
-			}
517
-			$result = $this->verify(
518
-				$path . '/appinfo/signature.json',
519
-				$path,
520
-				$appId,
521
-				$forceVerify
522
-			);
523
-		} catch (\Exception $e) {
524
-			$result = [
525
-				'EXCEPTION' => [
526
-					'class' => \get_class($e),
527
-					'message' => $e->getMessage(),
528
-				],
529
-			];
530
-		}
531
-		$this->storeResults($appId, $result);
532
-
533
-		return $result;
534
-	}
535
-
536
-	/**
537
-	 * Verify the signature of core. Returns an array with the following content:
538
-	 * [
539
-	 * 	'FILE_MISSING' =>
540
-	 * 	[
541
-	 * 		'filename' => [
542
-	 * 			'expected' => 'expectedSHA512',
543
-	 * 			'current' => 'currentSHA512',
544
-	 * 		],
545
-	 * 	],
546
-	 * 	'EXTRA_FILE' =>
547
-	 * 	[
548
-	 * 		'filename' => [
549
-	 * 			'expected' => 'expectedSHA512',
550
-	 * 			'current' => 'currentSHA512',
551
-	 * 		],
552
-	 * 	],
553
-	 * 	'INVALID_HASH' =>
554
-	 * 	[
555
-	 * 		'filename' => [
556
-	 * 			'expected' => 'expectedSHA512',
557
-	 * 			'current' => 'currentSHA512',
558
-	 * 		],
559
-	 * 	],
560
-	 * ]
561
-	 *
562
-	 * Array may be empty in case no problems have been found.
563
-	 *
564
-	 * @return array
565
-	 */
566
-	public function verifyCoreSignature(): array {
567
-		try {
568
-			$result = $this->verify(
569
-				$this->environmentHelper->getServerRoot() . '/core/signature.json',
570
-				$this->environmentHelper->getServerRoot(),
571
-				'core'
572
-			);
573
-		} catch (\Exception $e) {
574
-			$result = [
575
-				'EXCEPTION' => [
576
-					'class' => \get_class($e),
577
-					'message' => $e->getMessage(),
578
-				],
579
-			];
580
-		}
581
-		$this->storeResults('core', $result);
582
-
583
-		return $result;
584
-	}
585
-
586
-	/**
587
-	 * Verify the core code of the instance as well as all applicable applications
588
-	 * and store the results.
589
-	 */
590
-	public function runInstanceVerification() {
591
-		$this->cleanResults();
592
-		$this->verifyCoreSignature();
593
-		$appIds = $this->appLocator->getAllApps();
594
-		foreach ($appIds as $appId) {
595
-			// If an application is shipped a valid signature is required
596
-			$isShipped = $this->appManager->isShipped($appId);
597
-			$appNeedsToBeChecked = false;
598
-			if ($isShipped) {
599
-				$appNeedsToBeChecked = true;
600
-			} elseif ($this->fileAccessHelper->file_exists($this->appLocator->getAppPath($appId) . '/appinfo/signature.json')) {
601
-				// Otherwise only if the application explicitly ships a signature.json file
602
-				$appNeedsToBeChecked = true;
603
-			}
604
-
605
-			if ($appNeedsToBeChecked) {
606
-				$this->verifyAppSignature($appId);
607
-			}
608
-		}
609
-	}
60
+    public const CACHE_KEY = 'oc.integritycheck.checker';
61
+    /** @var EnvironmentHelper */
62
+    private $environmentHelper;
63
+    /** @var AppLocator */
64
+    private $appLocator;
65
+    /** @var FileAccessHelper */
66
+    private $fileAccessHelper;
67
+    /** @var IConfig|null */
68
+    private $config;
69
+    /** @var ICache */
70
+    private $cache;
71
+    /** @var IAppManager|null */
72
+    private $appManager;
73
+    /** @var IMimeTypeDetector */
74
+    private $mimeTypeDetector;
75
+
76
+    /**
77
+     * @param EnvironmentHelper $environmentHelper
78
+     * @param FileAccessHelper $fileAccessHelper
79
+     * @param AppLocator $appLocator
80
+     * @param IConfig|null $config
81
+     * @param ICacheFactory $cacheFactory
82
+     * @param IAppManager|null $appManager
83
+     * @param IMimeTypeDetector $mimeTypeDetector
84
+     */
85
+    public function __construct(EnvironmentHelper $environmentHelper,
86
+                                FileAccessHelper $fileAccessHelper,
87
+                                AppLocator $appLocator,
88
+                                ?IConfig $config,
89
+                                ICacheFactory $cacheFactory,
90
+                                ?IAppManager $appManager,
91
+                                IMimeTypeDetector $mimeTypeDetector) {
92
+        $this->environmentHelper = $environmentHelper;
93
+        $this->fileAccessHelper = $fileAccessHelper;
94
+        $this->appLocator = $appLocator;
95
+        $this->config = $config;
96
+        $this->cache = $cacheFactory->createDistributed(self::CACHE_KEY);
97
+        $this->appManager = $appManager;
98
+        $this->mimeTypeDetector = $mimeTypeDetector;
99
+    }
100
+
101
+    /**
102
+     * Whether code signing is enforced or not.
103
+     *
104
+     * @return bool
105
+     */
106
+    public function isCodeCheckEnforced(): bool {
107
+        $notSignedChannels = [ '', 'git'];
108
+        if (\in_array($this->environmentHelper->getChannel(), $notSignedChannels, true)) {
109
+            return false;
110
+        }
111
+
112
+        /**
113
+         * This config option is undocumented and supposed to be so, it's only
114
+         * applicable for very specific scenarios and we should not advertise it
115
+         * too prominent. So please do not add it to config.sample.php.
116
+         */
117
+        $isIntegrityCheckDisabled = false;
118
+        if ($this->config !== null) {
119
+            $isIntegrityCheckDisabled = $this->config->getSystemValueBool('integrity.check.disabled', false);
120
+        }
121
+        if ($isIntegrityCheckDisabled) {
122
+            return false;
123
+        }
124
+
125
+        return true;
126
+    }
127
+
128
+    /**
129
+     * Enumerates all files belonging to the folder. Sensible defaults are excluded.
130
+     *
131
+     * @param string $folderToIterate
132
+     * @param string $root
133
+     * @return \RecursiveIteratorIterator
134
+     * @throws \Exception
135
+     */
136
+    private function getFolderIterator(string $folderToIterate, string $root = ''): \RecursiveIteratorIterator {
137
+        $dirItr = new \RecursiveDirectoryIterator(
138
+            $folderToIterate,
139
+            \RecursiveDirectoryIterator::SKIP_DOTS
140
+        );
141
+        if ($root === '') {
142
+            $root = \OC::$SERVERROOT;
143
+        }
144
+        $root = rtrim($root, '/');
145
+
146
+        $excludeGenericFilesIterator = new ExcludeFileByNameFilterIterator($dirItr);
147
+        $excludeFoldersIterator = new ExcludeFoldersByPathFilterIterator($excludeGenericFilesIterator, $root);
148
+
149
+        return new \RecursiveIteratorIterator(
150
+            $excludeFoldersIterator,
151
+            \RecursiveIteratorIterator::SELF_FIRST
152
+        );
153
+    }
154
+
155
+    /**
156
+     * Returns an array of ['filename' => 'SHA512-hash-of-file'] for all files found
157
+     * in the iterator.
158
+     *
159
+     * @param \RecursiveIteratorIterator $iterator
160
+     * @param string $path
161
+     * @return array Array of hashes.
162
+     */
163
+    private function generateHashes(\RecursiveIteratorIterator $iterator,
164
+                                    string $path): array {
165
+        $hashes = [];
166
+
167
+        $baseDirectoryLength = \strlen($path);
168
+        foreach ($iterator as $filename => $data) {
169
+            /** @var \DirectoryIterator $data */
170
+            if ($data->isDir()) {
171
+                continue;
172
+            }
173
+
174
+            $relativeFileName = substr($filename, $baseDirectoryLength);
175
+            $relativeFileName = ltrim($relativeFileName, '/');
176
+
177
+            // Exclude signature.json files in the appinfo and root folder
178
+            if ($relativeFileName === 'appinfo/signature.json') {
179
+                continue;
180
+            }
181
+            // Exclude signature.json files in the appinfo and core folder
182
+            if ($relativeFileName === 'core/signature.json') {
183
+                continue;
184
+            }
185
+
186
+            // The .htaccess file in the root folder of ownCloud can contain
187
+            // custom content after the installation due to the fact that dynamic
188
+            // content is written into it at installation time as well. This
189
+            // includes for example the 404 and 403 instructions.
190
+            // Thus we ignore everything below the first occurrence of
191
+            // "#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####" and have the
192
+            // hash generated based on this.
193
+            if ($filename === $this->environmentHelper->getServerRoot() . '/.htaccess') {
194
+                $fileContent = file_get_contents($filename);
195
+                $explodedArray = explode('#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####', $fileContent);
196
+                if (\count($explodedArray) === 2) {
197
+                    $hashes[$relativeFileName] = hash('sha512', $explodedArray[0]);
198
+                    continue;
199
+                }
200
+            }
201
+            if ($filename === $this->environmentHelper->getServerRoot() . '/core/js/mimetypelist.js') {
202
+                $oldMimetypeList = new GenerateMimetypeFileBuilder();
203
+                $newFile = $oldMimetypeList->generateFile($this->mimeTypeDetector->getAllAliases());
204
+                $oldFile = $this->fileAccessHelper->file_get_contents($filename);
205
+                if ($newFile === $oldFile) {
206
+                    $hashes[$relativeFileName] = hash('sha512', $oldMimetypeList->generateFile($this->mimeTypeDetector->getOnlyDefaultAliases()));
207
+                    continue;
208
+                }
209
+            }
210
+
211
+            $hashes[$relativeFileName] = hash_file('sha512', $filename);
212
+        }
213
+
214
+        return $hashes;
215
+    }
216
+
217
+    /**
218
+     * Creates the signature data
219
+     *
220
+     * @param array $hashes
221
+     * @param X509 $certificate
222
+     * @param RSA $privateKey
223
+     * @return array
224
+     */
225
+    private function createSignatureData(array $hashes,
226
+                                            X509 $certificate,
227
+                                            RSA $privateKey): array {
228
+        ksort($hashes);
229
+
230
+        $privateKey->setSignatureMode(RSA::SIGNATURE_PSS);
231
+        $privateKey->setMGFHash('sha512');
232
+        // See https://tools.ietf.org/html/rfc3447#page-38
233
+        $privateKey->setSaltLength(0);
234
+        $signature = $privateKey->sign(json_encode($hashes));
235
+
236
+        return [
237
+            'hashes' => $hashes,
238
+            'signature' => base64_encode($signature),
239
+            'certificate' => $certificate->saveX509($certificate->currentCert),
240
+        ];
241
+    }
242
+
243
+    /**
244
+     * Write the signature of the app in the specified folder
245
+     *
246
+     * @param string $path
247
+     * @param X509 $certificate
248
+     * @param RSA $privateKey
249
+     * @throws \Exception
250
+     */
251
+    public function writeAppSignature($path,
252
+                                        X509 $certificate,
253
+                                        RSA $privateKey) {
254
+        $appInfoDir = $path . '/appinfo';
255
+        try {
256
+            $this->fileAccessHelper->assertDirectoryExists($appInfoDir);
257
+
258
+            $iterator = $this->getFolderIterator($path);
259
+            $hashes = $this->generateHashes($iterator, $path);
260
+            $signature = $this->createSignatureData($hashes, $certificate, $privateKey);
261
+            $this->fileAccessHelper->file_put_contents(
262
+                $appInfoDir . '/signature.json',
263
+                json_encode($signature, JSON_PRETTY_PRINT)
264
+            );
265
+        } catch (\Exception $e) {
266
+            if (!$this->fileAccessHelper->is_writable($appInfoDir)) {
267
+                throw new \Exception($appInfoDir . ' is not writable');
268
+            }
269
+            throw $e;
270
+        }
271
+    }
272
+
273
+    /**
274
+     * Write the signature of core
275
+     *
276
+     * @param X509 $certificate
277
+     * @param RSA $rsa
278
+     * @param string $path
279
+     * @throws \Exception
280
+     */
281
+    public function writeCoreSignature(X509 $certificate,
282
+                                        RSA $rsa,
283
+                                        $path) {
284
+        $coreDir = $path . '/core';
285
+        try {
286
+            $this->fileAccessHelper->assertDirectoryExists($coreDir);
287
+            $iterator = $this->getFolderIterator($path, $path);
288
+            $hashes = $this->generateHashes($iterator, $path);
289
+            $signatureData = $this->createSignatureData($hashes, $certificate, $rsa);
290
+            $this->fileAccessHelper->file_put_contents(
291
+                $coreDir . '/signature.json',
292
+                json_encode($signatureData, JSON_PRETTY_PRINT)
293
+            );
294
+        } catch (\Exception $e) {
295
+            if (!$this->fileAccessHelper->is_writable($coreDir)) {
296
+                throw new \Exception($coreDir . ' is not writable');
297
+            }
298
+            throw $e;
299
+        }
300
+    }
301
+
302
+    /**
303
+     * Split the certificate file in individual certs
304
+     *
305
+     * @param string $cert
306
+     * @return string[]
307
+     */
308
+    private function splitCerts(string $cert): array {
309
+        preg_match_all('([\-]{3,}[\S\ ]+?[\-]{3,}[\S\s]+?[\-]{3,}[\S\ ]+?[\-]{3,})', $cert, $matches);
310
+
311
+        return $matches[0];
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 bool $forceVerify
321
+     * @return array
322
+     * @throws InvalidSignatureException
323
+     * @throws \Exception
324
+     */
325
+    private function verify(string $signaturePath, string $basePath, string $certificateCN, bool $forceVerify = false): array {
326
+        if (!$forceVerify && !$this->isCodeCheckEnforced()) {
327
+            return [];
328
+        }
329
+
330
+        $content = $this->fileAccessHelper->file_get_contents($signaturePath);
331
+        $signatureData = null;
332
+
333
+        if (\is_string($content)) {
334
+            $signatureData = json_decode($content, true);
335
+        }
336
+        if (!\is_array($signatureData)) {
337
+            throw new InvalidSignatureException('Signature data not found.');
338
+        }
339
+
340
+        $expectedHashes = $signatureData['hashes'];
341
+        ksort($expectedHashes);
342
+        $signature = base64_decode($signatureData['signature']);
343
+        $certificate = $signatureData['certificate'];
344
+
345
+        // Check if certificate is signed by Nextcloud Root Authority
346
+        $x509 = new \phpseclib\File\X509();
347
+        $rootCertificatePublicKey = $this->fileAccessHelper->file_get_contents($this->environmentHelper->getServerRoot().'/resources/codesigning/root.crt');
348
+
349
+        $rootCerts = $this->splitCerts($rootCertificatePublicKey);
350
+        foreach ($rootCerts as $rootCert) {
351
+            $x509->loadCA($rootCert);
352
+        }
353
+        $x509->loadX509($certificate);
354
+        if (!$x509->validateSignature()) {
355
+            throw new InvalidSignatureException('Certificate is not valid.');
356
+        }
357
+        // Verify if certificate has proper CN. "core" CN is always trusted.
358
+        if ($x509->getDN(X509::DN_OPENSSL)['CN'] !== $certificateCN && $x509->getDN(X509::DN_OPENSSL)['CN'] !== 'core') {
359
+            throw new InvalidSignatureException(
360
+                sprintf('Certificate is not valid for required scope. (Requested: %s, current: CN=%s)', $certificateCN, $x509->getDN(true)['CN'])
361
+            );
362
+        }
363
+
364
+        // Check if the signature of the files is valid
365
+        $rsa = new \phpseclib\Crypt\RSA();
366
+        $rsa->loadKey($x509->currentCert['tbsCertificate']['subjectPublicKeyInfo']['subjectPublicKey']);
367
+        $rsa->setSignatureMode(RSA::SIGNATURE_PSS);
368
+        $rsa->setMGFHash('sha512');
369
+        // See https://tools.ietf.org/html/rfc3447#page-38
370
+        $rsa->setSaltLength(0);
371
+        if (!$rsa->verify(json_encode($expectedHashes), $signature)) {
372
+            throw new InvalidSignatureException('Signature could not get verified.');
373
+        }
374
+
375
+        // Fixes for the updater as shipped with ownCloud 9.0.x: The updater is
376
+        // replaced after the code integrity check is performed.
377
+        //
378
+        // Due to this reason we exclude the whole updater/ folder from the code
379
+        // integrity check.
380
+        if ($basePath === $this->environmentHelper->getServerRoot()) {
381
+            foreach ($expectedHashes as $fileName => $hash) {
382
+                if (strpos($fileName, 'updater/') === 0) {
383
+                    unset($expectedHashes[$fileName]);
384
+                }
385
+            }
386
+        }
387
+
388
+        // Compare the list of files which are not identical
389
+        $currentInstanceHashes = $this->generateHashes($this->getFolderIterator($basePath), $basePath);
390
+        $differencesA = array_diff($expectedHashes, $currentInstanceHashes);
391
+        $differencesB = array_diff($currentInstanceHashes, $expectedHashes);
392
+        $differences = array_unique(array_merge($differencesA, $differencesB));
393
+        $differenceArray = [];
394
+        foreach ($differences as $filename => $hash) {
395
+            // Check if file should not exist in the new signature table
396
+            if (!array_key_exists($filename, $expectedHashes)) {
397
+                $differenceArray['EXTRA_FILE'][$filename]['expected'] = '';
398
+                $differenceArray['EXTRA_FILE'][$filename]['current'] = $hash;
399
+                continue;
400
+            }
401
+
402
+            // Check if file is missing
403
+            if (!array_key_exists($filename, $currentInstanceHashes)) {
404
+                $differenceArray['FILE_MISSING'][$filename]['expected'] = $expectedHashes[$filename];
405
+                $differenceArray['FILE_MISSING'][$filename]['current'] = '';
406
+                continue;
407
+            }
408
+
409
+            // Check if hash does mismatch
410
+            if ($expectedHashes[$filename] !== $currentInstanceHashes[$filename]) {
411
+                $differenceArray['INVALID_HASH'][$filename]['expected'] = $expectedHashes[$filename];
412
+                $differenceArray['INVALID_HASH'][$filename]['current'] = $currentInstanceHashes[$filename];
413
+                continue;
414
+            }
415
+
416
+            // Should never happen.
417
+            throw new \Exception('Invalid behaviour in file hash comparison experienced. Please report this error to the developers.');
418
+        }
419
+
420
+        return $differenceArray;
421
+    }
422
+
423
+    /**
424
+     * Whether the code integrity check has passed successful or not
425
+     *
426
+     * @return bool
427
+     */
428
+    public function hasPassedCheck(): bool {
429
+        $results = $this->getResults();
430
+        if (empty($results)) {
431
+            return true;
432
+        }
433
+
434
+        return false;
435
+    }
436
+
437
+    /**
438
+     * @return array
439
+     */
440
+    public function getResults(): array {
441
+        $cachedResults = $this->cache->get(self::CACHE_KEY);
442
+        if (!\is_null($cachedResults) and $cachedResults !== false) {
443
+            return json_decode($cachedResults, true);
444
+        }
445
+
446
+        if ($this->config !== null) {
447
+            return json_decode($this->config->getAppValue('core', self::CACHE_KEY, '{}'), true);
448
+        }
449
+        return [];
450
+    }
451
+
452
+    /**
453
+     * Stores the results in the app config as well as cache
454
+     *
455
+     * @param string $scope
456
+     * @param array $result
457
+     */
458
+    private function storeResults(string $scope, array $result) {
459
+        $resultArray = $this->getResults();
460
+        unset($resultArray[$scope]);
461
+        if (!empty($result)) {
462
+            $resultArray[$scope] = $result;
463
+        }
464
+        if ($this->config !== null) {
465
+            $this->config->setAppValue('core', self::CACHE_KEY, json_encode($resultArray));
466
+        }
467
+        $this->cache->set(self::CACHE_KEY, json_encode($resultArray));
468
+    }
469
+
470
+    /**
471
+     *
472
+     * Clean previous results for a proper rescanning. Otherwise
473
+     */
474
+    private function cleanResults() {
475
+        $this->config->deleteAppValue('core', self::CACHE_KEY);
476
+        $this->cache->remove(self::CACHE_KEY);
477
+    }
478
+
479
+    /**
480
+     * Verify the signature of $appId. Returns an array with the following content:
481
+     * [
482
+     * 	'FILE_MISSING' =>
483
+     * 	[
484
+     * 		'filename' => [
485
+     * 			'expected' => 'expectedSHA512',
486
+     * 			'current' => 'currentSHA512',
487
+     * 		],
488
+     * 	],
489
+     * 	'EXTRA_FILE' =>
490
+     * 	[
491
+     * 		'filename' => [
492
+     * 			'expected' => 'expectedSHA512',
493
+     * 			'current' => 'currentSHA512',
494
+     * 		],
495
+     * 	],
496
+     * 	'INVALID_HASH' =>
497
+     * 	[
498
+     * 		'filename' => [
499
+     * 			'expected' => 'expectedSHA512',
500
+     * 			'current' => 'currentSHA512',
501
+     * 		],
502
+     * 	],
503
+     * ]
504
+     *
505
+     * Array may be empty in case no problems have been found.
506
+     *
507
+     * @param string $appId
508
+     * @param string $path Optional path. If none is given it will be guessed.
509
+     * @param bool $forceVerify
510
+     * @return array
511
+     */
512
+    public function verifyAppSignature(string $appId, string $path = '', bool $forceVerify = false): array {
513
+        try {
514
+            if ($path === '') {
515
+                $path = $this->appLocator->getAppPath($appId);
516
+            }
517
+            $result = $this->verify(
518
+                $path . '/appinfo/signature.json',
519
+                $path,
520
+                $appId,
521
+                $forceVerify
522
+            );
523
+        } catch (\Exception $e) {
524
+            $result = [
525
+                'EXCEPTION' => [
526
+                    'class' => \get_class($e),
527
+                    'message' => $e->getMessage(),
528
+                ],
529
+            ];
530
+        }
531
+        $this->storeResults($appId, $result);
532
+
533
+        return $result;
534
+    }
535
+
536
+    /**
537
+     * Verify the signature of core. Returns an array with the following content:
538
+     * [
539
+     * 	'FILE_MISSING' =>
540
+     * 	[
541
+     * 		'filename' => [
542
+     * 			'expected' => 'expectedSHA512',
543
+     * 			'current' => 'currentSHA512',
544
+     * 		],
545
+     * 	],
546
+     * 	'EXTRA_FILE' =>
547
+     * 	[
548
+     * 		'filename' => [
549
+     * 			'expected' => 'expectedSHA512',
550
+     * 			'current' => 'currentSHA512',
551
+     * 		],
552
+     * 	],
553
+     * 	'INVALID_HASH' =>
554
+     * 	[
555
+     * 		'filename' => [
556
+     * 			'expected' => 'expectedSHA512',
557
+     * 			'current' => 'currentSHA512',
558
+     * 		],
559
+     * 	],
560
+     * ]
561
+     *
562
+     * Array may be empty in case no problems have been found.
563
+     *
564
+     * @return array
565
+     */
566
+    public function verifyCoreSignature(): array {
567
+        try {
568
+            $result = $this->verify(
569
+                $this->environmentHelper->getServerRoot() . '/core/signature.json',
570
+                $this->environmentHelper->getServerRoot(),
571
+                'core'
572
+            );
573
+        } catch (\Exception $e) {
574
+            $result = [
575
+                'EXCEPTION' => [
576
+                    'class' => \get_class($e),
577
+                    'message' => $e->getMessage(),
578
+                ],
579
+            ];
580
+        }
581
+        $this->storeResults('core', $result);
582
+
583
+        return $result;
584
+    }
585
+
586
+    /**
587
+     * Verify the core code of the instance as well as all applicable applications
588
+     * and store the results.
589
+     */
590
+    public function runInstanceVerification() {
591
+        $this->cleanResults();
592
+        $this->verifyCoreSignature();
593
+        $appIds = $this->appLocator->getAllApps();
594
+        foreach ($appIds as $appId) {
595
+            // If an application is shipped a valid signature is required
596
+            $isShipped = $this->appManager->isShipped($appId);
597
+            $appNeedsToBeChecked = false;
598
+            if ($isShipped) {
599
+                $appNeedsToBeChecked = true;
600
+            } elseif ($this->fileAccessHelper->file_exists($this->appLocator->getAppPath($appId) . '/appinfo/signature.json')) {
601
+                // Otherwise only if the application explicitly ships a signature.json file
602
+                $appNeedsToBeChecked = true;
603
+            }
604
+
605
+            if ($appNeedsToBeChecked) {
606
+                $this->verifyAppSignature($appId);
607
+            }
608
+        }
609
+    }
610 610
 }
Please login to merge, or discard this patch.