1<?php
2/**
3 * Copyright 2002-2017 Horde LLC (http://www.horde.org/)
4 *
5 * See the enclosed file COPYING for license information (GPL). If you
6 * did not receive this file, see http://www.horde.org/licenses/gpl.
7 *
8 * @category  Horde
9 * @copyright 2002-2017 Horde LLC
10 * @license   http://www.horde.org/licenses/gpl GPL
11 * @package   IMP
12 */
13
14/**
15 * Renderer to allow viewing/decrypting of PGP formatted messages (RFC 3156).
16 *
17 * This class handles the following MIME types:
18 *   - application/pgp-encrypted (in multipart/encrypted part)
19 *   - application/pgp-keys
20 *   - application/pgp-signature (in multipart/signed part)
21 *
22 * This driver may add the following parameters to the URL:
23 *   - pgp_verify_msg: (boolean) Do verification of PGP signed data?
24 *   - pgp_view_key: (boolean) View PGP key details?
25 *
26 * @author    Michael Slusarz <slusarz@horde.org>
27 * @category  Horde
28 * @copyright 2002-2017 Horde LLC
29 * @license   http://www.horde.org/licenses/gpl GPL
30 * @package   IMP
31 */
32class IMP_Mime_Viewer_Pgp extends Horde_Mime_Viewer_Base
33{
34    /* Metadata constants. */
35    const PGP_SIGN_ENC = 'imp-pgp-signed-encrypted';
36
37    /**
38     * This driver's display capabilities.
39     *
40     * @var array
41     */
42    protected $_capability = array(
43        'full' => false,
44        'info' => false,
45        'inline' => true,
46        /* This driver *does* render raw data, but only for
47         * application/pgp-signature parts that have been processed by the
48         * text/plain driver and for displaying raw pgp keys. Altering this
49         * value is handled via the canRender() function. */
50        'raw' => false
51    );
52
53    /**
54     * Metadata for the current viewer/data.
55     *
56     * @var array
57     */
58    protected $_metadata = array(
59        'compressed' => false,
60        'embedded' => true,
61        'forceinline' => true
62    );
63
64    /**
65     * The address of the sender.
66     *
67     * @var Horde_Mail_Rfc822_Address
68     */
69    protected $_sender = null;
70
71    /**
72     * Return the full rendered version of the Horde_Mime_Part object.
73     *
74     * @return array  See parent::render().
75     */
76    protected function _render()
77    {
78        switch ($this->_mimepart->getType()) {
79        case 'application/pgp-keys':
80            $vars = $GLOBALS['injector']->getInstance('Horde_Variables');
81            if ($vars->pgp_view_key) {
82                // Throws exception on error.
83                return array(
84                    $this->_mimepart->getMimeId() => array(
85                        'data' => '<html><body><tt>' . nl2br(str_replace(' ', '&nbsp;', $GLOBALS['injector']->getInstance('IMP_Crypt_Pgp')->pgpPrettyKey($this->_mimepart->getContents()))) . '</tt></body></html>',
86                        'type' => 'text/html; charset=' . $this->getConfigParam('charset')
87                    )
88                );
89            }
90
91            return array(
92                $this->_mimepart->getMimeId() => array(
93                    'data' => $this->_mimepart->getContents(),
94                    'type' => 'text/plain; charset=' . $this->_mimepart->getCharset()
95                )
96            );
97        }
98    }
99
100    /**
101     * Return the full rendered version of the Horde_Mime_Part object.
102     *
103     * @return array  See parent::render().
104     */
105    protected function _renderRaw()
106    {
107        $ret = array(
108            'data' => '',
109            'type' => 'text/plain; charset=' . $this->getConfigParam('charset')
110        );
111
112        switch ($this->_mimepart->getType()) {
113        case 'application/pgp-signature':
114            $parts = $GLOBALS['injector']->getInstance('IMP_Crypt_Pgp_Parse')->parse($this->_mimepart->getContents());
115            foreach (array_keys($parts) as $key) {
116                if ($parts[$key]['type'] == Horde_Crypt_Pgp::ARMOR_SIGNATURE) {
117                    $ret['data'] = implode("\r\n", $parts[$key]['data']);
118                    break;
119                }
120            }
121            break;
122        }
123
124        return array(
125            $this->_mimepart->getMimeId() => $ret
126        );
127    }
128
129    /**
130     * Return the rendered inline version of the Horde_Mime_Part object.
131     *
132     * @return array  See parent::render().
133     */
134    protected function _renderInline()
135    {
136        $id = $this->_mimepart->getMimeId();
137
138        switch ($this->_mimepart->getType()) {
139        case 'application/pgp-keys':
140            return $this->_outputPGPKey();
141
142        case 'multipart/signed':
143            return $this->_outputPGPSigned();
144
145        case 'multipart/encrypted':
146            $cache = $this->getConfigParam('imp_contents')->getViewCache();
147
148            if (isset($cache->pgp[$id])) {
149                return array_merge(array(
150                    $id => array(
151                        'data' => null,
152                        'status' => $cache->pgp[$id]['status'],
153                        'type' => 'text/plain; charset=' . $this->getConfigParam('charset'),
154                        'wrap' => $cache->pgp[$id]['wrap']
155                    )
156                ), $cache->pgp[$id]['other']);
157            }
158            // Fall-through
159
160        case 'application/pgp-encrypted':
161        case 'application/pgp-signature':
162        default:
163            return array();
164        }
165    }
166
167    /**
168     * If this MIME part can contain embedded MIME part(s), and those part(s)
169     * exist, return a representation of that data.
170     *
171     * @return mixed  A Horde_Mime_Part object representing the embedded data.
172     *                Returns null if no embedded MIME part(s) exist.
173     */
174    protected function _getEmbeddedMimeParts()
175    {
176        if ($this->_mimepart->getType() != 'multipart/encrypted') {
177            return null;
178        }
179
180        $partlist = array_keys($this->_mimepart->contentTypeMap());
181        $base_id = reset($partlist);
182        $version_id = next($partlist);
183        $data_id = Horde_Mime::mimeIdArithmetic($version_id, 'next');
184
185        $status = new IMP_Mime_Status();
186        $status->icon('mime/encryption.png', 'PGP');
187
188        $cache = $this->getConfigParam('imp_contents')->getViewCache();
189        $cache->pgp[$base_id] = array(
190            'status' => array($status),
191            'other' => array(
192                $version_id => null,
193                $data_id => null
194            ),
195            'wrap' => ''
196        );
197
198        /* Is PGP active? */
199        if (empty($GLOBALS['conf']['gnupg']['path']) ||
200            !$GLOBALS['prefs']->getValue('use_pgp')) {
201            $status->addText(_("The data in this part has been encrypted via PGP, however, PGP support is disabled so the message cannot be decrypted."));
202            return null;
203        }
204
205        /* PGP version information appears in the first MIME subpart. We
206         * don't currently need to do anything with this information. The
207         * encrypted data appears in the second MIME subpart. */
208        $encrypted_part = $this->getConfigParam('imp_contents')->getMIMEPart($data_id);
209        $encrypted_data = $encrypted_part->getContents();
210
211        $symmetric_pass = $personal_pass = null;
212
213        /* Check if this a symmetrically encrypted message. */
214        try {
215            $imp_pgp = $GLOBALS['injector']->getInstance('IMP_Crypt_Pgp');
216            $symmetric = $imp_pgp->encryptedSymmetrically($encrypted_data);
217            if ($symmetric) {
218                $symmetric_id = $this->_getSymmetricID();
219                $symmetric_pass = $imp_pgp->getPassphrase('symmetric', $symmetric_id);
220
221                if (is_null($symmetric_pass)) {
222                    $status->addText(_("The data in this part has been encrypted via PGP."));
223
224                    /* Ask for the correct passphrase if this is encrypted
225                     * symmetrically. */
226                    $imple = $GLOBALS['injector']->getInstance('Horde_Core_Factory_Imple')->create('IMP_Ajax_Imple_PassphraseDialog', array(
227                        'params' => array(
228                            'symmetricid' => $symmetric_id
229                        ),
230                        'type' => 'pgpSymmetric'
231                    ));
232                    $status->addText(Horde::link('#', '', '', '', '', '', '', array('id' => $imple->getDomId())) . _("You must enter the passphrase used to encrypt this message to view it.") . '</a>');
233                    return null;
234                }
235            }
236        } catch (Horde_Exception $e) {
237            Horde::log($e, 'INFO');
238            return null;
239        }
240
241        /* Check if this is a literal compressed message. */
242        try {
243            $info = $imp_pgp->pgpPacketInformation($encrypted_data);
244        } catch (Horde_Exception $e) {
245            Horde::log($e, 'INFO');
246            return null;
247        }
248
249        $literal = !empty($info['literal']);
250        if ($literal) {
251            $status->addText(_("The data in this part has been compressed via PGP."));
252        } else {
253            $status->addText(_("The data in this part has been encrypted via PGP."));
254
255            if (!$symmetric) {
256                if ($imp_pgp->getPersonalPrivateKey()) {
257                    $personal_pass = $imp_pgp->getPassphrase('personal');
258                    if (is_null($personal_pass)) {
259                        /* Ask for the private key's passphrase if this is
260                         * encrypted asymmetrically. */
261                        $imple = $GLOBALS['injector']->getInstance('Horde_Core_Factory_Imple')->create('IMP_Ajax_Imple_PassphraseDialog', array(
262                            'type' => 'pgpPersonal'
263                        ));
264                        $status->addText(Horde::link('#', '', '', '', '', '', '', array('id' => $imple->getDomId())) . _("You must enter the passphrase for your PGP private key to view this message.") . '</a>');
265                        return null;
266                    }
267                } else {
268                    /* Output if there is no personal private key to decrypt
269                     * with. */
270                    $status->addText(_("However, no personal private key exists so the message cannot be decrypted."));
271                    return null;
272                }
273            }
274        }
275
276        try {
277            if (!is_null($symmetric_pass)) {
278                $decrypted_data = $imp_pgp->decryptMessage($encrypted_data, 'symmetric', array(
279                    'passphrase' => $symmetric_pass,
280                    'sender' => $this->_getSender()->bare_address
281                ));
282            } elseif (!is_null($personal_pass)) {
283                $decrypted_data = $imp_pgp->decryptMessage($encrypted_data, 'personal', array(
284                    'passphrase' => $personal_pass,
285                    'sender' => $this->_getSender()->bare_address
286                ));
287            } else {
288                $decrypted_data = $imp_pgp->decryptMessage($encrypted_data, 'literal');
289            }
290        } catch (Horde_Exception $e) {
291            $status->addText(_("The data in this part does not appear to be a valid PGP encrypted message. Error: ") . $e->getMessage());
292            if (!is_null($symmetric_pass)) {
293                $imp_pgp->unsetPassphrase('symmetric', $this->_getSymmetricID());
294                return $this->_getEmbeddedMimeParts();
295            }
296            return null;
297        }
298
299        $cache->pgp[$base_id]['wrap'] = 'mimePartWrapValid';
300
301        /* Check for combined encryption/signature data. */
302        if ($decrypted_data->result) {
303            $sig_text = is_bool($decrypted_data->result)
304                ? _("The data in this part has been digitally signed via PGP.")
305                : $this->_textFilter($decrypted_data->result, 'text2html', array('parselevel' => Horde_Text_Filter_Text2html::NOHTML));
306
307            $status2 = new IMP_Mime_Status($sig_text);
308            $status2->action(IMP_Mime_Status::SUCCESS);
309
310            $cache->pgp[$base_id]['status'][] = $status2;
311        }
312
313        /* Force armor data as text/plain data. */
314        if ($this->_mimepart->getMetadata(Horde_Crypt_Pgp_Parse::PGP_ARMOR)) {
315            $decrypted_data->message = "Content-Type: text/plain\n\n" .
316                                       $decrypted_data->message;
317        }
318
319        $new_part = Horde_Mime_Part::parseMessage($decrypted_data->message, array(
320            'forcemime' => true
321        ));
322
323        if ($new_part->getType() == 'multipart/signed') {
324            $data = new Horde_Stream_Temp();
325            try {
326                $data->add(Horde_Mime_Part::getRawPartText($decrypted_data->message, 'header', '1'));
327                $data->add("\n\n");
328                $data->add(Horde_Mime_Part::getRawPartText($decrypted_data->message, 'body', '1'));
329            } catch (Horde_Mime_Exception $e) {}
330
331            $new_part->setMetadata(self::PGP_SIGN_ENC, $data->stream);
332            $new_part->setContents($decrypted_data->message, array(
333                'encoding' => 'binary'
334            ));
335        }
336
337        return $new_part;
338    }
339
340    /**
341     * Generates output for 'application/pgp-keys' MIME_Parts.
342     *
343     * @return string  The HTML output.
344     */
345    protected function _outputPGPKey()
346    {
347        /* Is PGP active? */
348        if (empty($GLOBALS['conf']['gnupg']['path']) ||
349            !$GLOBALS['prefs']->getValue('use_pgp')) {
350            return array();
351        }
352
353        /* Initialize status message. */
354        $status = new IMP_Mime_Status(_("A PGP Public Key is attached to the message."));
355        $status->icon('mime/encryption.png', 'PGP');
356
357        $imp_contents = $this->getConfigParam('imp_contents');
358        $mime_id = $this->_mimepart->getMimeId();
359
360        if ($GLOBALS['prefs']->getValue('use_pgp') &&
361            $GLOBALS['prefs']->getValue('add_source') &&
362            $GLOBALS['registry']->hasMethod('contacts/addField')) {
363            // TODO: Check for key existence.
364            $imple = $GLOBALS['injector']->getInstance('Horde_Core_Factory_Imple')->create('IMP_Ajax_Imple_ImportEncryptKey', array(
365                'mime_id' => $mime_id,
366                'muid' => strval($imp_contents->getIndicesOb()),
367                'type' => 'pgp'
368            ));
369            $status->addText(Horde::link('#', '', '', '', '', '', '', array('id' => $imple->getDomId())) . _("Save the key to your address book.") . '</a>');
370        }
371        $status->addText($imp_contents->linkViewJS($this->_mimepart, 'view_attach', _("View key details."), array('params' => array('mode' => IMP_Contents::RENDER_FULL, 'pgp_view_key' => 1))));
372
373        return array(
374            $mime_id => array(
375                'data' => '',
376                'status' => $status,
377                'type' => 'text/html; charset=' . $this->getConfigParam('charset')
378            )
379        );
380    }
381
382    /**
383     * Generates HTML output for 'multipart/signed' MIME parts.
384     *
385     * @return string  The HTML output.
386     */
387    protected function _outputPGPSigned()
388    {
389        global $conf, $injector, $prefs, $registry, $session;
390
391        $partlist = array_keys($this->_mimepart->contentTypeMap());
392        $base_id = reset($partlist);
393        $signed_id = next($partlist);
394        $sig_id = Horde_Mime::mimeIdArithmetic($signed_id, 'next');
395
396        if (!$prefs->getValue('use_pgp') || empty($conf['gnupg']['path'])) {
397            return array(
398                $sig_id => null
399            );
400        }
401
402        $status = new IMP_Mime_Status();
403        $status->addText(_("The data in this part has been digitally signed via PGP."));
404        $status->icon('mime/encryption.png', 'PGP');
405
406        $ret = array(
407            $base_id => array(
408                'data' => '',
409                'nosummary' => true,
410                'status' => array($status),
411                'type' => 'text/html; charset=' . $this->getConfigParam('charset'),
412                'wrap' => 'mimePartWrap'
413            ),
414            $sig_id => null
415        );
416
417        if ($prefs->getValue('pgp_verify') ||
418            $injector->getInstance('Horde_Variables')->pgp_verify_msg) {
419            $imp_contents = $this->getConfigParam('imp_contents');
420            $sig_part = $imp_contents->getMIMEPart($sig_id);
421
422            $status2 = new IMP_Mime_Status();
423
424            if (!$sig_part) {
425                $status2->action(IMP_Mime_Status::ERROR);
426                $sig_text = _("This digitally signed message is broken.");
427                $ret[$base_id]['wrap'] = 'mimePartWrapInvalid';
428            } else {
429                /* Close session, since this may be a long-running
430                 * operation. */
431                $session->close();
432
433                try {
434                    $imp_pgp = $injector->getInstance('IMP_Crypt_Pgp');
435                    if ($sig_raw = $sig_part->getMetadata(Horde_Crypt_Pgp_Parse::SIG_RAW)) {
436                        $sig_result = $imp_pgp->verifySignature($sig_raw, $this->_getSender()->bare_address, null, $sig_part->getMetadata(Horde_Crypt_Pgp_Parse::SIG_CHARSET));
437                    } else {
438                        $stream = $imp_contents->isEmbedded($signed_id)
439                            ? $this->_mimepart->getMetadata(self::PGP_SIGN_ENC)
440                            : $imp_contents->getBodyPart($signed_id, array('mimeheaders' => true, 'stream' => true))->data;
441
442                        rewind($stream);
443                        stream_filter_register('horde_eol', 'Horde_Stream_Filter_Eol');
444                        stream_filter_append($stream, 'horde_eol', STREAM_FILTER_READ, array(
445                            'eol' => Horde_Mime_Part::RFC_EOL
446                        ));
447
448                        $sig_result = $imp_pgp->verifySignature(stream_get_contents($stream), $this->_getSender()->bare_address, $sig_part->getContents());
449                    }
450
451                    $status2->action(IMP_Mime_Status::SUCCESS);
452                    $sig_text = $sig_result->message;
453                    $ret[$base_id]['wrap'] = 'mimePartWrapValid';
454                } catch (Horde_Exception $e) {
455                    $status2->action(IMP_Mime_Status::ERROR);
456                    $sig_text = $e->getMessage();
457                    $ret[$base_id]['wrap'] = 'mimePartWrapInvalid';
458                }
459            }
460
461            $status2->addText($this->_textFilter($sig_text, 'text2html', array(
462                'parselevel' => Horde_Text_Filter_Text2html::NOHTML
463            )));
464            $ret[$base_id]['status'][] = $status2;
465        } else {
466            switch ($registry->getView()) {
467            case Horde_Registry::VIEW_BASIC:
468                $status->addText(Horde::link(Horde::selfUrlParams()->add(array('pgp_verify_msg' => 1))) . _("Click HERE to verify the message.") . '</a>');
469                break;
470
471            case Horde_Registry::VIEW_DYNAMIC:
472                $status->addText(Horde::link('#', '', 'pgpVerifyMsg') . _("Click HERE to verify the message.") . '</a>');
473                break;
474            }
475        }
476
477        return $ret;
478    }
479
480    /**
481     * Generates the symmetric ID for this message.
482     *
483     * @return string  Symmetric ID.
484     */
485    protected function _getSymmetricID()
486    {
487        return $GLOBALS['injector']->getInstance('IMP_Crypt_Pgp')->getSymmetricID($this->getConfigParam('imp_contents')->getMailbox(), $this->getConfigParam('imp_contents')->getUid(), $this->_mimepart->getMimeId());
488    }
489
490    /**
491     * Determine the address of the sender.
492     *
493     * @return Horde_Mail_Rfc822_Address  The from address.
494     */
495    protected function _getSender()
496    {
497        if (is_null($this->_sender)) {
498            $headers = $this->getConfigParam('imp_contents')->getHeader();
499            $tmp = $headers->getOb('from');
500            $this->_sender = $tmp[0];
501        }
502
503        return $this->_sender;
504    }
505
506    /**
507     * Can this driver render the data?
508     *
509     * @param string $mode  See parent::canRender().
510     *
511     * @return boolean  See parent::canRender().
512     */
513    public function canRender($mode)
514    {
515        switch ($mode) {
516        case 'full':
517            if ($this->_mimepart->getType() == 'application/pgp-keys') {
518                return true;
519            }
520            break;
521
522        case 'raw':
523            if (($this->_mimepart->getType() == 'application/pgp-signature') &&
524                $this->_mimepart->getMetadata(Horde_Crypt_Pgp_Parse::SIG_RAW)) {
525                return true;
526            }
527            break;
528        }
529
530        return parent::canRender($mode);
531    }
532
533}
534