Passed
Push — master ( d4d33e...10214f )
by Morris
13:41 queued 11s
created
lib/private/IntegrityCheck/Checker.php 1 patch
Indentation   +549 added lines, -549 removed lines patch added patch discarded remove patch
@@ -58,553 +58,553 @@
 block discarded – undo
58 58
  * @package OC\IntegrityCheck
59 59
  */
60 60
 class Checker {
61
-	public const CACHE_KEY = 'oc.integritycheck.checker';
62
-	/** @var EnvironmentHelper */
63
-	private $environmentHelper;
64
-	/** @var AppLocator */
65
-	private $appLocator;
66
-	/** @var FileAccessHelper */
67
-	private $fileAccessHelper;
68
-	/** @var IConfig|null */
69
-	private $config;
70
-	/** @var ICache */
71
-	private $cache;
72
-	/** @var IAppManager|null */
73
-	private $appManager;
74
-	/** @var IMimeTypeDetector */
75
-	private $mimeTypeDetector;
76
-
77
-	/**
78
-	 * @param EnvironmentHelper $environmentHelper
79
-	 * @param FileAccessHelper $fileAccessHelper
80
-	 * @param AppLocator $appLocator
81
-	 * @param IConfig|null $config
82
-	 * @param ICacheFactory $cacheFactory
83
-	 * @param IAppManager|null $appManager
84
-	 * @param IMimeTypeDetector $mimeTypeDetector
85
-	 */
86
-	public function __construct(EnvironmentHelper $environmentHelper,
87
-								FileAccessHelper $fileAccessHelper,
88
-								AppLocator $appLocator,
89
-								?IConfig $config,
90
-								ICacheFactory $cacheFactory,
91
-								?IAppManager $appManager,
92
-								IMimeTypeDetector $mimeTypeDetector) {
93
-		$this->environmentHelper = $environmentHelper;
94
-		$this->fileAccessHelper = $fileAccessHelper;
95
-		$this->appLocator = $appLocator;
96
-		$this->config = $config;
97
-		$this->cache = $cacheFactory->createDistributed(self::CACHE_KEY);
98
-		$this->appManager = $appManager;
99
-		$this->mimeTypeDetector = $mimeTypeDetector;
100
-	}
101
-
102
-	/**
103
-	 * Whether code signing is enforced or not.
104
-	 *
105
-	 * @return bool
106
-	 */
107
-	public function isCodeCheckEnforced(): bool {
108
-		$notSignedChannels = [ '', 'git'];
109
-		if (\in_array($this->environmentHelper->getChannel(), $notSignedChannels, true)) {
110
-			return false;
111
-		}
112
-
113
-		/**
114
-		 * This config option is undocumented and supposed to be so, it's only
115
-		 * applicable for very specific scenarios and we should not advertise it
116
-		 * too prominent. So please do not add it to config.sample.php.
117
-		 */
118
-		$isIntegrityCheckDisabled = false;
119
-		if ($this->config !== null) {
120
-			$isIntegrityCheckDisabled = $this->config->getSystemValue('integrity.check.disabled', false);
121
-		}
122
-		if ($isIntegrityCheckDisabled === true) {
123
-			return false;
124
-		}
125
-
126
-		return true;
127
-	}
128
-
129
-	/**
130
-	 * Enumerates all files belonging to the folder. Sensible defaults are excluded.
131
-	 *
132
-	 * @param string $folderToIterate
133
-	 * @param string $root
134
-	 * @return \RecursiveIteratorIterator
135
-	 * @throws \Exception
136
-	 */
137
-	private function getFolderIterator(string $folderToIterate, string $root = ''): \RecursiveIteratorIterator {
138
-		$dirItr = new \RecursiveDirectoryIterator(
139
-			$folderToIterate,
140
-			\RecursiveDirectoryIterator::SKIP_DOTS
141
-		);
142
-		if ($root === '') {
143
-			$root = \OC::$SERVERROOT;
144
-		}
145
-		$root = rtrim($root, '/');
146
-
147
-		$excludeGenericFilesIterator = new ExcludeFileByNameFilterIterator($dirItr);
148
-		$excludeFoldersIterator = new ExcludeFoldersByPathFilterIterator($excludeGenericFilesIterator, $root);
149
-
150
-		return new \RecursiveIteratorIterator(
151
-			$excludeFoldersIterator,
152
-			\RecursiveIteratorIterator::SELF_FIRST
153
-		);
154
-	}
155
-
156
-	/**
157
-	 * Returns an array of ['filename' => 'SHA512-hash-of-file'] for all files found
158
-	 * in the iterator.
159
-	 *
160
-	 * @param \RecursiveIteratorIterator $iterator
161
-	 * @param string $path
162
-	 * @return array Array of hashes.
163
-	 */
164
-	private function generateHashes(\RecursiveIteratorIterator $iterator,
165
-									string $path): array {
166
-		$hashes = [];
167
-
168
-		$baseDirectoryLength = \strlen($path);
169
-		foreach ($iterator as $filename => $data) {
170
-			/** @var \DirectoryIterator $data */
171
-			if ($data->isDir()) {
172
-				continue;
173
-			}
174
-
175
-			$relativeFileName = substr($filename, $baseDirectoryLength);
176
-			$relativeFileName = ltrim($relativeFileName, '/');
177
-
178
-			// Exclude signature.json files in the appinfo and root folder
179
-			if ($relativeFileName === 'appinfo/signature.json') {
180
-				continue;
181
-			}
182
-			// Exclude signature.json files in the appinfo and core folder
183
-			if ($relativeFileName === 'core/signature.json') {
184
-				continue;
185
-			}
186
-
187
-			// The .htaccess file in the root folder of ownCloud can contain
188
-			// custom content after the installation due to the fact that dynamic
189
-			// content is written into it at installation time as well. This
190
-			// includes for example the 404 and 403 instructions.
191
-			// Thus we ignore everything below the first occurrence of
192
-			// "#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####" and have the
193
-			// hash generated based on this.
194
-			if ($filename === $this->environmentHelper->getServerRoot() . '/.htaccess') {
195
-				$fileContent = file_get_contents($filename);
196
-				$explodedArray = explode('#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####', $fileContent);
197
-				if (\count($explodedArray) === 2) {
198
-					$hashes[$relativeFileName] = hash('sha512', $explodedArray[0]);
199
-					continue;
200
-				}
201
-			}
202
-			if ($filename === $this->environmentHelper->getServerRoot() . '/core/js/mimetypelist.js') {
203
-				$oldMimetypeList = new GenerateMimetypeFileBuilder();
204
-				$newFile = $oldMimetypeList->generateFile($this->mimeTypeDetector->getAllAliases());
205
-				if ($newFile === file_get_contents($filename)) {
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)) {
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
-	}
61
+    public const CACHE_KEY = 'oc.integritycheck.checker';
62
+    /** @var EnvironmentHelper */
63
+    private $environmentHelper;
64
+    /** @var AppLocator */
65
+    private $appLocator;
66
+    /** @var FileAccessHelper */
67
+    private $fileAccessHelper;
68
+    /** @var IConfig|null */
69
+    private $config;
70
+    /** @var ICache */
71
+    private $cache;
72
+    /** @var IAppManager|null */
73
+    private $appManager;
74
+    /** @var IMimeTypeDetector */
75
+    private $mimeTypeDetector;
76
+
77
+    /**
78
+     * @param EnvironmentHelper $environmentHelper
79
+     * @param FileAccessHelper $fileAccessHelper
80
+     * @param AppLocator $appLocator
81
+     * @param IConfig|null $config
82
+     * @param ICacheFactory $cacheFactory
83
+     * @param IAppManager|null $appManager
84
+     * @param IMimeTypeDetector $mimeTypeDetector
85
+     */
86
+    public function __construct(EnvironmentHelper $environmentHelper,
87
+                                FileAccessHelper $fileAccessHelper,
88
+                                AppLocator $appLocator,
89
+                                ?IConfig $config,
90
+                                ICacheFactory $cacheFactory,
91
+                                ?IAppManager $appManager,
92
+                                IMimeTypeDetector $mimeTypeDetector) {
93
+        $this->environmentHelper = $environmentHelper;
94
+        $this->fileAccessHelper = $fileAccessHelper;
95
+        $this->appLocator = $appLocator;
96
+        $this->config = $config;
97
+        $this->cache = $cacheFactory->createDistributed(self::CACHE_KEY);
98
+        $this->appManager = $appManager;
99
+        $this->mimeTypeDetector = $mimeTypeDetector;
100
+    }
101
+
102
+    /**
103
+     * Whether code signing is enforced or not.
104
+     *
105
+     * @return bool
106
+     */
107
+    public function isCodeCheckEnforced(): bool {
108
+        $notSignedChannels = [ '', 'git'];
109
+        if (\in_array($this->environmentHelper->getChannel(), $notSignedChannels, true)) {
110
+            return false;
111
+        }
112
+
113
+        /**
114
+         * This config option is undocumented and supposed to be so, it's only
115
+         * applicable for very specific scenarios and we should not advertise it
116
+         * too prominent. So please do not add it to config.sample.php.
117
+         */
118
+        $isIntegrityCheckDisabled = false;
119
+        if ($this->config !== null) {
120
+            $isIntegrityCheckDisabled = $this->config->getSystemValue('integrity.check.disabled', false);
121
+        }
122
+        if ($isIntegrityCheckDisabled === true) {
123
+            return false;
124
+        }
125
+
126
+        return true;
127
+    }
128
+
129
+    /**
130
+     * Enumerates all files belonging to the folder. Sensible defaults are excluded.
131
+     *
132
+     * @param string $folderToIterate
133
+     * @param string $root
134
+     * @return \RecursiveIteratorIterator
135
+     * @throws \Exception
136
+     */
137
+    private function getFolderIterator(string $folderToIterate, string $root = ''): \RecursiveIteratorIterator {
138
+        $dirItr = new \RecursiveDirectoryIterator(
139
+            $folderToIterate,
140
+            \RecursiveDirectoryIterator::SKIP_DOTS
141
+        );
142
+        if ($root === '') {
143
+            $root = \OC::$SERVERROOT;
144
+        }
145
+        $root = rtrim($root, '/');
146
+
147
+        $excludeGenericFilesIterator = new ExcludeFileByNameFilterIterator($dirItr);
148
+        $excludeFoldersIterator = new ExcludeFoldersByPathFilterIterator($excludeGenericFilesIterator, $root);
149
+
150
+        return new \RecursiveIteratorIterator(
151
+            $excludeFoldersIterator,
152
+            \RecursiveIteratorIterator::SELF_FIRST
153
+        );
154
+    }
155
+
156
+    /**
157
+     * Returns an array of ['filename' => 'SHA512-hash-of-file'] for all files found
158
+     * in the iterator.
159
+     *
160
+     * @param \RecursiveIteratorIterator $iterator
161
+     * @param string $path
162
+     * @return array Array of hashes.
163
+     */
164
+    private function generateHashes(\RecursiveIteratorIterator $iterator,
165
+                                    string $path): array {
166
+        $hashes = [];
167
+
168
+        $baseDirectoryLength = \strlen($path);
169
+        foreach ($iterator as $filename => $data) {
170
+            /** @var \DirectoryIterator $data */
171
+            if ($data->isDir()) {
172
+                continue;
173
+            }
174
+
175
+            $relativeFileName = substr($filename, $baseDirectoryLength);
176
+            $relativeFileName = ltrim($relativeFileName, '/');
177
+
178
+            // Exclude signature.json files in the appinfo and root folder
179
+            if ($relativeFileName === 'appinfo/signature.json') {
180
+                continue;
181
+            }
182
+            // Exclude signature.json files in the appinfo and core folder
183
+            if ($relativeFileName === 'core/signature.json') {
184
+                continue;
185
+            }
186
+
187
+            // The .htaccess file in the root folder of ownCloud can contain
188
+            // custom content after the installation due to the fact that dynamic
189
+            // content is written into it at installation time as well. This
190
+            // includes for example the 404 and 403 instructions.
191
+            // Thus we ignore everything below the first occurrence of
192
+            // "#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####" and have the
193
+            // hash generated based on this.
194
+            if ($filename === $this->environmentHelper->getServerRoot() . '/.htaccess') {
195
+                $fileContent = file_get_contents($filename);
196
+                $explodedArray = explode('#### DO NOT CHANGE ANYTHING ABOVE THIS LINE ####', $fileContent);
197
+                if (\count($explodedArray) === 2) {
198
+                    $hashes[$relativeFileName] = hash('sha512', $explodedArray[0]);
199
+                    continue;
200
+                }
201
+            }
202
+            if ($filename === $this->environmentHelper->getServerRoot() . '/core/js/mimetypelist.js') {
203
+                $oldMimetypeList = new GenerateMimetypeFileBuilder();
204
+                $newFile = $oldMimetypeList->generateFile($this->mimeTypeDetector->getAllAliases());
205
+                if ($newFile === file_get_contents($filename)) {
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)) {
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.
lib/private/Installer.php 2 patches
Indentation   +596 added lines, -596 removed lines patch added patch discarded remove patch
@@ -59,600 +59,600 @@
 block discarded – undo
59 59
  * This class provides the functionality needed to install, update and remove apps
60 60
  */
61 61
 class Installer {
62
-	/** @var AppFetcher */
63
-	private $appFetcher;
64
-	/** @var IClientService */
65
-	private $clientService;
66
-	/** @var ITempManager */
67
-	private $tempManager;
68
-	/** @var ILogger */
69
-	private $logger;
70
-	/** @var IConfig */
71
-	private $config;
72
-	/** @var array - for caching the result of app fetcher */
73
-	private $apps = null;
74
-	/** @var bool|null - for caching the result of the ready status */
75
-	private $isInstanceReadyForUpdates = null;
76
-	/** @var bool */
77
-	private $isCLI;
78
-
79
-	/**
80
-	 * @param AppFetcher $appFetcher
81
-	 * @param IClientService $clientService
82
-	 * @param ITempManager $tempManager
83
-	 * @param ILogger $logger
84
-	 * @param IConfig $config
85
-	 */
86
-	public function __construct(
87
-		AppFetcher $appFetcher,
88
-		IClientService $clientService,
89
-		ITempManager $tempManager,
90
-		ILogger $logger,
91
-		IConfig $config,
92
-		bool $isCLI
93
-	) {
94
-		$this->appFetcher = $appFetcher;
95
-		$this->clientService = $clientService;
96
-		$this->tempManager = $tempManager;
97
-		$this->logger = $logger;
98
-		$this->config = $config;
99
-		$this->isCLI = $isCLI;
100
-	}
101
-
102
-	/**
103
-	 * Installs an app that is located in one of the app folders already
104
-	 *
105
-	 * @param string $appId App to install
106
-	 * @param bool $forceEnable
107
-	 * @throws \Exception
108
-	 * @return string app ID
109
-	 */
110
-	public function installApp(string $appId, bool $forceEnable = false): string {
111
-		$app = \OC_App::findAppInDirectories($appId);
112
-		if ($app === false) {
113
-			throw new \Exception('App not found in any app directory');
114
-		}
115
-
116
-		$basedir = $app['path'].'/'.$appId;
117
-		$info = OC_App::getAppInfo($basedir.'/appinfo/info.xml', true);
118
-
119
-		$l = \OC::$server->getL10N('core');
120
-
121
-		if (!is_array($info)) {
122
-			throw new \Exception(
123
-				$l->t('App "%s" cannot be installed because appinfo file cannot be read.',
124
-					[$appId]
125
-				)
126
-			);
127
-		}
128
-
129
-		$ignoreMaxApps = $this->config->getSystemValue('app_install_overwrite', []);
130
-		$ignoreMax = $forceEnable || in_array($appId, $ignoreMaxApps, true);
131
-
132
-		$version = implode('.', \OCP\Util::getVersion());
133
-		if (!\OC_App::isAppCompatible($version, $info, $ignoreMax)) {
134
-			throw new \Exception(
135
-				// TODO $l
136
-				$l->t('App "%s" cannot be installed because it is not compatible with this version of the server.',
137
-					[$info['name']]
138
-				)
139
-			);
140
-		}
141
-
142
-		// check for required dependencies
143
-		\OC_App::checkAppDependencies($this->config, $l, $info, $ignoreMax);
144
-		/** @var Coordinator $coordinator */
145
-		$coordinator = \OC::$server->get(Coordinator::class);
146
-		$coordinator->runLazyRegistration($appId);
147
-		\OC_App::registerAutoloading($appId, $basedir);
148
-
149
-		$previousVersion = $this->config->getAppValue($info['id'], 'installed_version', false);
150
-		if ($previousVersion) {
151
-			OC_App::executeRepairSteps($appId, $info['repair-steps']['pre-migration']);
152
-		}
153
-
154
-		//install the database
155
-		if (is_file($basedir.'/appinfo/database.xml')) {
156
-			if (\OC::$server->getConfig()->getAppValue($info['id'], 'installed_version') === null) {
157
-				OC_DB::createDbFromStructure($basedir.'/appinfo/database.xml');
158
-			} else {
159
-				OC_DB::updateDbFromStructure($basedir.'/appinfo/database.xml');
160
-			}
161
-		} else {
162
-			$ms = new \OC\DB\MigrationService($info['id'], \OC::$server->get(Connection::class));
163
-			$ms->migrate('latest', true);
164
-		}
165
-		if ($previousVersion) {
166
-			OC_App::executeRepairSteps($appId, $info['repair-steps']['post-migration']);
167
-		}
168
-
169
-		\OC_App::setupBackgroundJobs($info['background-jobs']);
170
-
171
-		//run appinfo/install.php
172
-		self::includeAppScript($basedir . '/appinfo/install.php');
173
-
174
-		$appData = OC_App::getAppInfo($appId);
175
-		OC_App::executeRepairSteps($appId, $appData['repair-steps']['install']);
176
-
177
-		//set the installed version
178
-		\OC::$server->getConfig()->setAppValue($info['id'], 'installed_version', OC_App::getAppVersion($info['id'], false));
179
-		\OC::$server->getConfig()->setAppValue($info['id'], 'enabled', 'no');
180
-
181
-		//set remote/public handlers
182
-		foreach ($info['remote'] as $name => $path) {
183
-			\OC::$server->getConfig()->setAppValue('core', 'remote_'.$name, $info['id'].'/'.$path);
184
-		}
185
-		foreach ($info['public'] as $name => $path) {
186
-			\OC::$server->getConfig()->setAppValue('core', 'public_'.$name, $info['id'].'/'.$path);
187
-		}
188
-
189
-		OC_App::setAppTypes($info['id']);
190
-
191
-		return $info['id'];
192
-	}
193
-
194
-	/**
195
-	 * Updates the specified app from the appstore
196
-	 *
197
-	 * @param string $appId
198
-	 * @param bool [$allowUnstable] Allow unstable releases
199
-	 * @return bool
200
-	 */
201
-	public function updateAppstoreApp($appId, $allowUnstable = false) {
202
-		if ($this->isUpdateAvailable($appId, $allowUnstable)) {
203
-			try {
204
-				$this->downloadApp($appId, $allowUnstable);
205
-			} catch (\Exception $e) {
206
-				$this->logger->logException($e, [
207
-					'level' => ILogger::ERROR,
208
-					'app' => 'core',
209
-				]);
210
-				return false;
211
-			}
212
-			return OC_App::updateApp($appId);
213
-		}
214
-
215
-		return false;
216
-	}
217
-
218
-	/**
219
-	 * Split the certificate file in individual certs
220
-	 *
221
-	 * @param string $cert
222
-	 * @return string[]
223
-	 */
224
-	private function splitCerts(string $cert): array {
225
-		preg_match_all('([\-]{3,}[\S\ ]+?[\-]{3,}[\S\s]+?[\-]{3,}[\S\ ]+?[\-]{3,})', $cert, $matches);
226
-
227
-		return $matches[0];
228
-	}
229
-
230
-	/**
231
-	 * Downloads an app and puts it into the app directory
232
-	 *
233
-	 * @param string $appId
234
-	 * @param bool [$allowUnstable]
235
-	 *
236
-	 * @throws \Exception If the installation was not successful
237
-	 */
238
-	public function downloadApp($appId, $allowUnstable = false) {
239
-		$appId = strtolower($appId);
240
-
241
-		$apps = $this->appFetcher->get($allowUnstable);
242
-		foreach ($apps as $app) {
243
-			if ($app['id'] === $appId) {
244
-				// Load the certificate
245
-				$certificate = new X509();
246
-				$rootCrt = file_get_contents(__DIR__ . '/../../resources/codesigning/root.crt');
247
-				$rootCrts = $this->splitCerts($rootCrt);
248
-				foreach ($rootCrts as $rootCrt) {
249
-					$certificate->loadCA($rootCrt);
250
-				}
251
-				$loadedCertificate = $certificate->loadX509($app['certificate']);
252
-
253
-				// Verify if the certificate has been revoked
254
-				$crl = new X509();
255
-				foreach ($rootCrts as $rootCrt) {
256
-					$crl->loadCA($rootCrt);
257
-				}
258
-				$crl->loadCRL(file_get_contents(__DIR__ . '/../../resources/codesigning/root.crl'));
259
-				if ($crl->validateSignature() !== true) {
260
-					throw new \Exception('Could not validate CRL signature');
261
-				}
262
-				$csn = $loadedCertificate['tbsCertificate']['serialNumber']->toString();
263
-				$revoked = $crl->getRevoked($csn);
264
-				if ($revoked !== false) {
265
-					throw new \Exception(
266
-						sprintf(
267
-							'Certificate "%s" has been revoked',
268
-							$csn
269
-						)
270
-					);
271
-				}
272
-
273
-				// Verify if the certificate has been issued by the Nextcloud Code Authority CA
274
-				if ($certificate->validateSignature() !== true) {
275
-					throw new \Exception(
276
-						sprintf(
277
-							'App with id %s has a certificate not issued by a trusted Code Signing Authority',
278
-							$appId
279
-						)
280
-					);
281
-				}
282
-
283
-				// Verify if the certificate is issued for the requested app id
284
-				$certInfo = openssl_x509_parse($app['certificate']);
285
-				if (!isset($certInfo['subject']['CN'])) {
286
-					throw new \Exception(
287
-						sprintf(
288
-							'App with id %s has a cert with no CN',
289
-							$appId
290
-						)
291
-					);
292
-				}
293
-				if ($certInfo['subject']['CN'] !== $appId) {
294
-					throw new \Exception(
295
-						sprintf(
296
-							'App with id %s has a cert issued to %s',
297
-							$appId,
298
-							$certInfo['subject']['CN']
299
-						)
300
-					);
301
-				}
302
-
303
-				// Download the release
304
-				$tempFile = $this->tempManager->getTemporaryFile('.tar.gz');
305
-				$timeout = $this->isCLI ? 0 : 120;
306
-				$client = $this->clientService->newClient();
307
-				$client->get($app['releases'][0]['download'], ['sink' => $tempFile, 'timeout' => $timeout]);
308
-
309
-				// Check if the signature actually matches the downloaded content
310
-				$certificate = openssl_get_publickey($app['certificate']);
311
-				$verified = (bool)openssl_verify(file_get_contents($tempFile), base64_decode($app['releases'][0]['signature']), $certificate, OPENSSL_ALGO_SHA512);
312
-				openssl_free_key($certificate);
313
-
314
-				if ($verified === true) {
315
-					// Seems to match, let's proceed
316
-					$extractDir = $this->tempManager->getTemporaryFolder();
317
-					$archive = new TAR($tempFile);
318
-
319
-					if ($archive) {
320
-						if (!$archive->extract($extractDir)) {
321
-							$errorMessage = 'Could not extract app ' . $appId;
322
-
323
-							$archiveError = $archive->getError();
324
-							if ($archiveError instanceof \PEAR_Error) {
325
-								$errorMessage .= ': ' . $archiveError->getMessage();
326
-							}
327
-
328
-							throw new \Exception($errorMessage);
329
-						}
330
-						$allFiles = scandir($extractDir);
331
-						$folders = array_diff($allFiles, ['.', '..']);
332
-						$folders = array_values($folders);
333
-
334
-						if (count($folders) > 1) {
335
-							throw new \Exception(
336
-								sprintf(
337
-									'Extracted app %s has more than 1 folder',
338
-									$appId
339
-								)
340
-							);
341
-						}
342
-
343
-						// Check if appinfo/info.xml has the same app ID as well
344
-						$loadEntities = libxml_disable_entity_loader(false);
345
-						$xml = simplexml_load_file($extractDir . '/' . $folders[0] . '/appinfo/info.xml');
346
-						libxml_disable_entity_loader($loadEntities);
347
-						if ((string)$xml->id !== $appId) {
348
-							throw new \Exception(
349
-								sprintf(
350
-									'App for id %s has a wrong app ID in info.xml: %s',
351
-									$appId,
352
-									(string)$xml->id
353
-								)
354
-							);
355
-						}
356
-
357
-						// Check if the version is lower than before
358
-						$currentVersion = OC_App::getAppVersion($appId);
359
-						$newVersion = (string)$xml->version;
360
-						if (version_compare($currentVersion, $newVersion) === 1) {
361
-							throw new \Exception(
362
-								sprintf(
363
-									'App for id %s has version %s and tried to update to lower version %s',
364
-									$appId,
365
-									$currentVersion,
366
-									$newVersion
367
-								)
368
-							);
369
-						}
370
-
371
-						$baseDir = OC_App::getInstallPath() . '/' . $appId;
372
-						// Remove old app with the ID if existent
373
-						OC_Helper::rmdirr($baseDir);
374
-						// Move to app folder
375
-						if (@mkdir($baseDir)) {
376
-							$extractDir .= '/' . $folders[0];
377
-							OC_Helper::copyr($extractDir, $baseDir);
378
-						}
379
-						OC_Helper::copyr($extractDir, $baseDir);
380
-						OC_Helper::rmdirr($extractDir);
381
-						return;
382
-					} else {
383
-						throw new \Exception(
384
-							sprintf(
385
-								'Could not extract app with ID %s to %s',
386
-								$appId,
387
-								$extractDir
388
-							)
389
-						);
390
-					}
391
-				} else {
392
-					// Signature does not match
393
-					throw new \Exception(
394
-						sprintf(
395
-							'App with id %s has invalid signature',
396
-							$appId
397
-						)
398
-					);
399
-				}
400
-			}
401
-		}
402
-
403
-		throw new \Exception(
404
-			sprintf(
405
-				'Could not download app %s',
406
-				$appId
407
-			)
408
-		);
409
-	}
410
-
411
-	/**
412
-	 * Check if an update for the app is available
413
-	 *
414
-	 * @param string $appId
415
-	 * @param bool $allowUnstable
416
-	 * @return string|false false or the version number of the update
417
-	 */
418
-	public function isUpdateAvailable($appId, $allowUnstable = false) {
419
-		if ($this->isInstanceReadyForUpdates === null) {
420
-			$installPath = OC_App::getInstallPath();
421
-			if ($installPath === false || $installPath === null) {
422
-				$this->isInstanceReadyForUpdates = false;
423
-			} else {
424
-				$this->isInstanceReadyForUpdates = true;
425
-			}
426
-		}
427
-
428
-		if ($this->isInstanceReadyForUpdates === false) {
429
-			return false;
430
-		}
431
-
432
-		if ($this->isInstalledFromGit($appId) === true) {
433
-			return false;
434
-		}
435
-
436
-		if ($this->apps === null) {
437
-			$this->apps = $this->appFetcher->get($allowUnstable);
438
-		}
439
-
440
-		foreach ($this->apps as $app) {
441
-			if ($app['id'] === $appId) {
442
-				$currentVersion = OC_App::getAppVersion($appId);
443
-
444
-				if (!isset($app['releases'][0]['version'])) {
445
-					return false;
446
-				}
447
-				$newestVersion = $app['releases'][0]['version'];
448
-				if ($currentVersion !== '0' && version_compare($newestVersion, $currentVersion, '>')) {
449
-					return $newestVersion;
450
-				} else {
451
-					return false;
452
-				}
453
-			}
454
-		}
455
-
456
-		return false;
457
-	}
458
-
459
-	/**
460
-	 * Check if app has been installed from git
461
-	 * @param string $name name of the application to remove
462
-	 * @return boolean
463
-	 *
464
-	 * The function will check if the path contains a .git folder
465
-	 */
466
-	private function isInstalledFromGit($appId) {
467
-		$app = \OC_App::findAppInDirectories($appId);
468
-		if ($app === false) {
469
-			return false;
470
-		}
471
-		$basedir = $app['path'].'/'.$appId;
472
-		return file_exists($basedir.'/.git/');
473
-	}
474
-
475
-	/**
476
-	 * Check if app is already downloaded
477
-	 * @param string $name name of the application to remove
478
-	 * @return boolean
479
-	 *
480
-	 * The function will check if the app is already downloaded in the apps repository
481
-	 */
482
-	public function isDownloaded($name) {
483
-		foreach (\OC::$APPSROOTS as $dir) {
484
-			$dirToTest = $dir['path'];
485
-			$dirToTest .= '/';
486
-			$dirToTest .= $name;
487
-			$dirToTest .= '/';
488
-
489
-			if (is_dir($dirToTest)) {
490
-				return true;
491
-			}
492
-		}
493
-
494
-		return false;
495
-	}
496
-
497
-	/**
498
-	 * Removes an app
499
-	 * @param string $appId ID of the application to remove
500
-	 * @return boolean
501
-	 *
502
-	 *
503
-	 * This function works as follows
504
-	 *   -# call uninstall repair steps
505
-	 *   -# removing the files
506
-	 *
507
-	 * The function will not delete preferences, tables and the configuration,
508
-	 * this has to be done by the function oc_app_uninstall().
509
-	 */
510
-	public function removeApp($appId) {
511
-		if ($this->isDownloaded($appId)) {
512
-			if (\OC::$server->getAppManager()->isShipped($appId)) {
513
-				return false;
514
-			}
515
-			$appDir = OC_App::getInstallPath() . '/' . $appId;
516
-			OC_Helper::rmdirr($appDir);
517
-			return true;
518
-		} else {
519
-			\OCP\Util::writeLog('core', 'can\'t remove app '.$appId.'. It is not installed.', ILogger::ERROR);
520
-
521
-			return false;
522
-		}
523
-	}
524
-
525
-	/**
526
-	 * Installs the app within the bundle and marks the bundle as installed
527
-	 *
528
-	 * @param Bundle $bundle
529
-	 * @throws \Exception If app could not get installed
530
-	 */
531
-	public function installAppBundle(Bundle $bundle) {
532
-		$appIds = $bundle->getAppIdentifiers();
533
-		foreach ($appIds as $appId) {
534
-			if (!$this->isDownloaded($appId)) {
535
-				$this->downloadApp($appId);
536
-			}
537
-			$this->installApp($appId);
538
-			$app = new OC_App();
539
-			$app->enable($appId);
540
-		}
541
-		$bundles = json_decode($this->config->getAppValue('core', 'installed.bundles', json_encode([])), true);
542
-		$bundles[] = $bundle->getIdentifier();
543
-		$this->config->setAppValue('core', 'installed.bundles', json_encode($bundles));
544
-	}
545
-
546
-	/**
547
-	 * Installs shipped apps
548
-	 *
549
-	 * This function installs all apps found in the 'apps' directory that should be enabled by default;
550
-	 * @param bool $softErrors When updating we ignore errors and simply log them, better to have a
551
-	 *                         working ownCloud at the end instead of an aborted update.
552
-	 * @return array Array of error messages (appid => Exception)
553
-	 */
554
-	public static function installShippedApps($softErrors = false) {
555
-		$appManager = \OC::$server->getAppManager();
556
-		$config = \OC::$server->getConfig();
557
-		$errors = [];
558
-		foreach (\OC::$APPSROOTS as $app_dir) {
559
-			if ($dir = opendir($app_dir['path'])) {
560
-				while (false !== ($filename = readdir($dir))) {
561
-					if ($filename[0] !== '.' and is_dir($app_dir['path']."/$filename")) {
562
-						if (file_exists($app_dir['path']."/$filename/appinfo/info.xml")) {
563
-							if ($config->getAppValue($filename, "installed_version", null) === null) {
564
-								$info = OC_App::getAppInfo($filename);
565
-								$enabled = isset($info['default_enable']);
566
-								if (($enabled || in_array($filename, $appManager->getAlwaysEnabledApps()))
567
-									  && $config->getAppValue($filename, 'enabled') !== 'no') {
568
-									if ($softErrors) {
569
-										try {
570
-											Installer::installShippedApp($filename);
571
-										} catch (HintException $e) {
572
-											if ($e->getPrevious() instanceof TableExistsException) {
573
-												$errors[$filename] = $e;
574
-												continue;
575
-											}
576
-											throw $e;
577
-										}
578
-									} else {
579
-										Installer::installShippedApp($filename);
580
-									}
581
-									$config->setAppValue($filename, 'enabled', 'yes');
582
-								}
583
-							}
584
-						}
585
-					}
586
-				}
587
-				closedir($dir);
588
-			}
589
-		}
590
-
591
-		return $errors;
592
-	}
593
-
594
-	/**
595
-	 * install an app already placed in the app folder
596
-	 * @param string $app id of the app to install
597
-	 * @return integer
598
-	 */
599
-	public static function installShippedApp($app) {
600
-		//install the database
601
-		$appPath = OC_App::getAppPath($app);
602
-		\OC_App::registerAutoloading($app, $appPath);
603
-
604
-		if (is_file("$appPath/appinfo/database.xml")) {
605
-			try {
606
-				OC_DB::createDbFromStructure("$appPath/appinfo/database.xml");
607
-			} catch (TableExistsException $e) {
608
-				throw new HintException(
609
-					'Failed to enable app ' . $app,
610
-					'Please ask for help via one of our <a href="https://nextcloud.com/support/" target="_blank" rel="noreferrer noopener">support channels</a>.',
611
-					0, $e
612
-				);
613
-			}
614
-		} else {
615
-			$ms = new \OC\DB\MigrationService($app, \OC::$server->get(Connection::class));
616
-			$ms->migrate('latest', true);
617
-		}
618
-
619
-		//run appinfo/install.php
620
-		self::includeAppScript("$appPath/appinfo/install.php");
621
-
622
-		$info = OC_App::getAppInfo($app);
623
-		if (is_null($info)) {
624
-			return false;
625
-		}
626
-		\OC_App::setupBackgroundJobs($info['background-jobs']);
627
-
628
-		OC_App::executeRepairSteps($app, $info['repair-steps']['install']);
629
-
630
-		$config = \OC::$server->getConfig();
631
-
632
-		$config->setAppValue($app, 'installed_version', OC_App::getAppVersion($app));
633
-		if (array_key_exists('ocsid', $info)) {
634
-			$config->setAppValue($app, 'ocsid', $info['ocsid']);
635
-		}
636
-
637
-		//set remote/public handlers
638
-		foreach ($info['remote'] as $name => $path) {
639
-			$config->setAppValue('core', 'remote_'.$name, $app.'/'.$path);
640
-		}
641
-		foreach ($info['public'] as $name => $path) {
642
-			$config->setAppValue('core', 'public_'.$name, $app.'/'.$path);
643
-		}
644
-
645
-		OC_App::setAppTypes($info['id']);
646
-
647
-		return $info['id'];
648
-	}
649
-
650
-	/**
651
-	 * @param string $script
652
-	 */
653
-	private static function includeAppScript($script) {
654
-		if (file_exists($script)) {
655
-			include $script;
656
-		}
657
-	}
62
+    /** @var AppFetcher */
63
+    private $appFetcher;
64
+    /** @var IClientService */
65
+    private $clientService;
66
+    /** @var ITempManager */
67
+    private $tempManager;
68
+    /** @var ILogger */
69
+    private $logger;
70
+    /** @var IConfig */
71
+    private $config;
72
+    /** @var array - for caching the result of app fetcher */
73
+    private $apps = null;
74
+    /** @var bool|null - for caching the result of the ready status */
75
+    private $isInstanceReadyForUpdates = null;
76
+    /** @var bool */
77
+    private $isCLI;
78
+
79
+    /**
80
+     * @param AppFetcher $appFetcher
81
+     * @param IClientService $clientService
82
+     * @param ITempManager $tempManager
83
+     * @param ILogger $logger
84
+     * @param IConfig $config
85
+     */
86
+    public function __construct(
87
+        AppFetcher $appFetcher,
88
+        IClientService $clientService,
89
+        ITempManager $tempManager,
90
+        ILogger $logger,
91
+        IConfig $config,
92
+        bool $isCLI
93
+    ) {
94
+        $this->appFetcher = $appFetcher;
95
+        $this->clientService = $clientService;
96
+        $this->tempManager = $tempManager;
97
+        $this->logger = $logger;
98
+        $this->config = $config;
99
+        $this->isCLI = $isCLI;
100
+    }
101
+
102
+    /**
103
+     * Installs an app that is located in one of the app folders already
104
+     *
105
+     * @param string $appId App to install
106
+     * @param bool $forceEnable
107
+     * @throws \Exception
108
+     * @return string app ID
109
+     */
110
+    public function installApp(string $appId, bool $forceEnable = false): string {
111
+        $app = \OC_App::findAppInDirectories($appId);
112
+        if ($app === false) {
113
+            throw new \Exception('App not found in any app directory');
114
+        }
115
+
116
+        $basedir = $app['path'].'/'.$appId;
117
+        $info = OC_App::getAppInfo($basedir.'/appinfo/info.xml', true);
118
+
119
+        $l = \OC::$server->getL10N('core');
120
+
121
+        if (!is_array($info)) {
122
+            throw new \Exception(
123
+                $l->t('App "%s" cannot be installed because appinfo file cannot be read.',
124
+                    [$appId]
125
+                )
126
+            );
127
+        }
128
+
129
+        $ignoreMaxApps = $this->config->getSystemValue('app_install_overwrite', []);
130
+        $ignoreMax = $forceEnable || in_array($appId, $ignoreMaxApps, true);
131
+
132
+        $version = implode('.', \OCP\Util::getVersion());
133
+        if (!\OC_App::isAppCompatible($version, $info, $ignoreMax)) {
134
+            throw new \Exception(
135
+                // TODO $l
136
+                $l->t('App "%s" cannot be installed because it is not compatible with this version of the server.',
137
+                    [$info['name']]
138
+                )
139
+            );
140
+        }
141
+
142
+        // check for required dependencies
143
+        \OC_App::checkAppDependencies($this->config, $l, $info, $ignoreMax);
144
+        /** @var Coordinator $coordinator */
145
+        $coordinator = \OC::$server->get(Coordinator::class);
146
+        $coordinator->runLazyRegistration($appId);
147
+        \OC_App::registerAutoloading($appId, $basedir);
148
+
149
+        $previousVersion = $this->config->getAppValue($info['id'], 'installed_version', false);
150
+        if ($previousVersion) {
151
+            OC_App::executeRepairSteps($appId, $info['repair-steps']['pre-migration']);
152
+        }
153
+
154
+        //install the database
155
+        if (is_file($basedir.'/appinfo/database.xml')) {
156
+            if (\OC::$server->getConfig()->getAppValue($info['id'], 'installed_version') === null) {
157
+                OC_DB::createDbFromStructure($basedir.'/appinfo/database.xml');
158
+            } else {
159
+                OC_DB::updateDbFromStructure($basedir.'/appinfo/database.xml');
160
+            }
161
+        } else {
162
+            $ms = new \OC\DB\MigrationService($info['id'], \OC::$server->get(Connection::class));
163
+            $ms->migrate('latest', true);
164
+        }
165
+        if ($previousVersion) {
166
+            OC_App::executeRepairSteps($appId, $info['repair-steps']['post-migration']);
167
+        }
168
+
169
+        \OC_App::setupBackgroundJobs($info['background-jobs']);
170
+
171
+        //run appinfo/install.php
172
+        self::includeAppScript($basedir . '/appinfo/install.php');
173
+
174
+        $appData = OC_App::getAppInfo($appId);
175
+        OC_App::executeRepairSteps($appId, $appData['repair-steps']['install']);
176
+
177
+        //set the installed version
178
+        \OC::$server->getConfig()->setAppValue($info['id'], 'installed_version', OC_App::getAppVersion($info['id'], false));
179
+        \OC::$server->getConfig()->setAppValue($info['id'], 'enabled', 'no');
180
+
181
+        //set remote/public handlers
182
+        foreach ($info['remote'] as $name => $path) {
183
+            \OC::$server->getConfig()->setAppValue('core', 'remote_'.$name, $info['id'].'/'.$path);
184
+        }
185
+        foreach ($info['public'] as $name => $path) {
186
+            \OC::$server->getConfig()->setAppValue('core', 'public_'.$name, $info['id'].'/'.$path);
187
+        }
188
+
189
+        OC_App::setAppTypes($info['id']);
190
+
191
+        return $info['id'];
192
+    }
193
+
194
+    /**
195
+     * Updates the specified app from the appstore
196
+     *
197
+     * @param string $appId
198
+     * @param bool [$allowUnstable] Allow unstable releases
199
+     * @return bool
200
+     */
201
+    public function updateAppstoreApp($appId, $allowUnstable = false) {
202
+        if ($this->isUpdateAvailable($appId, $allowUnstable)) {
203
+            try {
204
+                $this->downloadApp($appId, $allowUnstable);
205
+            } catch (\Exception $e) {
206
+                $this->logger->logException($e, [
207
+                    'level' => ILogger::ERROR,
208
+                    'app' => 'core',
209
+                ]);
210
+                return false;
211
+            }
212
+            return OC_App::updateApp($appId);
213
+        }
214
+
215
+        return false;
216
+    }
217
+
218
+    /**
219
+     * Split the certificate file in individual certs
220
+     *
221
+     * @param string $cert
222
+     * @return string[]
223
+     */
224
+    private function splitCerts(string $cert): array {
225
+        preg_match_all('([\-]{3,}[\S\ ]+?[\-]{3,}[\S\s]+?[\-]{3,}[\S\ ]+?[\-]{3,})', $cert, $matches);
226
+
227
+        return $matches[0];
228
+    }
229
+
230
+    /**
231
+     * Downloads an app and puts it into the app directory
232
+     *
233
+     * @param string $appId
234
+     * @param bool [$allowUnstable]
235
+     *
236
+     * @throws \Exception If the installation was not successful
237
+     */
238
+    public function downloadApp($appId, $allowUnstable = false) {
239
+        $appId = strtolower($appId);
240
+
241
+        $apps = $this->appFetcher->get($allowUnstable);
242
+        foreach ($apps as $app) {
243
+            if ($app['id'] === $appId) {
244
+                // Load the certificate
245
+                $certificate = new X509();
246
+                $rootCrt = file_get_contents(__DIR__ . '/../../resources/codesigning/root.crt');
247
+                $rootCrts = $this->splitCerts($rootCrt);
248
+                foreach ($rootCrts as $rootCrt) {
249
+                    $certificate->loadCA($rootCrt);
250
+                }
251
+                $loadedCertificate = $certificate->loadX509($app['certificate']);
252
+
253
+                // Verify if the certificate has been revoked
254
+                $crl = new X509();
255
+                foreach ($rootCrts as $rootCrt) {
256
+                    $crl->loadCA($rootCrt);
257
+                }
258
+                $crl->loadCRL(file_get_contents(__DIR__ . '/../../resources/codesigning/root.crl'));
259
+                if ($crl->validateSignature() !== true) {
260
+                    throw new \Exception('Could not validate CRL signature');
261
+                }
262
+                $csn = $loadedCertificate['tbsCertificate']['serialNumber']->toString();
263
+                $revoked = $crl->getRevoked($csn);
264
+                if ($revoked !== false) {
265
+                    throw new \Exception(
266
+                        sprintf(
267
+                            'Certificate "%s" has been revoked',
268
+                            $csn
269
+                        )
270
+                    );
271
+                }
272
+
273
+                // Verify if the certificate has been issued by the Nextcloud Code Authority CA
274
+                if ($certificate->validateSignature() !== true) {
275
+                    throw new \Exception(
276
+                        sprintf(
277
+                            'App with id %s has a certificate not issued by a trusted Code Signing Authority',
278
+                            $appId
279
+                        )
280
+                    );
281
+                }
282
+
283
+                // Verify if the certificate is issued for the requested app id
284
+                $certInfo = openssl_x509_parse($app['certificate']);
285
+                if (!isset($certInfo['subject']['CN'])) {
286
+                    throw new \Exception(
287
+                        sprintf(
288
+                            'App with id %s has a cert with no CN',
289
+                            $appId
290
+                        )
291
+                    );
292
+                }
293
+                if ($certInfo['subject']['CN'] !== $appId) {
294
+                    throw new \Exception(
295
+                        sprintf(
296
+                            'App with id %s has a cert issued to %s',
297
+                            $appId,
298
+                            $certInfo['subject']['CN']
299
+                        )
300
+                    );
301
+                }
302
+
303
+                // Download the release
304
+                $tempFile = $this->tempManager->getTemporaryFile('.tar.gz');
305
+                $timeout = $this->isCLI ? 0 : 120;
306
+                $client = $this->clientService->newClient();
307
+                $client->get($app['releases'][0]['download'], ['sink' => $tempFile, 'timeout' => $timeout]);
308
+
309
+                // Check if the signature actually matches the downloaded content
310
+                $certificate = openssl_get_publickey($app['certificate']);
311
+                $verified = (bool)openssl_verify(file_get_contents($tempFile), base64_decode($app['releases'][0]['signature']), $certificate, OPENSSL_ALGO_SHA512);
312
+                openssl_free_key($certificate);
313
+
314
+                if ($verified === true) {
315
+                    // Seems to match, let's proceed
316
+                    $extractDir = $this->tempManager->getTemporaryFolder();
317
+                    $archive = new TAR($tempFile);
318
+
319
+                    if ($archive) {
320
+                        if (!$archive->extract($extractDir)) {
321
+                            $errorMessage = 'Could not extract app ' . $appId;
322
+
323
+                            $archiveError = $archive->getError();
324
+                            if ($archiveError instanceof \PEAR_Error) {
325
+                                $errorMessage .= ': ' . $archiveError->getMessage();
326
+                            }
327
+
328
+                            throw new \Exception($errorMessage);
329
+                        }
330
+                        $allFiles = scandir($extractDir);
331
+                        $folders = array_diff($allFiles, ['.', '..']);
332
+                        $folders = array_values($folders);
333
+
334
+                        if (count($folders) > 1) {
335
+                            throw new \Exception(
336
+                                sprintf(
337
+                                    'Extracted app %s has more than 1 folder',
338
+                                    $appId
339
+                                )
340
+                            );
341
+                        }
342
+
343
+                        // Check if appinfo/info.xml has the same app ID as well
344
+                        $loadEntities = libxml_disable_entity_loader(false);
345
+                        $xml = simplexml_load_file($extractDir . '/' . $folders[0] . '/appinfo/info.xml');
346
+                        libxml_disable_entity_loader($loadEntities);
347
+                        if ((string)$xml->id !== $appId) {
348
+                            throw new \Exception(
349
+                                sprintf(
350
+                                    'App for id %s has a wrong app ID in info.xml: %s',
351
+                                    $appId,
352
+                                    (string)$xml->id
353
+                                )
354
+                            );
355
+                        }
356
+
357
+                        // Check if the version is lower than before
358
+                        $currentVersion = OC_App::getAppVersion($appId);
359
+                        $newVersion = (string)$xml->version;
360
+                        if (version_compare($currentVersion, $newVersion) === 1) {
361
+                            throw new \Exception(
362
+                                sprintf(
363
+                                    'App for id %s has version %s and tried to update to lower version %s',
364
+                                    $appId,
365
+                                    $currentVersion,
366
+                                    $newVersion
367
+                                )
368
+                            );
369
+                        }
370
+
371
+                        $baseDir = OC_App::getInstallPath() . '/' . $appId;
372
+                        // Remove old app with the ID if existent
373
+                        OC_Helper::rmdirr($baseDir);
374
+                        // Move to app folder
375
+                        if (@mkdir($baseDir)) {
376
+                            $extractDir .= '/' . $folders[0];
377
+                            OC_Helper::copyr($extractDir, $baseDir);
378
+                        }
379
+                        OC_Helper::copyr($extractDir, $baseDir);
380
+                        OC_Helper::rmdirr($extractDir);
381
+                        return;
382
+                    } else {
383
+                        throw new \Exception(
384
+                            sprintf(
385
+                                'Could not extract app with ID %s to %s',
386
+                                $appId,
387
+                                $extractDir
388
+                            )
389
+                        );
390
+                    }
391
+                } else {
392
+                    // Signature does not match
393
+                    throw new \Exception(
394
+                        sprintf(
395
+                            'App with id %s has invalid signature',
396
+                            $appId
397
+                        )
398
+                    );
399
+                }
400
+            }
401
+        }
402
+
403
+        throw new \Exception(
404
+            sprintf(
405
+                'Could not download app %s',
406
+                $appId
407
+            )
408
+        );
409
+    }
410
+
411
+    /**
412
+     * Check if an update for the app is available
413
+     *
414
+     * @param string $appId
415
+     * @param bool $allowUnstable
416
+     * @return string|false false or the version number of the update
417
+     */
418
+    public function isUpdateAvailable($appId, $allowUnstable = false) {
419
+        if ($this->isInstanceReadyForUpdates === null) {
420
+            $installPath = OC_App::getInstallPath();
421
+            if ($installPath === false || $installPath === null) {
422
+                $this->isInstanceReadyForUpdates = false;
423
+            } else {
424
+                $this->isInstanceReadyForUpdates = true;
425
+            }
426
+        }
427
+
428
+        if ($this->isInstanceReadyForUpdates === false) {
429
+            return false;
430
+        }
431
+
432
+        if ($this->isInstalledFromGit($appId) === true) {
433
+            return false;
434
+        }
435
+
436
+        if ($this->apps === null) {
437
+            $this->apps = $this->appFetcher->get($allowUnstable);
438
+        }
439
+
440
+        foreach ($this->apps as $app) {
441
+            if ($app['id'] === $appId) {
442
+                $currentVersion = OC_App::getAppVersion($appId);
443
+
444
+                if (!isset($app['releases'][0]['version'])) {
445
+                    return false;
446
+                }
447
+                $newestVersion = $app['releases'][0]['version'];
448
+                if ($currentVersion !== '0' && version_compare($newestVersion, $currentVersion, '>')) {
449
+                    return $newestVersion;
450
+                } else {
451
+                    return false;
452
+                }
453
+            }
454
+        }
455
+
456
+        return false;
457
+    }
458
+
459
+    /**
460
+     * Check if app has been installed from git
461
+     * @param string $name name of the application to remove
462
+     * @return boolean
463
+     *
464
+     * The function will check if the path contains a .git folder
465
+     */
466
+    private function isInstalledFromGit($appId) {
467
+        $app = \OC_App::findAppInDirectories($appId);
468
+        if ($app === false) {
469
+            return false;
470
+        }
471
+        $basedir = $app['path'].'/'.$appId;
472
+        return file_exists($basedir.'/.git/');
473
+    }
474
+
475
+    /**
476
+     * Check if app is already downloaded
477
+     * @param string $name name of the application to remove
478
+     * @return boolean
479
+     *
480
+     * The function will check if the app is already downloaded in the apps repository
481
+     */
482
+    public function isDownloaded($name) {
483
+        foreach (\OC::$APPSROOTS as $dir) {
484
+            $dirToTest = $dir['path'];
485
+            $dirToTest .= '/';
486
+            $dirToTest .= $name;
487
+            $dirToTest .= '/';
488
+
489
+            if (is_dir($dirToTest)) {
490
+                return true;
491
+            }
492
+        }
493
+
494
+        return false;
495
+    }
496
+
497
+    /**
498
+     * Removes an app
499
+     * @param string $appId ID of the application to remove
500
+     * @return boolean
501
+     *
502
+     *
503
+     * This function works as follows
504
+     *   -# call uninstall repair steps
505
+     *   -# removing the files
506
+     *
507
+     * The function will not delete preferences, tables and the configuration,
508
+     * this has to be done by the function oc_app_uninstall().
509
+     */
510
+    public function removeApp($appId) {
511
+        if ($this->isDownloaded($appId)) {
512
+            if (\OC::$server->getAppManager()->isShipped($appId)) {
513
+                return false;
514
+            }
515
+            $appDir = OC_App::getInstallPath() . '/' . $appId;
516
+            OC_Helper::rmdirr($appDir);
517
+            return true;
518
+        } else {
519
+            \OCP\Util::writeLog('core', 'can\'t remove app '.$appId.'. It is not installed.', ILogger::ERROR);
520
+
521
+            return false;
522
+        }
523
+    }
524
+
525
+    /**
526
+     * Installs the app within the bundle and marks the bundle as installed
527
+     *
528
+     * @param Bundle $bundle
529
+     * @throws \Exception If app could not get installed
530
+     */
531
+    public function installAppBundle(Bundle $bundle) {
532
+        $appIds = $bundle->getAppIdentifiers();
533
+        foreach ($appIds as $appId) {
534
+            if (!$this->isDownloaded($appId)) {
535
+                $this->downloadApp($appId);
536
+            }
537
+            $this->installApp($appId);
538
+            $app = new OC_App();
539
+            $app->enable($appId);
540
+        }
541
+        $bundles = json_decode($this->config->getAppValue('core', 'installed.bundles', json_encode([])), true);
542
+        $bundles[] = $bundle->getIdentifier();
543
+        $this->config->setAppValue('core', 'installed.bundles', json_encode($bundles));
544
+    }
545
+
546
+    /**
547
+     * Installs shipped apps
548
+     *
549
+     * This function installs all apps found in the 'apps' directory that should be enabled by default;
550
+     * @param bool $softErrors When updating we ignore errors and simply log them, better to have a
551
+     *                         working ownCloud at the end instead of an aborted update.
552
+     * @return array Array of error messages (appid => Exception)
553
+     */
554
+    public static function installShippedApps($softErrors = false) {
555
+        $appManager = \OC::$server->getAppManager();
556
+        $config = \OC::$server->getConfig();
557
+        $errors = [];
558
+        foreach (\OC::$APPSROOTS as $app_dir) {
559
+            if ($dir = opendir($app_dir['path'])) {
560
+                while (false !== ($filename = readdir($dir))) {
561
+                    if ($filename[0] !== '.' and is_dir($app_dir['path']."/$filename")) {
562
+                        if (file_exists($app_dir['path']."/$filename/appinfo/info.xml")) {
563
+                            if ($config->getAppValue($filename, "installed_version", null) === null) {
564
+                                $info = OC_App::getAppInfo($filename);
565
+                                $enabled = isset($info['default_enable']);
566
+                                if (($enabled || in_array($filename, $appManager->getAlwaysEnabledApps()))
567
+                                      && $config->getAppValue($filename, 'enabled') !== 'no') {
568
+                                    if ($softErrors) {
569
+                                        try {
570
+                                            Installer::installShippedApp($filename);
571
+                                        } catch (HintException $e) {
572
+                                            if ($e->getPrevious() instanceof TableExistsException) {
573
+                                                $errors[$filename] = $e;
574
+                                                continue;
575
+                                            }
576
+                                            throw $e;
577
+                                        }
578
+                                    } else {
579
+                                        Installer::installShippedApp($filename);
580
+                                    }
581
+                                    $config->setAppValue($filename, 'enabled', 'yes');
582
+                                }
583
+                            }
584
+                        }
585
+                    }
586
+                }
587
+                closedir($dir);
588
+            }
589
+        }
590
+
591
+        return $errors;
592
+    }
593
+
594
+    /**
595
+     * install an app already placed in the app folder
596
+     * @param string $app id of the app to install
597
+     * @return integer
598
+     */
599
+    public static function installShippedApp($app) {
600
+        //install the database
601
+        $appPath = OC_App::getAppPath($app);
602
+        \OC_App::registerAutoloading($app, $appPath);
603
+
604
+        if (is_file("$appPath/appinfo/database.xml")) {
605
+            try {
606
+                OC_DB::createDbFromStructure("$appPath/appinfo/database.xml");
607
+            } catch (TableExistsException $e) {
608
+                throw new HintException(
609
+                    'Failed to enable app ' . $app,
610
+                    'Please ask for help via one of our <a href="https://nextcloud.com/support/" target="_blank" rel="noreferrer noopener">support channels</a>.',
611
+                    0, $e
612
+                );
613
+            }
614
+        } else {
615
+            $ms = new \OC\DB\MigrationService($app, \OC::$server->get(Connection::class));
616
+            $ms->migrate('latest', true);
617
+        }
618
+
619
+        //run appinfo/install.php
620
+        self::includeAppScript("$appPath/appinfo/install.php");
621
+
622
+        $info = OC_App::getAppInfo($app);
623
+        if (is_null($info)) {
624
+            return false;
625
+        }
626
+        \OC_App::setupBackgroundJobs($info['background-jobs']);
627
+
628
+        OC_App::executeRepairSteps($app, $info['repair-steps']['install']);
629
+
630
+        $config = \OC::$server->getConfig();
631
+
632
+        $config->setAppValue($app, 'installed_version', OC_App::getAppVersion($app));
633
+        if (array_key_exists('ocsid', $info)) {
634
+            $config->setAppValue($app, 'ocsid', $info['ocsid']);
635
+        }
636
+
637
+        //set remote/public handlers
638
+        foreach ($info['remote'] as $name => $path) {
639
+            $config->setAppValue('core', 'remote_'.$name, $app.'/'.$path);
640
+        }
641
+        foreach ($info['public'] as $name => $path) {
642
+            $config->setAppValue('core', 'public_'.$name, $app.'/'.$path);
643
+        }
644
+
645
+        OC_App::setAppTypes($info['id']);
646
+
647
+        return $info['id'];
648
+    }
649
+
650
+    /**
651
+     * @param string $script
652
+     */
653
+    private static function includeAppScript($script) {
654
+        if (file_exists($script)) {
655
+            include $script;
656
+        }
657
+    }
658 658
 }
Please login to merge, or discard this patch.
Spacing   +14 added lines, -14 removed lines patch added patch discarded remove patch
@@ -169,7 +169,7 @@  discard block
 block discarded – undo
169 169
 		\OC_App::setupBackgroundJobs($info['background-jobs']);
170 170
 
171 171
 		//run appinfo/install.php
172
-		self::includeAppScript($basedir . '/appinfo/install.php');
172
+		self::includeAppScript($basedir.'/appinfo/install.php');
173 173
 
174 174
 		$appData = OC_App::getAppInfo($appId);
175 175
 		OC_App::executeRepairSteps($appId, $appData['repair-steps']['install']);
@@ -243,7 +243,7 @@  discard block
 block discarded – undo
243 243
 			if ($app['id'] === $appId) {
244 244
 				// Load the certificate
245 245
 				$certificate = new X509();
246
-				$rootCrt = file_get_contents(__DIR__ . '/../../resources/codesigning/root.crt');
246
+				$rootCrt = file_get_contents(__DIR__.'/../../resources/codesigning/root.crt');
247 247
 				$rootCrts = $this->splitCerts($rootCrt);
248 248
 				foreach ($rootCrts as $rootCrt) {
249 249
 					$certificate->loadCA($rootCrt);
@@ -255,7 +255,7 @@  discard block
 block discarded – undo
255 255
 				foreach ($rootCrts as $rootCrt) {
256 256
 					$crl->loadCA($rootCrt);
257 257
 				}
258
-				$crl->loadCRL(file_get_contents(__DIR__ . '/../../resources/codesigning/root.crl'));
258
+				$crl->loadCRL(file_get_contents(__DIR__.'/../../resources/codesigning/root.crl'));
259 259
 				if ($crl->validateSignature() !== true) {
260 260
 					throw new \Exception('Could not validate CRL signature');
261 261
 				}
@@ -308,7 +308,7 @@  discard block
 block discarded – undo
308 308
 
309 309
 				// Check if the signature actually matches the downloaded content
310 310
 				$certificate = openssl_get_publickey($app['certificate']);
311
-				$verified = (bool)openssl_verify(file_get_contents($tempFile), base64_decode($app['releases'][0]['signature']), $certificate, OPENSSL_ALGO_SHA512);
311
+				$verified = (bool) openssl_verify(file_get_contents($tempFile), base64_decode($app['releases'][0]['signature']), $certificate, OPENSSL_ALGO_SHA512);
312 312
 				openssl_free_key($certificate);
313 313
 
314 314
 				if ($verified === true) {
@@ -318,11 +318,11 @@  discard block
 block discarded – undo
318 318
 
319 319
 					if ($archive) {
320 320
 						if (!$archive->extract($extractDir)) {
321
-							$errorMessage = 'Could not extract app ' . $appId;
321
+							$errorMessage = 'Could not extract app '.$appId;
322 322
 
323 323
 							$archiveError = $archive->getError();
324 324
 							if ($archiveError instanceof \PEAR_Error) {
325
-								$errorMessage .= ': ' . $archiveError->getMessage();
325
+								$errorMessage .= ': '.$archiveError->getMessage();
326 326
 							}
327 327
 
328 328
 							throw new \Exception($errorMessage);
@@ -342,21 +342,21 @@  discard block
 block discarded – undo
342 342
 
343 343
 						// Check if appinfo/info.xml has the same app ID as well
344 344
 						$loadEntities = libxml_disable_entity_loader(false);
345
-						$xml = simplexml_load_file($extractDir . '/' . $folders[0] . '/appinfo/info.xml');
345
+						$xml = simplexml_load_file($extractDir.'/'.$folders[0].'/appinfo/info.xml');
346 346
 						libxml_disable_entity_loader($loadEntities);
347
-						if ((string)$xml->id !== $appId) {
347
+						if ((string) $xml->id !== $appId) {
348 348
 							throw new \Exception(
349 349
 								sprintf(
350 350
 									'App for id %s has a wrong app ID in info.xml: %s',
351 351
 									$appId,
352
-									(string)$xml->id
352
+									(string) $xml->id
353 353
 								)
354 354
 							);
355 355
 						}
356 356
 
357 357
 						// Check if the version is lower than before
358 358
 						$currentVersion = OC_App::getAppVersion($appId);
359
-						$newVersion = (string)$xml->version;
359
+						$newVersion = (string) $xml->version;
360 360
 						if (version_compare($currentVersion, $newVersion) === 1) {
361 361
 							throw new \Exception(
362 362
 								sprintf(
@@ -368,12 +368,12 @@  discard block
 block discarded – undo
368 368
 							);
369 369
 						}
370 370
 
371
-						$baseDir = OC_App::getInstallPath() . '/' . $appId;
371
+						$baseDir = OC_App::getInstallPath().'/'.$appId;
372 372
 						// Remove old app with the ID if existent
373 373
 						OC_Helper::rmdirr($baseDir);
374 374
 						// Move to app folder
375 375
 						if (@mkdir($baseDir)) {
376
-							$extractDir .= '/' . $folders[0];
376
+							$extractDir .= '/'.$folders[0];
377 377
 							OC_Helper::copyr($extractDir, $baseDir);
378 378
 						}
379 379
 						OC_Helper::copyr($extractDir, $baseDir);
@@ -512,7 +512,7 @@  discard block
 block discarded – undo
512 512
 			if (\OC::$server->getAppManager()->isShipped($appId)) {
513 513
 				return false;
514 514
 			}
515
-			$appDir = OC_App::getInstallPath() . '/' . $appId;
515
+			$appDir = OC_App::getInstallPath().'/'.$appId;
516 516
 			OC_Helper::rmdirr($appDir);
517 517
 			return true;
518 518
 		} else {
@@ -606,7 +606,7 @@  discard block
 block discarded – undo
606 606
 				OC_DB::createDbFromStructure("$appPath/appinfo/database.xml");
607 607
 			} catch (TableExistsException $e) {
608 608
 				throw new HintException(
609
-					'Failed to enable app ' . $app,
609
+					'Failed to enable app '.$app,
610 610
 					'Please ask for help via one of our <a href="https://nextcloud.com/support/" target="_blank" rel="noreferrer noopener">support channels</a>.',
611 611
 					0, $e
612 612
 				);
Please login to merge, or discard this patch.
apps/files_external/lib/Lib/Storage/SFTP.php 1 patch
Indentation   +428 added lines, -428 removed lines patch added patch discarded remove patch
@@ -46,432 +46,432 @@
 block discarded – undo
46 46
  * provide access to SFTP servers.
47 47
  */
48 48
 class SFTP extends \OC\Files\Storage\Common {
49
-	private $host;
50
-	private $user;
51
-	private $root;
52
-	private $port = 22;
53
-
54
-	private $auth = [];
55
-
56
-	/**
57
-	 * @var \phpseclib\Net\SFTP
58
-	 */
59
-	protected $client;
60
-
61
-	/**
62
-	 * @param string $host protocol://server:port
63
-	 * @return array [$server, $port]
64
-	 */
65
-	private function splitHost($host) {
66
-		$input = $host;
67
-		if (strpos($host, '://') === false) {
68
-			// add a protocol to fix parse_url behavior with ipv6
69
-			$host = 'http://' . $host;
70
-		}
71
-
72
-		$parsed = parse_url($host);
73
-		if (is_array($parsed) && isset($parsed['port'])) {
74
-			return [$parsed['host'], $parsed['port']];
75
-		} elseif (is_array($parsed)) {
76
-			return [$parsed['host'], 22];
77
-		} else {
78
-			return [$input, 22];
79
-		}
80
-	}
81
-
82
-	/**
83
-	 * {@inheritdoc}
84
-	 */
85
-	public function __construct($params) {
86
-		// Register sftp://
87
-		Stream::register();
88
-
89
-		$parsedHost = $this->splitHost($params['host']);
90
-
91
-		$this->host = $parsedHost[0];
92
-		$this->port = $parsedHost[1];
93
-
94
-		if (!isset($params['user'])) {
95
-			throw new \UnexpectedValueException('no authentication parameters specified');
96
-		}
97
-		$this->user = $params['user'];
98
-
99
-		if (isset($params['public_key_auth'])) {
100
-			$this->auth[] = $params['public_key_auth'];
101
-		}
102
-		if (isset($params['password']) && $params['password'] !== '') {
103
-			$this->auth[] = $params['password'];
104
-		}
105
-
106
-		if ($this->auth === []) {
107
-			throw new \UnexpectedValueException('no authentication parameters specified');
108
-		}
109
-
110
-		$this->root
111
-			= isset($params['root']) ? $this->cleanPath($params['root']) : '/';
112
-
113
-		$this->root = '/' . ltrim($this->root, '/');
114
-		$this->root = rtrim($this->root, '/') . '/';
115
-	}
116
-
117
-	/**
118
-	 * Returns the connection.
119
-	 *
120
-	 * @return \phpseclib\Net\SFTP connected client instance
121
-	 * @throws \Exception when the connection failed
122
-	 */
123
-	public function getConnection() {
124
-		if (!is_null($this->client)) {
125
-			return $this->client;
126
-		}
127
-
128
-		$hostKeys = $this->readHostKeys();
129
-		$this->client = new \phpseclib\Net\SFTP($this->host, $this->port);
130
-
131
-		// The SSH Host Key MUST be verified before login().
132
-		$currentHostKey = $this->client->getServerPublicHostKey();
133
-		if (array_key_exists($this->host, $hostKeys)) {
134
-			if ($hostKeys[$this->host] !== $currentHostKey) {
135
-				throw new \Exception('Host public key does not match known key');
136
-			}
137
-		} else {
138
-			$hostKeys[$this->host] = $currentHostKey;
139
-			$this->writeHostKeys($hostKeys);
140
-		}
141
-
142
-		$login = false;
143
-		foreach ($this->auth as $auth) {
144
-			/** @psalm-suppress TooManyArguments */
145
-			$login = $this->client->login($this->user, $auth);
146
-			if ($login === true) {
147
-				break;
148
-			}
149
-		}
150
-
151
-		if ($login === false) {
152
-			throw new \Exception('Login failed');
153
-		}
154
-		return $this->client;
155
-	}
156
-
157
-	/**
158
-	 * {@inheritdoc}
159
-	 */
160
-	public function test() {
161
-		if (
162
-			!isset($this->host)
163
-			|| !isset($this->user)
164
-		) {
165
-			return false;
166
-		}
167
-		return $this->getConnection()->nlist() !== false;
168
-	}
169
-
170
-	/**
171
-	 * {@inheritdoc}
172
-	 */
173
-	public function getId() {
174
-		$id = 'sftp::' . $this->user . '@' . $this->host;
175
-		if ($this->port !== 22) {
176
-			$id .= ':' . $this->port;
177
-		}
178
-		// note: this will double the root slash,
179
-		// we should not change it to keep compatible with
180
-		// old storage ids
181
-		$id .= '/' . $this->root;
182
-		return $id;
183
-	}
184
-
185
-	/**
186
-	 * @return string
187
-	 */
188
-	public function getHost() {
189
-		return $this->host;
190
-	}
191
-
192
-	/**
193
-	 * @return string
194
-	 */
195
-	public function getRoot() {
196
-		return $this->root;
197
-	}
198
-
199
-	/**
200
-	 * @return mixed
201
-	 */
202
-	public function getUser() {
203
-		return $this->user;
204
-	}
205
-
206
-	/**
207
-	 * @param string $path
208
-	 * @return string
209
-	 */
210
-	private function absPath($path) {
211
-		return $this->root . $this->cleanPath($path);
212
-	}
213
-
214
-	/**
215
-	 * @return string|false
216
-	 */
217
-	private function hostKeysPath() {
218
-		try {
219
-			$storage_view = \OCP\Files::getStorage('files_external');
220
-			if ($storage_view) {
221
-				return \OC::$server->getConfig()->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data') .
222
-					$storage_view->getAbsolutePath('') .
223
-					'ssh_hostKeys';
224
-			}
225
-		} catch (\Exception $e) {
226
-		}
227
-		return false;
228
-	}
229
-
230
-	/**
231
-	 * @param $keys
232
-	 * @return bool
233
-	 */
234
-	protected function writeHostKeys($keys) {
235
-		try {
236
-			$keyPath = $this->hostKeysPath();
237
-			if ($keyPath && file_exists($keyPath)) {
238
-				$fp = fopen($keyPath, 'w');
239
-				foreach ($keys as $host => $key) {
240
-					fwrite($fp, $host . '::' . $key . "\n");
241
-				}
242
-				fclose($fp);
243
-				return true;
244
-			}
245
-		} catch (\Exception $e) {
246
-		}
247
-		return false;
248
-	}
249
-
250
-	/**
251
-	 * @return array
252
-	 */
253
-	protected function readHostKeys() {
254
-		try {
255
-			$keyPath = $this->hostKeysPath();
256
-			if (file_exists($keyPath)) {
257
-				$hosts = [];
258
-				$keys = [];
259
-				$lines = file($keyPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
260
-				if ($lines) {
261
-					foreach ($lines as $line) {
262
-						$hostKeyArray = explode("::", $line, 2);
263
-						if (count($hostKeyArray) === 2) {
264
-							$hosts[] = $hostKeyArray[0];
265
-							$keys[] = $hostKeyArray[1];
266
-						}
267
-					}
268
-					return array_combine($hosts, $keys);
269
-				}
270
-			}
271
-		} catch (\Exception $e) {
272
-		}
273
-		return [];
274
-	}
275
-
276
-	/**
277
-	 * {@inheritdoc}
278
-	 */
279
-	public function mkdir($path) {
280
-		try {
281
-			return $this->getConnection()->mkdir($this->absPath($path));
282
-		} catch (\Exception $e) {
283
-			return false;
284
-		}
285
-	}
286
-
287
-	/**
288
-	 * {@inheritdoc}
289
-	 */
290
-	public function rmdir($path) {
291
-		try {
292
-			$result = $this->getConnection()->delete($this->absPath($path), true);
293
-			// workaround: stray stat cache entry when deleting empty folders
294
-			// see https://github.com/phpseclib/phpseclib/issues/706
295
-			$this->getConnection()->clearStatCache();
296
-			return $result;
297
-		} catch (\Exception $e) {
298
-			return false;
299
-		}
300
-	}
301
-
302
-	/**
303
-	 * {@inheritdoc}
304
-	 */
305
-	public function opendir($path) {
306
-		try {
307
-			$list = $this->getConnection()->nlist($this->absPath($path));
308
-			if ($list === false) {
309
-				return false;
310
-			}
311
-
312
-			$id = md5('sftp:' . $path);
313
-			$dirStream = [];
314
-			foreach ($list as $file) {
315
-				if ($file !== '.' && $file !== '..') {
316
-					$dirStream[] = $file;
317
-				}
318
-			}
319
-			return IteratorDirectory::wrap($dirStream);
320
-		} catch (\Exception $e) {
321
-			return false;
322
-		}
323
-	}
324
-
325
-	/**
326
-	 * {@inheritdoc}
327
-	 */
328
-	public function filetype($path) {
329
-		try {
330
-			$stat = $this->getConnection()->stat($this->absPath($path));
331
-			if ((int) $stat['type'] === NET_SFTP_TYPE_REGULAR) {
332
-				return 'file';
333
-			}
334
-
335
-			if ((int) $stat['type'] === NET_SFTP_TYPE_DIRECTORY) {
336
-				return 'dir';
337
-			}
338
-		} catch (\Exception $e) {
339
-		}
340
-		return false;
341
-	}
342
-
343
-	/**
344
-	 * {@inheritdoc}
345
-	 */
346
-	public function file_exists($path) {
347
-		try {
348
-			return $this->getConnection()->stat($this->absPath($path)) !== false;
349
-		} catch (\Exception $e) {
350
-			return false;
351
-		}
352
-	}
353
-
354
-	/**
355
-	 * {@inheritdoc}
356
-	 */
357
-	public function unlink($path) {
358
-		try {
359
-			return $this->getConnection()->delete($this->absPath($path), true);
360
-		} catch (\Exception $e) {
361
-			return false;
362
-		}
363
-	}
364
-
365
-	/**
366
-	 * {@inheritdoc}
367
-	 */
368
-	public function fopen($path, $mode) {
369
-		try {
370
-			$absPath = $this->absPath($path);
371
-			switch ($mode) {
372
-				case 'r':
373
-				case 'rb':
374
-					if (!$this->file_exists($path)) {
375
-						return false;
376
-					}
377
-					SFTPReadStream::register();
378
-					$context = stream_context_create(['sftp' => ['session' => $this->getConnection()]]);
379
-					$handle = fopen('sftpread://' . trim($absPath, '/'), 'r', false, $context);
380
-					return RetryWrapper::wrap($handle);
381
-				case 'w':
382
-				case 'wb':
383
-					SFTPWriteStream::register();
384
-					$context = stream_context_create(['sftp' => ['session' => $this->getConnection()]]);
385
-					return fopen('sftpwrite://' . trim($absPath, '/'), 'w', false, $context);
386
-				case 'a':
387
-				case 'ab':
388
-				case 'r+':
389
-				case 'w+':
390
-				case 'wb+':
391
-				case 'a+':
392
-				case 'x':
393
-				case 'x+':
394
-				case 'c':
395
-				case 'c+':
396
-					$context = stream_context_create(['sftp' => ['session' => $this->getConnection()]]);
397
-					$handle = fopen($this->constructUrl($path), $mode, false, $context);
398
-					return RetryWrapper::wrap($handle);
399
-			}
400
-		} catch (\Exception $e) {
401
-		}
402
-		return false;
403
-	}
404
-
405
-	/**
406
-	 * {@inheritdoc}
407
-	 */
408
-	public function touch($path, $mtime = null) {
409
-		try {
410
-			if (!is_null($mtime)) {
411
-				return false;
412
-			}
413
-			if (!$this->file_exists($path)) {
414
-				$this->getConnection()->put($this->absPath($path), '');
415
-			} else {
416
-				return false;
417
-			}
418
-		} catch (\Exception $e) {
419
-			return false;
420
-		}
421
-		return true;
422
-	}
423
-
424
-	/**
425
-	 * @param string $path
426
-	 * @param string $target
427
-	 * @throws \Exception
428
-	 */
429
-	public function getFile($path, $target) {
430
-		$this->getConnection()->get($path, $target);
431
-	}
432
-
433
-	/**
434
-	 * {@inheritdoc}
435
-	 */
436
-	public function rename($source, $target) {
437
-		try {
438
-			if ($this->file_exists($target)) {
439
-				$this->unlink($target);
440
-			}
441
-			return $this->getConnection()->rename(
442
-				$this->absPath($source),
443
-				$this->absPath($target)
444
-			);
445
-		} catch (\Exception $e) {
446
-			return false;
447
-		}
448
-	}
449
-
450
-	/**
451
-	 * {@inheritdoc}
452
-	 */
453
-	public function stat($path) {
454
-		try {
455
-			$stat = $this->getConnection()->stat($this->absPath($path));
456
-
457
-			$mtime = $stat ? $stat['mtime'] : -1;
458
-			$size = $stat ? $stat['size'] : 0;
459
-
460
-			return ['mtime' => $mtime, 'size' => $size, 'ctime' => -1];
461
-		} catch (\Exception $e) {
462
-			return false;
463
-		}
464
-	}
465
-
466
-	/**
467
-	 * @param string $path
468
-	 * @return string
469
-	 */
470
-	public function constructUrl($path) {
471
-		// Do not pass the password here. We want to use the Net_SFTP object
472
-		// supplied via stream context or fail. We only supply username and
473
-		// hostname because this might show up in logs (they are not used).
474
-		$url = 'sftp://' . urlencode($this->user) . '@' . $this->host . ':' . $this->port . $this->root . $path;
475
-		return $url;
476
-	}
49
+    private $host;
50
+    private $user;
51
+    private $root;
52
+    private $port = 22;
53
+
54
+    private $auth = [];
55
+
56
+    /**
57
+     * @var \phpseclib\Net\SFTP
58
+     */
59
+    protected $client;
60
+
61
+    /**
62
+     * @param string $host protocol://server:port
63
+     * @return array [$server, $port]
64
+     */
65
+    private function splitHost($host) {
66
+        $input = $host;
67
+        if (strpos($host, '://') === false) {
68
+            // add a protocol to fix parse_url behavior with ipv6
69
+            $host = 'http://' . $host;
70
+        }
71
+
72
+        $parsed = parse_url($host);
73
+        if (is_array($parsed) && isset($parsed['port'])) {
74
+            return [$parsed['host'], $parsed['port']];
75
+        } elseif (is_array($parsed)) {
76
+            return [$parsed['host'], 22];
77
+        } else {
78
+            return [$input, 22];
79
+        }
80
+    }
81
+
82
+    /**
83
+     * {@inheritdoc}
84
+     */
85
+    public function __construct($params) {
86
+        // Register sftp://
87
+        Stream::register();
88
+
89
+        $parsedHost = $this->splitHost($params['host']);
90
+
91
+        $this->host = $parsedHost[0];
92
+        $this->port = $parsedHost[1];
93
+
94
+        if (!isset($params['user'])) {
95
+            throw new \UnexpectedValueException('no authentication parameters specified');
96
+        }
97
+        $this->user = $params['user'];
98
+
99
+        if (isset($params['public_key_auth'])) {
100
+            $this->auth[] = $params['public_key_auth'];
101
+        }
102
+        if (isset($params['password']) && $params['password'] !== '') {
103
+            $this->auth[] = $params['password'];
104
+        }
105
+
106
+        if ($this->auth === []) {
107
+            throw new \UnexpectedValueException('no authentication parameters specified');
108
+        }
109
+
110
+        $this->root
111
+            = isset($params['root']) ? $this->cleanPath($params['root']) : '/';
112
+
113
+        $this->root = '/' . ltrim($this->root, '/');
114
+        $this->root = rtrim($this->root, '/') . '/';
115
+    }
116
+
117
+    /**
118
+     * Returns the connection.
119
+     *
120
+     * @return \phpseclib\Net\SFTP connected client instance
121
+     * @throws \Exception when the connection failed
122
+     */
123
+    public function getConnection() {
124
+        if (!is_null($this->client)) {
125
+            return $this->client;
126
+        }
127
+
128
+        $hostKeys = $this->readHostKeys();
129
+        $this->client = new \phpseclib\Net\SFTP($this->host, $this->port);
130
+
131
+        // The SSH Host Key MUST be verified before login().
132
+        $currentHostKey = $this->client->getServerPublicHostKey();
133
+        if (array_key_exists($this->host, $hostKeys)) {
134
+            if ($hostKeys[$this->host] !== $currentHostKey) {
135
+                throw new \Exception('Host public key does not match known key');
136
+            }
137
+        } else {
138
+            $hostKeys[$this->host] = $currentHostKey;
139
+            $this->writeHostKeys($hostKeys);
140
+        }
141
+
142
+        $login = false;
143
+        foreach ($this->auth as $auth) {
144
+            /** @psalm-suppress TooManyArguments */
145
+            $login = $this->client->login($this->user, $auth);
146
+            if ($login === true) {
147
+                break;
148
+            }
149
+        }
150
+
151
+        if ($login === false) {
152
+            throw new \Exception('Login failed');
153
+        }
154
+        return $this->client;
155
+    }
156
+
157
+    /**
158
+     * {@inheritdoc}
159
+     */
160
+    public function test() {
161
+        if (
162
+            !isset($this->host)
163
+            || !isset($this->user)
164
+        ) {
165
+            return false;
166
+        }
167
+        return $this->getConnection()->nlist() !== false;
168
+    }
169
+
170
+    /**
171
+     * {@inheritdoc}
172
+     */
173
+    public function getId() {
174
+        $id = 'sftp::' . $this->user . '@' . $this->host;
175
+        if ($this->port !== 22) {
176
+            $id .= ':' . $this->port;
177
+        }
178
+        // note: this will double the root slash,
179
+        // we should not change it to keep compatible with
180
+        // old storage ids
181
+        $id .= '/' . $this->root;
182
+        return $id;
183
+    }
184
+
185
+    /**
186
+     * @return string
187
+     */
188
+    public function getHost() {
189
+        return $this->host;
190
+    }
191
+
192
+    /**
193
+     * @return string
194
+     */
195
+    public function getRoot() {
196
+        return $this->root;
197
+    }
198
+
199
+    /**
200
+     * @return mixed
201
+     */
202
+    public function getUser() {
203
+        return $this->user;
204
+    }
205
+
206
+    /**
207
+     * @param string $path
208
+     * @return string
209
+     */
210
+    private function absPath($path) {
211
+        return $this->root . $this->cleanPath($path);
212
+    }
213
+
214
+    /**
215
+     * @return string|false
216
+     */
217
+    private function hostKeysPath() {
218
+        try {
219
+            $storage_view = \OCP\Files::getStorage('files_external');
220
+            if ($storage_view) {
221
+                return \OC::$server->getConfig()->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data') .
222
+                    $storage_view->getAbsolutePath('') .
223
+                    'ssh_hostKeys';
224
+            }
225
+        } catch (\Exception $e) {
226
+        }
227
+        return false;
228
+    }
229
+
230
+    /**
231
+     * @param $keys
232
+     * @return bool
233
+     */
234
+    protected function writeHostKeys($keys) {
235
+        try {
236
+            $keyPath = $this->hostKeysPath();
237
+            if ($keyPath && file_exists($keyPath)) {
238
+                $fp = fopen($keyPath, 'w');
239
+                foreach ($keys as $host => $key) {
240
+                    fwrite($fp, $host . '::' . $key . "\n");
241
+                }
242
+                fclose($fp);
243
+                return true;
244
+            }
245
+        } catch (\Exception $e) {
246
+        }
247
+        return false;
248
+    }
249
+
250
+    /**
251
+     * @return array
252
+     */
253
+    protected function readHostKeys() {
254
+        try {
255
+            $keyPath = $this->hostKeysPath();
256
+            if (file_exists($keyPath)) {
257
+                $hosts = [];
258
+                $keys = [];
259
+                $lines = file($keyPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
260
+                if ($lines) {
261
+                    foreach ($lines as $line) {
262
+                        $hostKeyArray = explode("::", $line, 2);
263
+                        if (count($hostKeyArray) === 2) {
264
+                            $hosts[] = $hostKeyArray[0];
265
+                            $keys[] = $hostKeyArray[1];
266
+                        }
267
+                    }
268
+                    return array_combine($hosts, $keys);
269
+                }
270
+            }
271
+        } catch (\Exception $e) {
272
+        }
273
+        return [];
274
+    }
275
+
276
+    /**
277
+     * {@inheritdoc}
278
+     */
279
+    public function mkdir($path) {
280
+        try {
281
+            return $this->getConnection()->mkdir($this->absPath($path));
282
+        } catch (\Exception $e) {
283
+            return false;
284
+        }
285
+    }
286
+
287
+    /**
288
+     * {@inheritdoc}
289
+     */
290
+    public function rmdir($path) {
291
+        try {
292
+            $result = $this->getConnection()->delete($this->absPath($path), true);
293
+            // workaround: stray stat cache entry when deleting empty folders
294
+            // see https://github.com/phpseclib/phpseclib/issues/706
295
+            $this->getConnection()->clearStatCache();
296
+            return $result;
297
+        } catch (\Exception $e) {
298
+            return false;
299
+        }
300
+    }
301
+
302
+    /**
303
+     * {@inheritdoc}
304
+     */
305
+    public function opendir($path) {
306
+        try {
307
+            $list = $this->getConnection()->nlist($this->absPath($path));
308
+            if ($list === false) {
309
+                return false;
310
+            }
311
+
312
+            $id = md5('sftp:' . $path);
313
+            $dirStream = [];
314
+            foreach ($list as $file) {
315
+                if ($file !== '.' && $file !== '..') {
316
+                    $dirStream[] = $file;
317
+                }
318
+            }
319
+            return IteratorDirectory::wrap($dirStream);
320
+        } catch (\Exception $e) {
321
+            return false;
322
+        }
323
+    }
324
+
325
+    /**
326
+     * {@inheritdoc}
327
+     */
328
+    public function filetype($path) {
329
+        try {
330
+            $stat = $this->getConnection()->stat($this->absPath($path));
331
+            if ((int) $stat['type'] === NET_SFTP_TYPE_REGULAR) {
332
+                return 'file';
333
+            }
334
+
335
+            if ((int) $stat['type'] === NET_SFTP_TYPE_DIRECTORY) {
336
+                return 'dir';
337
+            }
338
+        } catch (\Exception $e) {
339
+        }
340
+        return false;
341
+    }
342
+
343
+    /**
344
+     * {@inheritdoc}
345
+     */
346
+    public function file_exists($path) {
347
+        try {
348
+            return $this->getConnection()->stat($this->absPath($path)) !== false;
349
+        } catch (\Exception $e) {
350
+            return false;
351
+        }
352
+    }
353
+
354
+    /**
355
+     * {@inheritdoc}
356
+     */
357
+    public function unlink($path) {
358
+        try {
359
+            return $this->getConnection()->delete($this->absPath($path), true);
360
+        } catch (\Exception $e) {
361
+            return false;
362
+        }
363
+    }
364
+
365
+    /**
366
+     * {@inheritdoc}
367
+     */
368
+    public function fopen($path, $mode) {
369
+        try {
370
+            $absPath = $this->absPath($path);
371
+            switch ($mode) {
372
+                case 'r':
373
+                case 'rb':
374
+                    if (!$this->file_exists($path)) {
375
+                        return false;
376
+                    }
377
+                    SFTPReadStream::register();
378
+                    $context = stream_context_create(['sftp' => ['session' => $this->getConnection()]]);
379
+                    $handle = fopen('sftpread://' . trim($absPath, '/'), 'r', false, $context);
380
+                    return RetryWrapper::wrap($handle);
381
+                case 'w':
382
+                case 'wb':
383
+                    SFTPWriteStream::register();
384
+                    $context = stream_context_create(['sftp' => ['session' => $this->getConnection()]]);
385
+                    return fopen('sftpwrite://' . trim($absPath, '/'), 'w', false, $context);
386
+                case 'a':
387
+                case 'ab':
388
+                case 'r+':
389
+                case 'w+':
390
+                case 'wb+':
391
+                case 'a+':
392
+                case 'x':
393
+                case 'x+':
394
+                case 'c':
395
+                case 'c+':
396
+                    $context = stream_context_create(['sftp' => ['session' => $this->getConnection()]]);
397
+                    $handle = fopen($this->constructUrl($path), $mode, false, $context);
398
+                    return RetryWrapper::wrap($handle);
399
+            }
400
+        } catch (\Exception $e) {
401
+        }
402
+        return false;
403
+    }
404
+
405
+    /**
406
+     * {@inheritdoc}
407
+     */
408
+    public function touch($path, $mtime = null) {
409
+        try {
410
+            if (!is_null($mtime)) {
411
+                return false;
412
+            }
413
+            if (!$this->file_exists($path)) {
414
+                $this->getConnection()->put($this->absPath($path), '');
415
+            } else {
416
+                return false;
417
+            }
418
+        } catch (\Exception $e) {
419
+            return false;
420
+        }
421
+        return true;
422
+    }
423
+
424
+    /**
425
+     * @param string $path
426
+     * @param string $target
427
+     * @throws \Exception
428
+     */
429
+    public function getFile($path, $target) {
430
+        $this->getConnection()->get($path, $target);
431
+    }
432
+
433
+    /**
434
+     * {@inheritdoc}
435
+     */
436
+    public function rename($source, $target) {
437
+        try {
438
+            if ($this->file_exists($target)) {
439
+                $this->unlink($target);
440
+            }
441
+            return $this->getConnection()->rename(
442
+                $this->absPath($source),
443
+                $this->absPath($target)
444
+            );
445
+        } catch (\Exception $e) {
446
+            return false;
447
+        }
448
+    }
449
+
450
+    /**
451
+     * {@inheritdoc}
452
+     */
453
+    public function stat($path) {
454
+        try {
455
+            $stat = $this->getConnection()->stat($this->absPath($path));
456
+
457
+            $mtime = $stat ? $stat['mtime'] : -1;
458
+            $size = $stat ? $stat['size'] : 0;
459
+
460
+            return ['mtime' => $mtime, 'size' => $size, 'ctime' => -1];
461
+        } catch (\Exception $e) {
462
+            return false;
463
+        }
464
+    }
465
+
466
+    /**
467
+     * @param string $path
468
+     * @return string
469
+     */
470
+    public function constructUrl($path) {
471
+        // Do not pass the password here. We want to use the Net_SFTP object
472
+        // supplied via stream context or fail. We only supply username and
473
+        // hostname because this might show up in logs (they are not used).
474
+        $url = 'sftp://' . urlencode($this->user) . '@' . $this->host . ':' . $this->port . $this->root . $path;
475
+        return $url;
476
+    }
477 477
 }
Please login to merge, or discard this patch.