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