1<?php 2 3/** 4 +-------------------------------------------------------------------------+ 5 | Engine of the Enigma Plugin | 6 | | 7 | Copyright (C) The Roundcube Dev Team | 8 | | 9 | Licensed under the GNU General Public License version 3 or | 10 | any later version with exceptions for skins & plugins. | 11 | See the README file for a full license statement. | 12 +-------------------------------------------------------------------------+ 13 | Author: Aleksander Machniak <alec@alec.pl> | 14 +-------------------------------------------------------------------------+ 15*/ 16 17/** 18 * Enigma plugin engine. 19 * 20 * RFC2440: OpenPGP Message Format 21 * RFC3156: MIME Security with OpenPGP 22 * RFC3851: S/MIME 23 */ 24class enigma_engine 25{ 26 private $rc; 27 private $enigma; 28 private $pgp_driver; 29 private $smime_driver; 30 private $password_time; 31 private $cache = []; 32 33 public $decryptions = []; 34 public $signatures = []; 35 public $encrypted_parts = []; 36 37 const ENCRYPTED_PARTIALLY = 100; 38 39 const SIGN_MODE_BODY = 1; 40 const SIGN_MODE_SEPARATE = 2; 41 const SIGN_MODE_MIME = 4; 42 43 const ENCRYPT_MODE_BODY = 1; 44 const ENCRYPT_MODE_MIME = 2; 45 const ENCRYPT_MODE_SIGN = 4; 46 47 48 /** 49 * Plugin initialization. 50 */ 51 function __construct($enigma) 52 { 53 $this->rc = rcmail::get_instance(); 54 $this->enigma = $enigma; 55 56 $this->password_time = $this->rc->config->get('enigma_password_time') * 60; 57 58 // this will remove passwords from session after some time 59 if ($this->password_time) { 60 $this->get_passwords(); 61 } 62 } 63 64 /** 65 * PGP driver initialization. 66 */ 67 function load_pgp_driver() 68 { 69 if ($this->pgp_driver) { 70 return; 71 } 72 73 $driver = 'enigma_driver_' . $this->rc->config->get('enigma_pgp_driver', 'gnupg'); 74 $username = $this->rc->user->get_username(); 75 76 // Load driver 77 $this->pgp_driver = new $driver($username); 78 79 if (!$this->pgp_driver) { 80 rcube::raise_error([ 81 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 82 'message' => "Enigma plugin: Unable to load PGP driver: $driver" 83 ], true, true 84 ); 85 } 86 87 // Initialise driver 88 $result = $this->pgp_driver->init(); 89 90 if ($result instanceof enigma_error) { 91 self::raise_error($result, __LINE__, true); 92 } 93 } 94 95 /** 96 * S/MIME driver initialization. 97 */ 98 function load_smime_driver() 99 { 100 if ($this->smime_driver) { 101 return; 102 } 103 104 $driver = 'enigma_driver_' . $this->rc->config->get('enigma_smime_driver', 'phpssl'); 105 $username = $this->rc->user->get_username(); 106 107 // Load driver 108 $this->smime_driver = new $driver($username); 109 110 if (!$this->smime_driver) { 111 rcube::raise_error([ 112 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 113 'message' => "Enigma plugin: Unable to load S/MIME driver: $driver" 114 ], true, true 115 ); 116 } 117 118 // Initialise driver 119 $result = $this->smime_driver->init(); 120 121 if ($result instanceof enigma_error) { 122 self::raise_error($result, __LINE__, true); 123 } 124 } 125 126 /** 127 * Handler for message signing 128 * 129 * @param Mail_mime &$message Original message 130 * @param int $mode Encryption mode 131 * 132 * @return enigma_error On error returns error object 133 */ 134 function sign_message(&$message, $mode = null) 135 { 136 $mime = new enigma_mime_message($message, enigma_mime_message::PGP_SIGNED); 137 $from = $mime->getFromAddress(); 138 139 // find private key 140 $key = $this->find_key($from, true); 141 142 if (empty($key)) { 143 return new enigma_error(enigma_error::KEYNOTFOUND); 144 } 145 146 // check if we have password for this key 147 $passwords = $this->get_passwords(); 148 $pass = isset($passwords[$key->id]) ? $passwords[$key->id] : null; 149 150 if ($pass === null && !$this->rc->config->get('enigma_passwordless')) { 151 // ask for password 152 $error = ['missing' => [$key->id => $key->name]]; 153 return new enigma_error(enigma_error::BADPASS, '', $error); 154 } 155 156 $key->password = $pass; 157 158 // select mode 159 switch ($mode) { 160 case self::SIGN_MODE_BODY: 161 $pgp_mode = Crypt_GPG::SIGN_MODE_CLEAR; 162 break; 163 164 case self::SIGN_MODE_MIME: 165 $pgp_mode = Crypt_GPG::SIGN_MODE_DETACHED; 166 break; 167 168 default: 169 if ($mime->isMultipart()) { 170 $pgp_mode = Crypt_GPG::SIGN_MODE_DETACHED; 171 } 172 else { 173 $pgp_mode = Crypt_GPG::SIGN_MODE_CLEAR; 174 } 175 } 176 177 // get message body 178 if ($pgp_mode == Crypt_GPG::SIGN_MODE_CLEAR) { 179 // in this mode we'll replace text part 180 // with the one containing signature 181 $body = $message->getTXTBody(); 182 183 $text_charset = $message->getParam('text_charset'); 184 $line_length = $this->rc->config->get('line_length', 72); 185 186 // We can't use format=flowed for signed messages 187 if (strpos($text_charset, 'format=flowed')) { 188 list($charset, $params) = explode(';', $text_charset); 189 $body = rcube_mime::unfold_flowed($body); 190 $body = rcube_mime::wordwrap($body, $line_length, "\r\n", false, $charset); 191 192 $text_charset = str_replace(";\r\n format=flowed", '', $text_charset); 193 } 194 } 195 else { 196 // here we'll build PGP/MIME message 197 $body = $mime->getOrigBody(); 198 } 199 200 // sign the body 201 $result = $this->pgp_sign($body, $key, $pgp_mode); 202 203 if ($result !== true) { 204 if ($result->getCode() == enigma_error::BADPASS) { 205 // ask for password 206 $error = ['bad' => [$key->id => $key->name]]; 207 return new enigma_error(enigma_error::BADPASS, '', $error); 208 } 209 210 return $result; 211 } 212 213 // replace message body 214 if ($pgp_mode == Crypt_GPG::SIGN_MODE_CLEAR) { 215 $message->setTXTBody($body); 216 if (!empty($text_charset)) { 217 $message->setParam('text_charset', $text_charset); 218 } 219 } 220 else { 221 $mime->addPGPSignature($body, $this->pgp_driver->signature_algorithm()); 222 $message = $mime; 223 } 224 } 225 226 /** 227 * Handler for message encryption 228 * 229 * @param Mail_mime &$message Original message 230 * @param int $mode Encryption mode 231 * @param bool $is_draft Is draft-save action - use only sender's key for encryption 232 * 233 * @return enigma_error On error returns error object 234 */ 235 function encrypt_message(&$message, $mode = null, $is_draft = false) 236 { 237 $mime = new enigma_mime_message($message, enigma_mime_message::PGP_ENCRYPTED); 238 239 // always use sender's key 240 $from = $mime->getFromAddress(); 241 242 $sign_key = null; 243 $keys = []; 244 245 // check senders key for signing 246 if ($mode & self::ENCRYPT_MODE_SIGN) { 247 $sign_key = $this->find_key($from, true); 248 249 if (empty($sign_key)) { 250 return new enigma_error(enigma_error::KEYNOTFOUND); 251 } 252 253 // check if we have password for this key 254 $passwords = $this->get_passwords(); 255 $sign_pass = isset($passwords[$sign_key->id]) ? $passwords[$sign_key->id] : null; 256 257 if ($sign_pass === null && !$this->rc->config->get('enigma_passwordless')) { 258 // ask for password 259 $error = ['missing' => [$sign_key->id => $sign_key->name]]; 260 return new enigma_error(enigma_error::BADPASS, '', $error); 261 } 262 263 $sign_key->password = $sign_pass; 264 } 265 266 $recipients = [$from]; 267 268 // if it's not a draft we add all recipients' keys 269 if (!$is_draft) { 270 $recipients = array_merge($recipients, $mime->getRecipients()); 271 } 272 273 $recipients = array_unique($recipients); 274 275 // find recipient public keys 276 foreach ((array) $recipients as $email) { 277 if ($email == $from && $sign_key) { 278 $key = $sign_key; 279 } 280 else { 281 $key = $this->find_key($email); 282 } 283 284 if (empty($key)) { 285 return new enigma_error(enigma_error::KEYNOTFOUND, '', ['missing' => $email]); 286 } 287 288 $keys[] = $key; 289 } 290 291 // select mode 292 if ($mode & self::ENCRYPT_MODE_BODY) { 293 $encrypt_mode = $mode; 294 } 295 else if ($mode & self::ENCRYPT_MODE_MIME) { 296 $encrypt_mode = $mode; 297 } 298 else { 299 $encrypt_mode = $mime->isMultipart() ? self::ENCRYPT_MODE_MIME : self::ENCRYPT_MODE_BODY; 300 } 301 302 // get message body 303 if ($encrypt_mode == self::ENCRYPT_MODE_BODY) { 304 // in this mode we'll replace text part 305 // with the one containing encrypted message 306 $body = $message->getTXTBody(); 307 } 308 else { 309 // here we'll build PGP/MIME message 310 $body = $mime->getOrigBody(); 311 } 312 313 // sign the body 314 $result = $this->pgp_encrypt($body, $keys, $sign_key); 315 316 if ($result !== true) { 317 if ($result->getCode() == enigma_error::BADPASS) { 318 // ask for password 319 $error = ['bad' => [$sign_key->id => $sign_key->name]]; 320 return new enigma_error(enigma_error::BADPASS, '', $error); 321 } 322 323 return $result; 324 } 325 326 // replace message body 327 if ($encrypt_mode == self::ENCRYPT_MODE_BODY) { 328 $message->setTXTBody($body); 329 } 330 else { 331 $mime->setPGPEncryptedBody($body); 332 $message = $mime; 333 } 334 } 335 336 /** 337 * Handler for attaching public key to a message 338 * 339 * @param Mail_mime &$message Original message 340 * 341 * @return bool True on success, False on failure 342 */ 343 function attach_public_key(&$message) 344 { 345 $headers = $message->headers(); 346 $from = rcube_mime::decode_address_list($headers['From'], 1, false, null, true); 347 $from = isset($from[1]) ? $from[1] : null; 348 349 // find my key 350 if ($from && ($key = $this->find_key($from, true))) { 351 $pubkey_armor = $this->export_key($key->id); 352 353 if (!$pubkey_armor instanceof enigma_error) { 354 $pubkey_name = '0x' . enigma_key::format_id($key->id) . '.asc'; 355 $message->addAttachment($pubkey_armor, 'application/pgp-keys', $pubkey_name, false, '7bit'); 356 return true; 357 } 358 } 359 360 return false; 361 } 362 363 /** 364 * Handler for message_part_structure hook. 365 * Called for every part of the message. 366 * 367 * @param array $p Original parameters 368 * @param string $body Part body (will be set if used internally) 369 * 370 * @return array Modified parameters 371 */ 372 function part_structure($p, $body = null) 373 { 374 static $got_content = false; 375 376 // Prevent from "decryption oracle" [CVE-2019-10740] (#6638) 377 // On mail compose (edit/reply/forward) we support encrypted content only 378 // in the first "content part" of the message. 379 if ($got_content && $this->rc->task == 'mail' && $this->rc->action == 'compose') { 380 return; 381 } 382 383 // Don't be tempted to support encryption in text/html parts 384 // Because of EFAIL vulnerability we should never support this (#6289) 385 386 if ($p['mimetype'] == 'text/plain' || $p['mimetype'] == 'application/pgp') { 387 $this->parse_plain($p, $body); 388 $got_content = true; 389 } 390 else if ($p['mimetype'] == 'multipart/signed') { 391 $this->parse_signed($p, $body); 392 $got_content = true; 393 } 394 else if ($p['mimetype'] == 'multipart/encrypted') { 395 $this->parse_encrypted($p); 396 $got_content = true; 397 } 398 else if ($p['mimetype'] == 'application/pkcs7-mime') { 399 $this->parse_encrypted($p); 400 $got_content = true; 401 } 402 else { 403 $got_content = !empty($p['structure']->type) && $p['structure']->type === 'content'; 404 } 405 406 return $p; 407 } 408 409 /** 410 * Handler for message_part_body hook. 411 * 412 * @param array $p Original parameters 413 * 414 * @return array Modified parameters 415 */ 416 function part_body($p) 417 { 418 // encrypted attachment, see parse_plain_encrypted() 419 if (!empty($p['part']->need_decryption) && $p['part']->body === null) { 420 $this->load_pgp_driver(); 421 422 $storage = $this->rc->get_storage(); 423 $body = $storage->get_message_part($p['object']->uid, $p['part']->mime_id, $p['part'], null, null, true, 0, false); 424 $result = $this->pgp_decrypt($body); 425 426 // @TODO: what to do on error? 427 if ($result === true) { 428 $p['part']->body = $body; 429 $p['part']->size = strlen($body); 430 $p['part']->body_modified = true; 431 } 432 } 433 434 return $p; 435 } 436 437 /** 438 * Handler for plain/text message. 439 * 440 * @param array &$p Reference to hook's parameters 441 * @param string $body Part body (will be set if used internally) 442 */ 443 function parse_plain(&$p, $body = null) 444 { 445 $part = $p['structure']; 446 447 // Get message body from IMAP server 448 if ($body === null) { 449 $body = $this->get_part_body($p['object'], $part); 450 } 451 452 // In this way we can use fgets on string as on file handle 453 // Don't use php://temp for security (body may come from an encrypted part) 454 $fd = fopen('php://memory', 'r+'); 455 if (!$fd) { 456 return; 457 } 458 459 fwrite($fd, $body); 460 rewind($fd); 461 462 $body = ''; 463 $prefix = ''; 464 $mode = ''; 465 $tokens = [ 466 'BEGIN PGP SIGNED MESSAGE' => 'signed-start', 467 'END PGP SIGNATURE' => 'signed-end', 468 'BEGIN PGP MESSAGE' => 'encrypted-start', 469 'END PGP MESSAGE' => 'encrypted-end', 470 ]; 471 $regexp = '/^-----(' . implode('|', array_keys($tokens)) . ')-----[\r\n]*/'; 472 473 while (($line = fgets($fd)) !== false) { 474 if (strlen($line) > 5 && $line[0] === '-' && $line[4] === '-' && preg_match($regexp, $line, $m)) { 475 switch ($tokens[$m[1]]) { 476 case 'signed-start': 477 $body = $line; 478 $mode = 'signed'; 479 break; 480 481 case 'signed-end': 482 if ($mode === 'signed') { 483 $body .= $line; 484 } 485 break 2; // ignore anything after this line 486 487 case 'encrypted-start': 488 $body = $line; 489 $mode = 'encrypted'; 490 break; 491 492 case 'encrypted-end': 493 if ($mode === 'encrypted') { 494 $body .= $line; 495 } 496 break 2; // ignore anything after this line 497 } 498 499 continue; 500 } 501 502 if ($mode === 'signed') { 503 $body .= $line; 504 } 505 else if ($mode === 'encrypted') { 506 $body .= $line; 507 } 508 else { 509 $prefix .= $line; 510 } 511 } 512 513 fclose($fd); 514 515 if ($mode === 'signed') { 516 $this->parse_plain_signed($p, $body, $prefix); 517 } 518 else if ($mode === 'encrypted') { 519 $this->parse_plain_encrypted($p, $body, $prefix); 520 } 521 } 522 523 /** 524 * Handler for multipart/signed message. 525 * 526 * @param array &$p Reference to hook's parameters 527 * @param string $body Part body (will be set if used internally) 528 */ 529 function parse_signed(&$p, $body = null) 530 { 531 $struct = $p['structure']; 532 533 // S/MIME 534 if (!empty($struct->parts[1]) && $struct->parts[1]->mimetype == 'application/pkcs7-signature') { 535 $this->parse_smime_signed($p, $body); 536 } 537 // PGP/MIME: RFC3156 538 // The multipart/signed body MUST consist of exactly two parts. 539 // The first part contains the signed data in MIME canonical format, 540 // including a set of appropriate content headers describing the data. 541 // The second body MUST contain the PGP digital signature. It MUST be 542 // labeled with a content type of "application/pgp-signature". 543 else if (count($struct->parts) == 2 544 && $struct->parts[1] && $struct->parts[1]->mimetype == 'application/pgp-signature' 545 ) { 546 $this->parse_pgp_signed($p, $body); 547 } 548 } 549 550 /** 551 * Handler for multipart/encrypted message. 552 * 553 * @param array &$p Reference to hook's parameters 554 */ 555 function parse_encrypted(&$p) 556 { 557 $struct = $p['structure']; 558 559 // S/MIME 560 if ($p['mimetype'] == 'application/pkcs7-mime') { 561 $this->parse_smime_encrypted($p); 562 } 563 // PGP/MIME: RFC3156 564 // The multipart/encrypted MUST consist of exactly two parts. The first 565 // MIME body part must have a content type of "application/pgp-encrypted". 566 // This body contains the control information. 567 // The second MIME body part MUST contain the actual encrypted data. It 568 // must be labeled with a content type of "application/octet-stream". 569 else if (count($struct->parts) == 2 570 && $struct->parts[0] && $struct->parts[0]->mimetype == 'application/pgp-encrypted' 571 && $struct->parts[1] && $struct->parts[1]->mimetype == 'application/octet-stream' 572 ) { 573 $this->parse_pgp_encrypted($p); 574 } 575 } 576 577 /** 578 * Handler for plain signed message. 579 * Excludes message and signature bodies and verifies signature. 580 * 581 * @param array &$p Reference to hook's parameters 582 * @param string $body Message (part) body 583 * @param string $prefix Body prefix (additional text before the encrypted block) 584 */ 585 private function parse_plain_signed(&$p, $body, $prefix = '') 586 { 587 if (!$this->rc->config->get('enigma_signatures', true)) { 588 return; 589 } 590 591 $this->load_pgp_driver(); 592 $part = $p['structure']; 593 594 // Verify signature 595 if ($this->rc->action == 'show' || $this->rc->action == 'preview' || $this->rc->action == 'print') { 596 $sig = $this->pgp_verify($body); 597 } 598 599 // In this way we can use fgets on string as on file handle 600 // Don't use php://temp for security (body may come from an encrypted part) 601 $fd = fopen('php://memory', 'r+'); 602 if (!$fd) { 603 return; 604 } 605 606 fwrite($fd, $body); 607 rewind($fd); 608 609 $body = $part->body = null; 610 $part->body_modified = true; 611 612 // Extract body (and signature?) 613 while (($line = fgets($fd, 1024)) !== false) { 614 if ($part->body === null) { 615 $part->body = ''; 616 } 617 else if (preg_match('/^-----BEGIN PGP SIGNATURE-----/', $line)) { 618 break; 619 } 620 else { 621 $part->body .= $line; 622 } 623 } 624 625 fclose($fd); 626 627 // Remove "Hash" Armor Headers 628 $part->body = preg_replace('/^.*\r*\n\r*\n/', '', $part->body); 629 // de-Dash-Escape (RFC2440) 630 $part->body = preg_replace('/(^|\n)- -/', '\\1-', $part->body); 631 632 if ($prefix) { 633 $part->body = $prefix . $part->body; 634 } 635 636 // Store signature data for display 637 if (!empty($sig)) { 638 $sig->partial = !empty($prefix); 639 $this->signatures[$part->mime_id] = $sig; 640 } 641 } 642 643 /** 644 * Handler for PGP/MIME signed message. 645 * Verifies signature. 646 * 647 * @param array &$p Reference to hook's parameters 648 * @param string $body Part body (will be set if used internally) 649 */ 650 private function parse_pgp_signed(&$p, $body = null) 651 { 652 if (!$this->rc->config->get('enigma_signatures', true)) { 653 return; 654 } 655 656 if ($this->rc->action != 'show' && $this->rc->action != 'preview' && $this->rc->action != 'print') { 657 return; 658 } 659 660 $this->load_pgp_driver(); 661 $struct = $p['structure']; 662 663 $msg_part = $struct->parts[0]; 664 $sig_part = $struct->parts[1]; 665 666 // Get bodies 667 if ($body === null) { 668 if (empty($struct->body_modified)) { 669 $body = $this->get_part_body($p['object'], $struct); 670 } 671 } 672 673 $boundary = $struct->ctype_parameters['boundary']; 674 675 // when it is a signed message forwarded as attachment 676 // ctype_parameters property will not be set 677 if (!$boundary && !empty($struct->headers['content-type']) 678 && preg_match('/boundary="?([a-zA-Z0-9\'()+_,-.\/:=?]+)"?/', $struct->headers['content-type'], $m) 679 ) { 680 $boundary = $m[1]; 681 } 682 683 // set signed part body 684 list($msg_body, $sig_body) = $this->explode_signed_body($body, $boundary); 685 686 // Verify 687 if ($sig_body && $msg_body) { 688 $sig = $this->pgp_verify($msg_body, $sig_body); 689 690 // Store signature data for display 691 $this->signatures[$struct->mime_id] = $sig; 692 $this->signatures[$msg_part->mime_id] = $sig; 693 } 694 } 695 696 /** 697 * Handler for S/MIME signed message. 698 * Verifies signature. 699 * 700 * @param array &$p Reference to hook's parameters 701 * @param string $body Part body (will be set if used internally) 702 */ 703 private function parse_smime_signed(&$p, $body = null) 704 { 705 if (!$this->rc->config->get('enigma_signatures', true)) { 706 return; 707 } 708 709 // @TODO 710 } 711 712 /** 713 * Handler for plain encrypted message. 714 * 715 * @param array &$p Reference to hook's parameters 716 * @param string $body Message (part) body 717 * @param string $prefix Body prefix (additional text before the encrypted block) 718 */ 719 private function parse_plain_encrypted(&$p, $body, $prefix = '') 720 { 721 if (!$this->rc->config->get('enigma_decryption', true)) { 722 return; 723 } 724 725 $this->load_pgp_driver(); 726 $part = $p['structure']; 727 728 // Decrypt 729 $result = $this->pgp_decrypt($body, $signature); 730 731 // Store decryption status 732 $this->decryptions[$part->mime_id] = $result; 733 734 // Store signature data for display 735 if ($signature) { 736 $this->signatures[$part->mime_id] = $signature; 737 } 738 739 // find parent part ID 740 if (strpos($part->mime_id, '.')) { 741 $items = explode('.', $part->mime_id); 742 array_pop($items); 743 $parent = implode('.', $items); 744 } 745 else { 746 $parent = 0; 747 } 748 749 // Parse decrypted message 750 if ($result === true) { 751 $part->body = $prefix . $body; 752 $part->body_modified = true; 753 754 // it maybe PGP signed inside, verify signature 755 $this->parse_plain($p, $body); 756 757 // Remember it was decrypted 758 $this->encrypted_parts[] = $part->mime_id; 759 760 // Inform the user that only a part of the body was encrypted 761 if ($prefix) { 762 $this->decryptions[$part->mime_id] = self::ENCRYPTED_PARTIALLY; 763 } 764 765 // Encrypted plain message may contain encrypted attachments 766 // in such case attachments have .pgp extension and type application/octet-stream. 767 // This is what happens when you select "Encrypt each attachment separately 768 // and send the message using inline PGP" in Thunderbird's Enigmail. 769 770 if (!empty($p['object']->mime_parts[$parent])) { 771 foreach ((array) $p['object']->mime_parts[$parent]->parts as $p) { 772 if ($p->disposition == 'attachment' && $p->mimetype == 'application/octet-stream' 773 && preg_match('/^(.*)\.pgp$/i', $p->filename, $m) 774 ) { 775 // modify filename 776 $p->filename = $m[1]; 777 // flag the part, it will be decrypted when needed 778 $p->need_decryption = true; 779 // disable caching 780 $p->body_modified = true; 781 } 782 } 783 } 784 } 785 // decryption failed, but the message may have already 786 // been cached with the modified parts (see above), 787 // let's bring the original state back 788 else if (!empty($p['object']->mime_parts[$parent])) { 789 foreach ((array) $p['object']->mime_parts[$parent]->parts as $p) { 790 if ($p->need_decryption && !preg_match('/^(.*)\.pgp$/i', $p->filename, $m)) { 791 // modify filename 792 $p->filename .= '.pgp'; 793 // flag the part, it will be decrypted when needed 794 unset($p->need_decryption); 795 } 796 } 797 } 798 } 799 800 /** 801 * Handler for PGP/MIME encrypted message. 802 * 803 * @param array &$p Reference to hook's parameters 804 */ 805 private function parse_pgp_encrypted(&$p) 806 { 807 if (!$this->rc->config->get('enigma_decryption', true)) { 808 return; 809 } 810 811 $this->load_pgp_driver(); 812 813 $struct = $p['structure']; 814 $part = $struct->parts[1]; 815 816 // Get body 817 $body = $this->get_part_body($p['object'], $part); 818 819 // Decrypt 820 $result = $this->pgp_decrypt($body, $signature); 821 822 if ($result === true) { 823 // Parse decrypted message 824 $struct = $this->parse_body($body); 825 826 // Modify original message structure 827 $this->modify_structure($p, $struct, strlen($body)); 828 829 // Parse the structure (there may be encrypted/signed parts inside 830 $this->part_structure([ 831 'object' => $p['object'], 832 'structure' => $struct, 833 'mimetype' => $struct->mimetype 834 ], $body); 835 836 // Attach the decryption message to all parts 837 $this->decryptions[$struct->mime_id] = $result; 838 foreach ((array) $struct->parts as $sp) { 839 $this->decryptions[$sp->mime_id] = $result; 840 if ($signature) { 841 $this->signatures[$sp->mime_id] = $signature; 842 } 843 } 844 } 845 else { 846 $this->decryptions[$part->mime_id] = $result; 847 848 // Make sure decryption status message will be displayed 849 $part->type = 'content'; 850 $p['object']->parts[] = $part; 851 852 // don't show encrypted part on attachments list 853 // don't show "cannot display encrypted message" text 854 $p['abort'] = true; 855 } 856 } 857 858 /** 859 * Handler for S/MIME encrypted message. 860 * 861 * @param array &$p Reference to hook's parameters 862 */ 863 private function parse_smime_encrypted(&$p) 864 { 865 if (!$this->rc->config->get('enigma_decryption', true)) { 866 return; 867 } 868 869 // @TODO 870 } 871 872 /** 873 * PGP signature verification. 874 * 875 * @param mixed &$msg_body Message body 876 * @param mixed $sig_body Signature body (for MIME messages) 877 * 878 * @return mixed enigma_signature or enigma_error 879 */ 880 private function pgp_verify(&$msg_body, $sig_body = null) 881 { 882 // @TODO: Handle big bodies using (temp) files 883 884 // Get rid of possible non-ascii characters (#5962) 885 $sig_body = preg_replace('/[^\x00-\x7F]/', '', $sig_body); 886 887 $sig = $this->pgp_driver->verify($msg_body, $sig_body); 888 889 if (($sig instanceof enigma_error) && $sig->getCode() != enigma_error::KEYNOTFOUND) { 890 self::raise_error($sig, __LINE__); 891 } 892 893 return $sig; 894 } 895 896 /** 897 * PGP message decryption. 898 * 899 * @param mixed &$msg_body Message body 900 * @param enigma_signature &$signature Signature verification result 901 * 902 * @return mixed True or enigma_error 903 */ 904 private function pgp_decrypt(&$msg_body, &$signature = null) 905 { 906 // @TODO: Handle big bodies using (temp) files 907 908 // Get rid of possible non-ascii characters (#5962) 909 $msg_body = preg_replace('/[^\x00-\x7F]/', '', $msg_body); 910 911 $keys = $this->get_passwords(); 912 $result = $this->pgp_driver->decrypt($msg_body, $keys, $signature); 913 914 if ($result instanceof enigma_error) { 915 if ($result->getCode() != enigma_error::KEYNOTFOUND) { 916 self::raise_error($result, __LINE__); 917 } 918 919 return $result; 920 } 921 922 $msg_body = $result; 923 924 return true; 925 } 926 927 /** 928 * PGP message signing 929 * 930 * @param mixed &$msg_body Message body 931 * @param enigma_key $key The key (with passphrase) 932 * @param int $mode Signing mode 933 * 934 * @return mixed True or enigma_error 935 */ 936 private function pgp_sign(&$msg_body, $key, $mode = null) 937 { 938 // @TODO: Handle big bodies using (temp) files 939 $result = $this->pgp_driver->sign($msg_body, $key, $mode); 940 941 if ($result instanceof enigma_error) { 942 if ($result->getCode() != enigma_error::KEYNOTFOUND) { 943 self::raise_error($result, __LINE__); 944 } 945 946 return $result; 947 } 948 949 $msg_body = $result; 950 951 return true; 952 } 953 954 /** 955 * PGP message encrypting 956 * 957 * @param mixed &$msg_body Message body 958 * @param array $keys Keys (array of enigma_key objects) 959 * @param string $sign_key Optional signing Key ID 960 * @param string $sign_pass Optional signing Key password 961 * 962 * @return mixed True or enigma_error 963 */ 964 private function pgp_encrypt(&$msg_body, $keys, $sign_key = null, $sign_pass = null) 965 { 966 // @TODO: Handle big bodies using (temp) files 967 $result = $this->pgp_driver->encrypt($msg_body, $keys, $sign_key, $sign_pass); 968 969 if ($result instanceof enigma_error) { 970 if ($result->getCode() != enigma_error::KEYNOTFOUND) { 971 self::raise_error($result, __LINE__); 972 } 973 974 return $result; 975 } 976 977 $msg_body = $result; 978 979 return true; 980 } 981 982 /** 983 * PGP keys listing. 984 * 985 * @param mixed $pattern Key ID/Name pattern 986 * 987 * @return mixed Array of keys or enigma_error 988 */ 989 function list_keys($pattern = '') 990 { 991 $this->load_pgp_driver(); 992 $result = $this->pgp_driver->list_keys($pattern); 993 994 if ($result instanceof enigma_error) { 995 self::raise_error($result, __LINE__); 996 } 997 998 return $result; 999 } 1000 1001 /** 1002 * Find PGP private/public key 1003 * 1004 * @param string $email E-mail address 1005 * @param bool $can_sign Need a key for signing? 1006 * 1007 * @return enigma_key The key 1008 */ 1009 function find_key($email, $can_sign = false) 1010 { 1011 if ($can_sign && array_key_exists($email, $this->cache)) { 1012 return $this->cache[$email]; 1013 } 1014 1015 $this->load_pgp_driver(); 1016 $result = $this->pgp_driver->list_keys($email); 1017 1018 if ($result instanceof enigma_error) { 1019 self::raise_error($result, __LINE__); 1020 return; 1021 } 1022 1023 $mode = $can_sign ? enigma_key::CAN_SIGN : enigma_key::CAN_ENCRYPT; 1024 $ret = null; 1025 1026 // check key validity and type 1027 foreach ($result as $key) { 1028 if (($subkey = $key->find_subkey($email, $mode)) 1029 && (!$can_sign || $key->get_type() == enigma_key::TYPE_KEYPAIR) 1030 ) { 1031 $ret = $key; 1032 break; 1033 } 1034 } 1035 1036 // cache private key info for better performance 1037 // we can skip one list_keys() call when signing and attaching a key 1038 if ($can_sign) { 1039 $this->cache[$email] = $ret; 1040 } 1041 1042 return $ret; 1043 } 1044 1045 /** 1046 * PGP key details. 1047 * 1048 * @param mixed $keyid Key ID 1049 * 1050 * @return mixed enigma_key or enigma_error 1051 */ 1052 function get_key($keyid) 1053 { 1054 $this->load_pgp_driver(); 1055 $result = $this->pgp_driver->get_key($keyid); 1056 1057 if ($result instanceof enigma_error) { 1058 self::raise_error($result, __LINE__); 1059 } 1060 1061 return $result; 1062 } 1063 1064 /** 1065 * PGP key delete. 1066 * 1067 * @param string $keyid Key ID 1068 * 1069 * @return enigma_error|bool True on success 1070 */ 1071 function delete_key($keyid) 1072 { 1073 $this->load_pgp_driver(); 1074 $result = $this->pgp_driver->delete_key($keyid); 1075 1076 if ($result instanceof enigma_error) { 1077 self::raise_error($result, __LINE__); 1078 } 1079 1080 return $result; 1081 } 1082 1083 /** 1084 * PGP keys pair generation. 1085 * 1086 * @param array $data Key pair parameters 1087 * 1088 * @return mixed enigma_key or enigma_error 1089 */ 1090 function generate_key($data) 1091 { 1092 $this->load_pgp_driver(); 1093 $result = $this->pgp_driver->gen_key($data); 1094 1095 if ($result instanceof enigma_error) { 1096 self::raise_error($result, __LINE__); 1097 } 1098 1099 return $result; 1100 } 1101 1102 /** 1103 * PGP keys/certs import. 1104 * 1105 * @param mixed $content Import file name or content 1106 * @param boolean $isfile True if first argument is a filename 1107 * 1108 * @return mixed Import status data array or enigma_error 1109 */ 1110 function import_key($content, $isfile = false) 1111 { 1112 $this->load_pgp_driver(); 1113 $result = $this->pgp_driver->import($content, $isfile, $this->get_passwords()); 1114 1115 if ($result instanceof enigma_error) { 1116 self::raise_error($result, __LINE__); 1117 } 1118 else { 1119 $result['imported'] = $result['public_imported'] + $result['private_imported']; 1120 $result['unchanged'] = $result['public_unchanged'] + $result['private_unchanged']; 1121 } 1122 1123 return $result; 1124 } 1125 1126 /** 1127 * PGP keys/certs export. 1128 * 1129 * @param string $key Key ID 1130 * @param resource $fp Optional output stream 1131 * @param bool $include_private Include private key 1132 * 1133 * @return mixed Key content or enigma_error 1134 */ 1135 function export_key($key, $fp = null, $include_private = false) 1136 { 1137 $this->load_pgp_driver(); 1138 $result = $this->pgp_driver->export($key, $include_private, $this->get_passwords()); 1139 1140 if ($result instanceof enigma_error) { 1141 self::raise_error($result, __LINE__); 1142 return $result; 1143 } 1144 1145 if ($fp) { 1146 fwrite($fp, $result); 1147 } 1148 else { 1149 return $result; 1150 } 1151 } 1152 1153 /** 1154 * Registers password for specified key/cert sent by the password prompt. 1155 */ 1156 function password_handler() 1157 { 1158 $keyid = rcube_utils::get_input_value('_keyid', rcube_utils::INPUT_POST); 1159 $passwd = rcube_utils::get_input_value('_passwd', rcube_utils::INPUT_POST, true); 1160 1161 if ($keyid && is_string($passwd) && strlen($passwd)) { 1162 $this->save_password(strtoupper($keyid), $passwd); 1163 } 1164 } 1165 1166 /** 1167 * Saves key/cert password in user session 1168 */ 1169 function save_password($keyid, $password) 1170 { 1171 // we store passwords in session for specified time 1172 if (!empty($_SESSION['enigma_pass'])) { 1173 $config = $this->rc->decrypt($_SESSION['enigma_pass']); 1174 $config = unserialize($config); 1175 } else { 1176 $config = []; 1177 } 1178 1179 $config[$keyid] = [$password, time()]; 1180 1181 $_SESSION['enigma_pass'] = $this->rc->encrypt(serialize($config)); 1182 } 1183 1184 /** 1185 * Returns currently stored passwords 1186 */ 1187 function get_passwords() 1188 { 1189 if (!empty($_SESSION['enigma_pass'])) { 1190 $config = $this->rc->decrypt($_SESSION['enigma_pass']); 1191 $config = @unserialize($config); 1192 } 1193 1194 $threshold = $this->password_time ? time() - $this->password_time : 0; 1195 $keys = []; 1196 1197 // delete expired passwords 1198 if (!empty($config)) { 1199 foreach ($config as $key => $value) { 1200 if ($threshold && $value[1] < $threshold) { 1201 unset($config[$key]); 1202 $modified = true; 1203 } 1204 else { 1205 $keys[$key] = $value[0]; 1206 } 1207 } 1208 1209 if (!empty($modified)) { 1210 $_SESSION['enigma_pass'] = $this->rc->encrypt(serialize($config)); 1211 } 1212 } 1213 1214 return $keys; 1215 } 1216 1217 /** 1218 * Get message part body. 1219 * 1220 * @param rcube_message $msg Message object 1221 * @param rcube_message_part $part Message part 1222 */ 1223 private function get_part_body($msg, $part) 1224 { 1225 // @TODO: Handle big bodies using file handles 1226 1227 // This is a special case when we want to get the whole body 1228 // using direct IMAP access, in other cases we prefer 1229 // rcube_message::get_part_body() as the body may be already in memory 1230 if (!$part->mime_id) { 1231 // fake the size which may be empty for multipart/* parts 1232 // otherwise get_message_part() below will fail 1233 if (!$part->size) { 1234 $reset = true; 1235 $part->size = 1; 1236 } 1237 1238 $storage = $this->rc->get_storage(); 1239 $body = $storage->get_message_part($msg->uid, $part->mime_id, $part, 1240 null, null, true, 0, false); 1241 1242 if (!empty($reset)) { 1243 $part->size = 0; 1244 } 1245 } 1246 else { 1247 $body = $msg->get_part_body($part->mime_id, false); 1248 } 1249 1250 return $body; 1251 } 1252 1253 /** 1254 * Parse decrypted message body into structure 1255 * 1256 * @param string &$body Message body 1257 * 1258 * @return array Message structure 1259 */ 1260 private function parse_body(&$body) 1261 { 1262 // Mail_mimeDecode need \r\n end-line, but gpg may return \n 1263 $body = preg_replace('/\r?\n/', "\r\n", $body); 1264 1265 // parse the body into structure 1266 return rcube_mime::parse_message($body); 1267 } 1268 1269 /** 1270 * Replace message encrypted structure with decrypted message structure 1271 * 1272 * @param array &$p Hook arguments 1273 * @param rcube_message_part $struct Part structure 1274 * @param int $size Part size 1275 */ 1276 private function modify_structure(&$p, $struct, $size = 0) 1277 { 1278 // modify mime_parts property of the message object 1279 $old_id = $p['structure']->mime_id; 1280 1281 foreach (array_keys($p['object']->mime_parts) as $idx) { 1282 if (!$old_id || $idx == $old_id || strpos($idx, $old_id . '.') === 0) { 1283 unset($p['object']->mime_parts[$idx]); 1284 } 1285 } 1286 1287 // set some part params used by Roundcube core 1288 $struct->headers = array_merge($p['structure']->headers, $struct->headers); 1289 $struct->size = $size; 1290 $struct->filename = $p['structure']->filename; 1291 1292 // modify the new structure to be correctly handled by Roundcube 1293 $this->modify_structure_part($struct, $p['object'], $old_id); 1294 1295 // replace old structure with the new one 1296 $p['structure'] = $struct; 1297 $p['mimetype'] = $struct->mimetype; 1298 } 1299 1300 /** 1301 * Modify decrypted message part 1302 * 1303 * @param rcube_message_part $part 1304 * @param rcube_message $msg 1305 * @param string $old_id 1306 */ 1307 private function modify_structure_part($part, $msg, $old_id) 1308 { 1309 // never cache the body 1310 $part->body_modified = true; 1311 $part->encoding = 'stream'; 1312 1313 // modify part identifier 1314 if ($old_id) { 1315 $part->mime_id = !$part->mime_id ? $old_id : ($old_id . '.' . $part->mime_id); 1316 } 1317 1318 // Cache the fact it was decrypted 1319 $this->encrypted_parts[] = $part->mime_id; 1320 $msg->mime_parts[$part->mime_id] = $part; 1321 1322 // modify sub-parts 1323 foreach ((array) $part->parts as $p) { 1324 $this->modify_structure_part($p, $msg, $old_id); 1325 } 1326 } 1327 1328 /** 1329 * Extracts body and signature of multipart/signed message body 1330 */ 1331 private function explode_signed_body($body, $boundary) 1332 { 1333 if (!$body) { 1334 return []; 1335 } 1336 1337 $boundary = '--' . $boundary; 1338 $boundary_len = strlen($boundary) + 2; 1339 1340 // Find boundaries 1341 $start = strpos($body, $boundary) + $boundary_len; 1342 $end = strpos($body, $boundary, $start); 1343 1344 // Get signed body and signature 1345 $sig = substr($body, $end + $boundary_len); 1346 $body = substr($body, $start, $end - $start - 2); 1347 1348 // Cleanup signature 1349 $sig = substr($sig, strpos($sig, "\r\n\r\n") + 4); 1350 $sig = substr($sig, 0, strpos($sig, $boundary)); 1351 1352 return [$body, $sig]; 1353 } 1354 1355 /** 1356 * Checks if specified message part is a PGP-key or S/MIME cert data 1357 * 1358 * @param rcube_message_part $part Part object 1359 * 1360 * @return boolean True if part is a key/cert 1361 */ 1362 public function is_keys_part($part) 1363 { 1364 // @TODO: S/MIME 1365 return ( 1366 // Content-Type: application/pgp-keys 1367 $part->mimetype == 'application/pgp-keys' 1368 ); 1369 } 1370 1371 /** 1372 * Removes all user keys and assigned data 1373 * 1374 * @param string $username Username 1375 * 1376 * @return bool True on success, False on failure 1377 */ 1378 public function delete_user_data($username) 1379 { 1380 $homedir = $this->rc->config->get('enigma_pgp_homedir', INSTALL_PATH . 'plugins/enigma/home'); 1381 $homedir .= DIRECTORY_SEPARATOR . $username; 1382 1383 return file_exists($homedir) ? self::delete_dir($homedir) : true; 1384 } 1385 1386 /** 1387 * Recursive method to remove directory with its content 1388 * 1389 * @param string $dir Directory 1390 */ 1391 public static function delete_dir($dir) 1392 { 1393 // This code can be executed from command line, make sure 1394 // we have permissions to delete keys directory 1395 if (!is_writable($dir)) { 1396 rcube::raise_error("Unable to delete $dir", false, true); 1397 return false; 1398 } 1399 1400 if ($content = scandir($dir)) { 1401 foreach ($content as $filename) { 1402 if ($filename != '.' && $filename != '..') { 1403 $filename = $dir . DIRECTORY_SEPARATOR . $filename; 1404 1405 if (is_dir($filename)) { 1406 self::delete_dir($filename); 1407 } 1408 else { 1409 unlink($filename); 1410 } 1411 } 1412 } 1413 1414 rmdir($dir); 1415 } 1416 1417 return true; 1418 } 1419 1420 /** 1421 * Check if specified driver feature is supported 1422 */ 1423 public function is_supported($feature) 1424 { 1425 $this->load_pgp_driver(); 1426 1427 return in_array($feature, $this->pgp_driver->capabilities()); 1428 } 1429 1430 /** 1431 * Raise/log (relevant) errors 1432 */ 1433 protected static function raise_error($result, $line, $abort = false) 1434 { 1435 if ($result->getCode() != enigma_error::BADPASS) { 1436 rcube::raise_error([ 1437 'code' => 600, 1438 'file' => __FILE__, 1439 'line' => $line, 1440 'message' => "Enigma plugin: " . $result->getMessage() 1441 ], true, $abort 1442 ); 1443 } 1444 } 1445} 1446