1<?php
2/**
3 * @copyright Copyright (c) 2016, ownCloud, Inc.
4 *
5 * @author Bjoern Schiessle <bjoern@schiessle.org>
6 * @author Björn Schießle <bjoern@schiessle.org>
7 * @author Christoph Wurst <christoph@winzerhof-wurst.at>
8 * @author Clark Tomlinson <fallen013@gmail.com>
9 * @author Joas Schilling <coding@schilljs.com>
10 * @author Lukas Reschke <lukas@statuscode.ch>
11 * @author Morris Jobke <hey@morrisjobke.de>
12 * @author Roeland Jago Douma <roeland@famdouma.nl>
13 * @author Stefan Weiberg <sweiberg@suse.com>
14 * @author Thomas Müller <thomas.mueller@tmit.eu>
15 *
16 * @license AGPL-3.0
17 *
18 * This code is free software: you can redistribute it and/or modify
19 * it under the terms of the GNU Affero General Public License, version 3,
20 * as published by the Free Software Foundation.
21 *
22 * This program is distributed in the hope that it will be useful,
23 * but WITHOUT ANY WARRANTY; without even the implied warranty of
24 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
25 * GNU Affero General Public License for more details.
26 *
27 * You should have received a copy of the GNU Affero General Public License, version 3,
28 * along with this program. If not, see <http://www.gnu.org/licenses/>
29 *
30 */
31namespace OCA\Encryption\Crypto;
32
33use OC\Encryption\Exceptions\DecryptionFailedException;
34use OC\Encryption\Exceptions\EncryptionFailedException;
35use OC\ServerNotAvailableException;
36use OCA\Encryption\Exceptions\MultiKeyDecryptException;
37use OCA\Encryption\Exceptions\MultiKeyEncryptException;
38use OCP\Encryption\Exceptions\GenericEncryptionException;
39use OCP\IConfig;
40use OCP\IL10N;
41use OCP\ILogger;
42use OCP\IUserSession;
43
44/**
45 * Class Crypt provides the encryption implementation of the default Nextcloud
46 * encryption module. As default AES-256-CTR is used, it does however offer support
47 * for the following modes:
48 *
49 * - AES-256-CTR
50 * - AES-128-CTR
51 * - AES-256-CFB
52 * - AES-128-CFB
53 *
54 * For integrity protection Encrypt-Then-MAC using HMAC-SHA256 is used.
55 *
56 * @package OCA\Encryption\Crypto
57 */
58class Crypt {
59	public const SUPPORTED_CIPHERS_AND_KEY_SIZE = [
60		'AES-256-CTR' => 32,
61		'AES-128-CTR' => 16,
62		'AES-256-CFB' => 32,
63		'AES-128-CFB' => 16,
64	];
65	// one out of SUPPORTED_CIPHERS_AND_KEY_SIZE
66	public const DEFAULT_CIPHER = 'AES-256-CTR';
67	// default cipher from old Nextcloud versions
68	public const LEGACY_CIPHER = 'AES-128-CFB';
69
70	public const SUPPORTED_KEY_FORMATS = ['hash', 'password'];
71	// one out of SUPPORTED_KEY_FORMATS
72	public const DEFAULT_KEY_FORMAT = 'hash';
73	// default key format, old Nextcloud version encrypted the private key directly
74	// with the user password
75	public const LEGACY_KEY_FORMAT = 'password';
76
77	public const HEADER_START = 'HBEGIN';
78	public const HEADER_END = 'HEND';
79
80	/** @var ILogger */
81	private $logger;
82
83	/** @var string */
84	private $user;
85
86	/** @var IConfig */
87	private $config;
88
89	/** @var IL10N */
90	private $l;
91
92	/** @var string|null */
93	private $currentCipher;
94
95	/** @var bool */
96	private $supportLegacy;
97
98	/**
99	 * @param ILogger $logger
100	 * @param IUserSession $userSession
101	 * @param IConfig $config
102	 * @param IL10N $l
103	 */
104	public function __construct(ILogger $logger, IUserSession $userSession, IConfig $config, IL10N $l) {
105		$this->logger = $logger;
106		$this->user = $userSession && $userSession->isLoggedIn() ? $userSession->getUser()->getUID() : '"no user given"';
107		$this->config = $config;
108		$this->l = $l;
109		$this->supportLegacy = $this->config->getSystemValueBool('encryption.legacy_format_support', false);
110	}
111
112	/**
113	 * create new private/public key-pair for user
114	 *
115	 * @return array|bool
116	 */
117	public function createKeyPair() {
118		$log = $this->logger;
119		$res = $this->getOpenSSLPKey();
120
121		if (!$res) {
122			$log->error("Encryption Library couldn't generate users key-pair for {$this->user}",
123				['app' => 'encryption']);
124
125			if (openssl_error_string()) {
126				$log->error('Encryption library openssl_pkey_new() fails: ' . openssl_error_string(),
127					['app' => 'encryption']);
128			}
129		} elseif (openssl_pkey_export($res,
130			$privateKey,
131			null,
132			$this->getOpenSSLConfig())) {
133			$keyDetails = openssl_pkey_get_details($res);
134			$publicKey = $keyDetails['key'];
135
136			return [
137				'publicKey' => $publicKey,
138				'privateKey' => $privateKey
139			];
140		}
141		$log->error('Encryption library couldn\'t export users private key, please check your servers OpenSSL configuration.' . $this->user,
142			['app' => 'encryption']);
143		if (openssl_error_string()) {
144			$log->error('Encryption Library:' . openssl_error_string(),
145				['app' => 'encryption']);
146		}
147
148		return false;
149	}
150
151	/**
152	 * Generates a new private key
153	 *
154	 * @return resource
155	 */
156	public function getOpenSSLPKey() {
157		$config = $this->getOpenSSLConfig();
158		return openssl_pkey_new($config);
159	}
160
161	/**
162	 * get openSSL Config
163	 *
164	 * @return array
165	 */
166	private function getOpenSSLConfig() {
167		$config = ['private_key_bits' => 4096];
168		$config = array_merge(
169			$config,
170			$this->config->getSystemValue('openssl', [])
171		);
172		return $config;
173	}
174
175	/**
176	 * @param string $plainContent
177	 * @param string $passPhrase
178	 * @param int $version
179	 * @param int $position
180	 * @return false|string
181	 * @throws EncryptionFailedException
182	 */
183	public function symmetricEncryptFileContent($plainContent, $passPhrase, $version, $position) {
184		if (!$plainContent) {
185			$this->logger->error('Encryption Library, symmetrical encryption failed no content given',
186				['app' => 'encryption']);
187			return false;
188		}
189
190		$iv = $this->generateIv();
191
192		$encryptedContent = $this->encrypt($plainContent,
193			$iv,
194			$passPhrase,
195			$this->getCipher());
196
197		// Create a signature based on the key as well as the current version
198		$sig = $this->createSignature($encryptedContent, $passPhrase.'_'.$version.'_'.$position);
199
200		// combine content to encrypt the IV identifier and actual IV
201		$catFile = $this->concatIV($encryptedContent, $iv);
202		$catFile = $this->concatSig($catFile, $sig);
203		return $this->addPadding($catFile);
204	}
205
206	/**
207	 * generate header for encrypted file
208	 *
209	 * @param string $keyFormat see SUPPORTED_KEY_FORMATS
210	 * @return string
211	 * @throws \InvalidArgumentException
212	 */
213	public function generateHeader($keyFormat = self::DEFAULT_KEY_FORMAT) {
214		if (in_array($keyFormat, self::SUPPORTED_KEY_FORMATS, true) === false) {
215			throw new \InvalidArgumentException('key format "' . $keyFormat . '" is not supported');
216		}
217
218		$cipher = $this->getCipher();
219
220		$header = self::HEADER_START
221			. ':cipher:' . $cipher
222			. ':keyFormat:' . $keyFormat
223			. ':' . self::HEADER_END;
224
225		return $header;
226	}
227
228	/**
229	 * @param string $plainContent
230	 * @param string $iv
231	 * @param string $passPhrase
232	 * @param string $cipher
233	 * @return string
234	 * @throws EncryptionFailedException
235	 */
236	private function encrypt($plainContent, $iv, $passPhrase = '', $cipher = self::DEFAULT_CIPHER) {
237		$encryptedContent = openssl_encrypt($plainContent,
238			$cipher,
239			$passPhrase,
240			false,
241			$iv);
242
243		if (!$encryptedContent) {
244			$error = 'Encryption (symmetric) of content failed';
245			$this->logger->error($error . openssl_error_string(),
246				['app' => 'encryption']);
247			throw new EncryptionFailedException($error);
248		}
249
250		return $encryptedContent;
251	}
252
253	/**
254	 * return cipher either from config.php or the default cipher defined in
255	 * this class
256	 *
257	 * @return string
258	 */
259	private function getCachedCipher() {
260		if (isset($this->currentCipher)) {
261			return $this->currentCipher;
262		}
263
264		// Get cipher either from config.php or the default cipher defined in this class
265		$cipher = $this->config->getSystemValue('cipher', self::DEFAULT_CIPHER);
266		if (!isset(self::SUPPORTED_CIPHERS_AND_KEY_SIZE[$cipher])) {
267			$this->logger->warning(
268				sprintf(
269					'Unsupported cipher (%s) defined in config.php supported. Falling back to %s',
270					$cipher,
271					self::DEFAULT_CIPHER
272				),
273				['app' => 'encryption']
274			);
275			$cipher = self::DEFAULT_CIPHER;
276		}
277
278		// Remember current cipher to avoid frequent lookups
279		$this->currentCipher = $cipher;
280		return $this->currentCipher;
281	}
282
283	/**
284	 * return current encryption cipher
285	 *
286	 * @return string
287	 */
288	public function getCipher() {
289		return $this->getCachedCipher();
290	}
291
292	/**
293	 * get key size depending on the cipher
294	 *
295	 * @param string $cipher
296	 * @return int
297	 * @throws \InvalidArgumentException
298	 */
299	protected function getKeySize($cipher) {
300		if (isset(self::SUPPORTED_CIPHERS_AND_KEY_SIZE[$cipher])) {
301			return self::SUPPORTED_CIPHERS_AND_KEY_SIZE[$cipher];
302		}
303
304		throw new \InvalidArgumentException(
305			sprintf(
306					'Unsupported cipher (%s) defined.',
307					$cipher
308			)
309		);
310	}
311
312	/**
313	 * get legacy cipher
314	 *
315	 * @return string
316	 */
317	public function getLegacyCipher() {
318		if (!$this->supportLegacy) {
319			throw new ServerNotAvailableException('Legacy cipher is no longer supported!');
320		}
321
322		return self::LEGACY_CIPHER;
323	}
324
325	/**
326	 * @param string $encryptedContent
327	 * @param string $iv
328	 * @return string
329	 */
330	private function concatIV($encryptedContent, $iv) {
331		return $encryptedContent . '00iv00' . $iv;
332	}
333
334	/**
335	 * @param string $encryptedContent
336	 * @param string $signature
337	 * @return string
338	 */
339	private function concatSig($encryptedContent, $signature) {
340		return $encryptedContent . '00sig00' . $signature;
341	}
342
343	/**
344	 * Note: This is _NOT_ a padding used for encryption purposes. It is solely
345	 * used to achieve the PHP stream size. It has _NOTHING_ to do with the
346	 * encrypted content and is not used in any crypto primitive.
347	 *
348	 * @param string $data
349	 * @return string
350	 */
351	private function addPadding($data) {
352		return $data . 'xxx';
353	}
354
355	/**
356	 * generate password hash used to encrypt the users private key
357	 *
358	 * @param string $password
359	 * @param string $cipher
360	 * @param string $uid only used for user keys
361	 * @return string
362	 */
363	protected function generatePasswordHash($password, $cipher, $uid = '') {
364		$instanceId = $this->config->getSystemValue('instanceid');
365		$instanceSecret = $this->config->getSystemValue('secret');
366		$salt = hash('sha256', $uid . $instanceId . $instanceSecret, true);
367		$keySize = $this->getKeySize($cipher);
368
369		$hash = hash_pbkdf2(
370			'sha256',
371			$password,
372			$salt,
373			100000,
374			$keySize,
375			true
376		);
377
378		return $hash;
379	}
380
381	/**
382	 * encrypt private key
383	 *
384	 * @param string $privateKey
385	 * @param string $password
386	 * @param string $uid for regular users, empty for system keys
387	 * @return false|string
388	 */
389	public function encryptPrivateKey($privateKey, $password, $uid = '') {
390		$cipher = $this->getCipher();
391		$hash = $this->generatePasswordHash($password, $cipher, $uid);
392		$encryptedKey = $this->symmetricEncryptFileContent(
393			$privateKey,
394			$hash,
395			0,
396			0
397		);
398
399		return $encryptedKey;
400	}
401
402	/**
403	 * @param string $privateKey
404	 * @param string $password
405	 * @param string $uid for regular users, empty for system keys
406	 * @return false|string
407	 */
408	public function decryptPrivateKey($privateKey, $password = '', $uid = '') {
409		$header = $this->parseHeader($privateKey);
410
411		if (isset($header['cipher'])) {
412			$cipher = $header['cipher'];
413		} else {
414			$cipher = $this->getLegacyCipher();
415		}
416
417		if (isset($header['keyFormat'])) {
418			$keyFormat = $header['keyFormat'];
419		} else {
420			$keyFormat = self::LEGACY_KEY_FORMAT;
421		}
422
423		if ($keyFormat === self::DEFAULT_KEY_FORMAT) {
424			$password = $this->generatePasswordHash($password, $cipher, $uid);
425		}
426
427		// If we found a header we need to remove it from the key we want to decrypt
428		if (!empty($header)) {
429			$privateKey = substr($privateKey,
430				strpos($privateKey,
431					self::HEADER_END) + strlen(self::HEADER_END));
432		}
433
434		$plainKey = $this->symmetricDecryptFileContent(
435			$privateKey,
436			$password,
437			$cipher,
438			0
439		);
440
441		if ($this->isValidPrivateKey($plainKey) === false) {
442			return false;
443		}
444
445		return $plainKey;
446	}
447
448	/**
449	 * check if it is a valid private key
450	 *
451	 * @param string $plainKey
452	 * @return bool
453	 */
454	protected function isValidPrivateKey($plainKey) {
455		$res = openssl_get_privatekey($plainKey);
456		// TODO: remove resource check one php7.4 is not longer supported
457		if (is_resource($res) || (is_object($res) && get_class($res) === 'OpenSSLAsymmetricKey')) {
458			$sslInfo = openssl_pkey_get_details($res);
459			if (isset($sslInfo['key'])) {
460				return true;
461			}
462		}
463
464		return false;
465	}
466
467	/**
468	 * @param string $keyFileContents
469	 * @param string $passPhrase
470	 * @param string $cipher
471	 * @param int $version
472	 * @param int|string $position
473	 * @return string
474	 * @throws DecryptionFailedException
475	 */
476	public function symmetricDecryptFileContent($keyFileContents, $passPhrase, $cipher = self::DEFAULT_CIPHER, $version = 0, $position = 0) {
477		if ($keyFileContents == '') {
478			return '';
479		}
480
481		$catFile = $this->splitMetaData($keyFileContents, $cipher);
482
483		if ($catFile['signature'] !== false) {
484			try {
485				// First try the new format
486				$this->checkSignature($catFile['encrypted'], $passPhrase . '_' . $version . '_' . $position, $catFile['signature']);
487			} catch (GenericEncryptionException $e) {
488				// For compatibility with old files check the version without _
489				$this->checkSignature($catFile['encrypted'], $passPhrase . $version . $position, $catFile['signature']);
490			}
491		}
492
493		return $this->decrypt($catFile['encrypted'],
494			$catFile['iv'],
495			$passPhrase,
496			$cipher);
497	}
498
499	/**
500	 * check for valid signature
501	 *
502	 * @param string $data
503	 * @param string $passPhrase
504	 * @param string $expectedSignature
505	 * @throws GenericEncryptionException
506	 */
507	private function checkSignature($data, $passPhrase, $expectedSignature) {
508		$enforceSignature = !$this->config->getSystemValue('encryption_skip_signature_check', false);
509
510		$signature = $this->createSignature($data, $passPhrase);
511		$isCorrectHash = hash_equals($expectedSignature, $signature);
512
513		if (!$isCorrectHash && $enforceSignature) {
514			throw new GenericEncryptionException('Bad Signature', $this->l->t('Bad Signature'));
515		} elseif (!$isCorrectHash && !$enforceSignature) {
516			$this->logger->info("Signature check skipped", ['app' => 'encryption']);
517		}
518	}
519
520	/**
521	 * create signature
522	 *
523	 * @param string $data
524	 * @param string $passPhrase
525	 * @return string
526	 */
527	private function createSignature($data, $passPhrase) {
528		$passPhrase = hash('sha512', $passPhrase . 'a', true);
529		return hash_hmac('sha256', $data, $passPhrase);
530	}
531
532
533	/**
534	 * remove padding
535	 *
536	 * @param string $padded
537	 * @param bool $hasSignature did the block contain a signature, in this case we use a different padding
538	 * @return string|false
539	 */
540	private function removePadding($padded, $hasSignature = false) {
541		if ($hasSignature === false && substr($padded, -2) === 'xx') {
542			return substr($padded, 0, -2);
543		} elseif ($hasSignature === true && substr($padded, -3) === 'xxx') {
544			return substr($padded, 0, -3);
545		}
546		return false;
547	}
548
549	/**
550	 * split meta data from encrypted file
551	 * Note: for now, we assume that the meta data always start with the iv
552	 *       followed by the signature, if available
553	 *
554	 * @param string $catFile
555	 * @param string $cipher
556	 * @return array
557	 */
558	private function splitMetaData($catFile, $cipher) {
559		if ($this->hasSignature($catFile, $cipher)) {
560			$catFile = $this->removePadding($catFile, true);
561			$meta = substr($catFile, -93);
562			$iv = substr($meta, strlen('00iv00'), 16);
563			$sig = substr($meta, 22 + strlen('00sig00'));
564			$encrypted = substr($catFile, 0, -93);
565		} else {
566			$catFile = $this->removePadding($catFile);
567			$meta = substr($catFile, -22);
568			$iv = substr($meta, -16);
569			$sig = false;
570			$encrypted = substr($catFile, 0, -22);
571		}
572
573		return [
574			'encrypted' => $encrypted,
575			'iv' => $iv,
576			'signature' => $sig
577		];
578	}
579
580	/**
581	 * check if encrypted block is signed
582	 *
583	 * @param string $catFile
584	 * @param string $cipher
585	 * @return bool
586	 * @throws GenericEncryptionException
587	 */
588	private function hasSignature($catFile, $cipher) {
589		$skipSignatureCheck = $this->config->getSystemValue('encryption_skip_signature_check', false);
590
591		$meta = substr($catFile, -93);
592		$signaturePosition = strpos($meta, '00sig00');
593
594		// If we no longer support the legacy format then everything needs a signature
595		if (!$skipSignatureCheck && !$this->supportLegacy && $signaturePosition === false) {
596			throw new GenericEncryptionException('Missing Signature', $this->l->t('Missing Signature'));
597		}
598
599		// Enforce signature for the new 'CTR' ciphers
600		if (!$skipSignatureCheck && $signaturePosition === false && stripos($cipher, 'ctr') !== false) {
601			throw new GenericEncryptionException('Missing Signature', $this->l->t('Missing Signature'));
602		}
603
604		return ($signaturePosition !== false);
605	}
606
607
608	/**
609	 * @param string $encryptedContent
610	 * @param string $iv
611	 * @param string $passPhrase
612	 * @param string $cipher
613	 * @return string
614	 * @throws DecryptionFailedException
615	 */
616	private function decrypt($encryptedContent, $iv, $passPhrase = '', $cipher = self::DEFAULT_CIPHER) {
617		$plainContent = openssl_decrypt($encryptedContent,
618			$cipher,
619			$passPhrase,
620			false,
621			$iv);
622
623		if ($plainContent) {
624			return $plainContent;
625		} else {
626			throw new DecryptionFailedException('Encryption library: Decryption (symmetric) of content failed: ' . openssl_error_string());
627		}
628	}
629
630	/**
631	 * @param string $data
632	 * @return array
633	 */
634	protected function parseHeader($data) {
635		$result = [];
636
637		if (substr($data, 0, strlen(self::HEADER_START)) === self::HEADER_START) {
638			$endAt = strpos($data, self::HEADER_END);
639			$header = substr($data, 0, $endAt + strlen(self::HEADER_END));
640
641			// +1 not to start with an ':' which would result in empty element at the beginning
642			$exploded = explode(':',
643				substr($header, strlen(self::HEADER_START) + 1));
644
645			$element = array_shift($exploded);
646
647			while ($element !== self::HEADER_END) {
648				$result[$element] = array_shift($exploded);
649				$element = array_shift($exploded);
650			}
651		}
652
653		return $result;
654	}
655
656	/**
657	 * generate initialization vector
658	 *
659	 * @return string
660	 * @throws GenericEncryptionException
661	 */
662	private function generateIv() {
663		return random_bytes(16);
664	}
665
666	/**
667	 * Generate a cryptographically secure pseudo-random 256-bit ASCII key, used
668	 * as file key
669	 *
670	 * @return string
671	 * @throws \Exception
672	 */
673	public function generateFileKey() {
674		return random_bytes(32);
675	}
676
677	/**
678	 * @param $encKeyFile
679	 * @param $shareKey
680	 * @param $privateKey
681	 * @return string
682	 * @throws MultiKeyDecryptException
683	 */
684	public function multiKeyDecrypt($encKeyFile, $shareKey, $privateKey) {
685		if (!$encKeyFile) {
686			throw new MultiKeyDecryptException('Cannot multikey decrypt empty plain content');
687		}
688
689		if (openssl_open($encKeyFile, $plainContent, $shareKey, $privateKey, 'RC4')) {
690			return $plainContent;
691		} else {
692			throw new MultiKeyDecryptException('multikeydecrypt with share key failed:' . openssl_error_string());
693		}
694	}
695
696	/**
697	 * @param string $plainContent
698	 * @param array $keyFiles
699	 * @return array
700	 * @throws MultiKeyEncryptException
701	 */
702	public function multiKeyEncrypt($plainContent, array $keyFiles) {
703		// openssl_seal returns false without errors if plaincontent is empty
704		// so trigger our own error
705		if (empty($plainContent)) {
706			throw new MultiKeyEncryptException('Cannot multikeyencrypt empty plain content');
707		}
708
709		// Set empty vars to be set by openssl by reference
710		$sealed = '';
711		$shareKeys = [];
712		$mappedShareKeys = [];
713
714		if (openssl_seal($plainContent, $sealed, $shareKeys, $keyFiles, 'RC4')) {
715			$i = 0;
716
717			// Ensure each shareKey is labelled with its corresponding key id
718			foreach ($keyFiles as $userId => $publicKey) {
719				$mappedShareKeys[$userId] = $shareKeys[$i];
720				$i++;
721			}
722
723			return [
724				'keys' => $mappedShareKeys,
725				'data' => $sealed
726			];
727		} else {
728			throw new MultiKeyEncryptException('multikeyencryption failed ' . openssl_error_string());
729		}
730	}
731}
732