1 # Copyright (C) 2012 W. Trevor King <wking@tremily.us>
3 # This file is part of pgp-mime.
5 # pgp-mime is free software: you can redistribute it and/or modify it under the
6 # terms of the GNU General Public License as published by the Free Software
7 # Foundation, either version 3 of the License, or (at your option) any later
10 # pgp-mime is distributed in the hope that it will be useful, but WITHOUT ANY
11 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
12 # A PARTICULAR PURPOSE. See the GNU General Public License for more details.
14 # You should have received a copy of the GNU General Public License along with
15 # pgp-mime. If not, see <http://www.gnu.org/licenses/>.
18 from email import message_from_bytes as _message_from_bytes
19 from email.encoders import encode_7or8bit as _encode_7or8bit
20 from email.generator import BytesGenerator as _BytesGenerator
21 from email.mime.application import MIMEApplication as _MIMEApplication
22 from email.mime.multipart import MIMEMultipart as _MIMEMultipart
23 from email import policy as _email_policy
26 from . import LOG as _LOG
27 from .crypt import sign_and_encrypt_bytes as _sign_and_encrypt_bytes
28 from .crypt import verify_bytes as _verify_bytes
29 from .email import email_targets as _email_targets
30 from .email import strip_bcc as _strip_bcc
33 def _flatten(message):
34 r"""Flatten a message to bytes.
36 >>> from pgp_mime.email import encodedMIMEText
37 >>> message = encodedMIMEText('Hi\nBye')
38 >>> _flatten(message) # doctest: +ELLIPSIS
39 b'Content-Type: text/plain; charset="us-ascii"\r\nMIME-Version: ...'
41 bytesio = _io.BytesIO()
42 generator = _BytesGenerator(bytesio, policy=_email_policy.SMTP)
43 generator.flatten(message)
44 return bytesio.getvalue()
46 def sign(message, signers=None, **kwargs):
47 r"""Sign a ``Message``, returning the signed version.
51 +-> application/pgp-signature (signature)
53 >>> from pgp_mime.email import encodedMIMEText
54 >>> message = encodedMIMEText('Hi\nBye')
55 >>> signed = sign(message, signers=['pgp-mime@invalid.com'])
56 >>> signed.set_boundary('boundsep')
57 >>> print(signed.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
58 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="boundsep"
60 Content-Disposition: inline
63 Content-Type: text/plain; charset="us-ascii"
65 Content-Transfer-Encoding: 7bit
66 Content-Disposition: inline
72 Content-Transfer-Encoding: 7bit
73 Content-Description: OpenPGP digital signature
74 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
76 -----BEGIN PGP SIGNATURE-----
78 -----END PGP SIGNATURE-----
82 >>> from email.mime.multipart import MIMEMultipart
83 >>> message = MIMEMultipart()
84 >>> message.attach(encodedMIMEText('Part A'))
85 >>> message.attach(encodedMIMEText('Part B'))
86 >>> signed = sign(message, signers=['pgp-mime@invalid.com'])
87 >>> signed.set_boundary('boundsep')
88 >>> print(signed.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
89 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="boundsep"
91 Content-Disposition: inline
94 Content-Type: multipart/mixed; boundary="===============...=="
97 --===============...==
98 Content-Type: text/plain; charset="us-ascii"
100 Content-Transfer-Encoding: 7bit
101 Content-Disposition: inline
104 --===============...==
105 Content-Type: text/plain; charset="us-ascii"
107 Content-Transfer-Encoding: 7bit
108 Content-Disposition: inline
111 --===============...==--
114 Content-Transfer-Encoding: 7bit
115 Content-Description: OpenPGP digital signature
116 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
118 -----BEGIN PGP SIGNATURE-----
120 -----END PGP SIGNATURE-----
124 body = _flatten(message)
125 signature = str(_sign_and_encrypt_bytes(data=body, **kwargs), 'us-ascii')
126 sig = _MIMEApplication(
128 _subtype='pgp-signature; name="signature.asc"',
129 _encoder=_encode_7or8bit)
130 sig['Content-Description'] = 'OpenPGP digital signature'
131 sig.set_charset('us-ascii')
133 msg = _MIMEMultipart(
134 'signed', micalg='pgp-sha1', protocol='application/pgp-signature')
137 msg['Content-Disposition'] = 'inline'
140 def encrypt(message, recipients=None, **kwargs):
141 r"""Encrypt a ``Message``, returning the encrypted version.
144 +-> application/pgp-encrypted (control information)
145 +-> application/octet-stream (body)
147 >>> from pgp_mime.email import encodedMIMEText
148 >>> message = encodedMIMEText('Hi\nBye')
149 >>> message['To'] = 'pgp-mime-test <pgp-mime@invalid.com>'
150 >>> encrypted = encrypt(message)
151 >>> encrypted.set_boundary('boundsep')
152 >>> print(encrypted.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
153 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="boundsep"
155 Content-Disposition: inline
159 Content-Transfer-Encoding: 7bit
160 Content-Type: application/pgp-encrypted; charset="us-ascii"
166 Content-Transfer-Encoding: 7bit
167 Content-Description: OpenPGP encrypted message
168 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
170 -----BEGIN PGP MESSAGE-----
172 -----END PGP MESSAGE-----
176 >>> from email.mime.multipart import MIMEMultipart
177 >>> message = MIMEMultipart()
178 >>> message.attach(encodedMIMEText('Part A'))
179 >>> message.attach(encodedMIMEText('Part B'))
180 >>> encrypted = encrypt(
181 ... message, recipients=['pgp-mime@invalid.com'], always_trust=True)
182 >>> encrypted.set_boundary('boundsep')
183 >>> print(encrypted.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
184 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="boundsep"
186 Content-Disposition: inline
190 Content-Transfer-Encoding: 7bit
191 Content-Type: application/pgp-encrypted; charset="us-ascii"
197 Content-Transfer-Encoding: 7bit
198 Content-Description: OpenPGP encrypted message
199 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
201 -----BEGIN PGP MESSAGE-----
203 -----END PGP MESSAGE-----
207 body = _flatten(message)
208 if recipients is None:
209 recipients = [email for name,email in _email_targets(message)]
210 _LOG.debug('extracted encryption recipients: {}'.format(recipients))
211 encrypted = str(_sign_and_encrypt_bytes(
212 data=body, recipients=recipients, **kwargs), 'us-ascii')
213 enc = _MIMEApplication(
215 _subtype='octet-stream; name="encrypted.asc"',
216 _encoder=_encode_7or8bit)
217 enc['Content-Description'] = 'OpenPGP encrypted message'
218 enc.set_charset('us-ascii')
219 control = _MIMEApplication(
220 _data='Version: 1\n',
221 _subtype='pgp-encrypted',
222 _encoder=_encode_7or8bit)
223 control.set_charset('us-ascii')
224 msg = _MIMEMultipart(
227 protocol='application/pgp-encrypted')
230 msg['Content-Disposition'] = 'inline'
233 def sign_and_encrypt(message, signers=None, recipients=None, **kwargs):
234 r"""Sign and encrypt a ``Message``, returning the encrypted version.
237 +-> application/pgp-encrypted (control information)
238 +-> application/octet-stream (body)
240 >>> from pgp_mime.email import encodedMIMEText
241 >>> message = encodedMIMEText('Hi\nBye')
242 >>> message['To'] = 'pgp-mime-test <pgp-mime@invalid.com>'
243 >>> encrypted = sign_and_encrypt(
244 ... message, signers=['pgp-mime@invalid.com'], always_trust=True)
245 >>> encrypted.set_boundary('boundsep')
246 >>> print(encrypted.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
247 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="boundsep"
249 Content-Disposition: inline
253 Content-Transfer-Encoding: 7bit
254 Content-Type: application/pgp-encrypted; charset="us-ascii"
260 Content-Transfer-Encoding: 7bit
261 Content-Description: OpenPGP encrypted message
262 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
264 -----BEGIN PGP MESSAGE-----
266 -----END PGP MESSAGE-----
270 >>> from email.mime.multipart import MIMEMultipart
271 >>> message = MIMEMultipart()
272 >>> message.attach(encodedMIMEText('Part A'))
273 >>> message.attach(encodedMIMEText('Part B'))
274 >>> encrypted = sign_and_encrypt(
275 ... message, signers=['pgp-mime@invalid.com'],
276 ... recipients=['pgp-mime@invalid.com'], always_trust=True)
277 >>> encrypted.set_boundary('boundsep')
278 >>> print(encrypted.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
279 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="boundsep"
281 Content-Disposition: inline
285 Content-Transfer-Encoding: 7bit
286 Content-Type: application/pgp-encrypted; charset="us-ascii"
292 Content-Transfer-Encoding: 7bit
293 Content-Description: OpenPGP encrypted message
294 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
296 -----BEGIN PGP MESSAGE-----
298 -----END PGP MESSAGE-----
302 _strip_bcc(message=message)
303 body = _flatten(message)
304 if recipients is None:
305 recipients = [email for name,email in _email_targets(message)]
306 _LOG.debug('extracted encryption recipients: {}'.format(recipients))
308 _sign_and_encrypt_bytes(
309 data=body, signers=signers, recipients=recipients, **kwargs),
311 enc = _MIMEApplication(
313 _subtype='octet-stream; name="encrypted.asc"',
314 _encoder=_encode_7or8bit)
315 enc['Content-Description'] = 'OpenPGP encrypted message'
316 enc.set_charset('us-ascii')
317 control = _MIMEApplication(
318 _data='Version: 1\n',
319 _subtype='pgp-encrypted',
320 _encoder=_encode_7or8bit)
321 control.set_charset('us-ascii')
322 msg = _MIMEMultipart(
325 protocol='application/pgp-encrypted')
328 msg['Content-Disposition'] = 'inline'
331 def _get_encrypted_parts(message):
332 ct = message.get_content_type()
333 assert ct == 'multipart/encrypted', ct
334 params = dict(message.get_params())
335 assert params.get('protocol', None) == 'application/pgp-encrypted', params
336 assert message.is_multipart(), message
337 control = body = None
338 for part in message.get_payload():
341 assert part.is_multipart() == False, part
342 ct = part.get_content_type()
343 if ct == 'application/pgp-encrypted':
345 raise ValueError('multiple application/pgp-encrypted parts')
347 elif ct == 'application/octet-stream':
349 raise ValueError('multiple application/octet-stream parts')
352 raise ValueError('unnecessary {} part'.format(ct))
354 raise ValueError('missing application/pgp-encrypted part')
356 raise ValueError('missing application/octet-stream part')
357 return (control, body)
359 def _get_signed_parts(message):
360 ct = message.get_content_type()
361 assert ct == 'multipart/signed', ct
362 params = dict(message.get_params())
363 assert params.get('protocol', None) == 'application/pgp-signature', params
364 assert message.is_multipart(), message
365 body = signature = None
366 for part in message.get_payload():
369 ct = part.get_content_type()
370 if ct == 'application/pgp-signature':
372 raise ValueError('multiple application/pgp-signature parts')
376 raise ValueError('multiple non-signature parts')
379 raise ValueError('missing body part')
381 raise ValueError('missing application/pgp-signature part')
382 return (body, signature)
384 def decrypt(message, **kwargs):
385 r"""Decrypt a multipart/encrypted message.
387 >>> from pgp_mime.email import encodedMIMEText
388 >>> message = encodedMIMEText('Hi\nBye')
389 >>> encrypted = encrypt(message, recipients=['<pgp-mime@invalid.com>'])
390 >>> decrypted = decrypt(encrypted)
391 >>> print(decrypted.as_string().replace('\r\n', '\n'))
392 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
393 Content-Type: text/plain; charset="us-ascii"
395 Content-Transfer-Encoding: 7bit
396 Content-Disposition: inline
401 >>> from email.mime.multipart import MIMEMultipart
402 >>> message = MIMEMultipart()
403 >>> message.attach(encodedMIMEText('Part A'))
404 >>> message.attach(encodedMIMEText('Part B'))
405 >>> encrypted = encrypt(
406 ... message, recipients=['pgp-mime@invalid.com'], always_trust=True)
407 >>> decrypted = decrypt(encrypted)
408 >>> decrypted.set_boundary('boundsep')
409 >>> print(decrypted.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
410 Content-Type: multipart/mixed; boundary="boundsep"
414 Content-Type: text/plain; charset="us-ascii"
416 Content-Transfer-Encoding: 7bit
417 Content-Disposition: inline
421 Content-Type: text/plain; charset="us-ascii"
423 Content-Transfer-Encoding: 7bit
424 Content-Disposition: inline
430 control,body = _get_encrypted_parts(message)
431 encrypted = body.get_payload(decode=True)
432 if not isinstance(encrypted, bytes):
433 encrypted = encrypted.encode('us-ascii')
434 decrypted,verified,result = _verify_bytes(encrypted, **kwargs)
435 return _message_from_bytes(decrypted)
437 def verify(message, **kwargs):
438 r"""Verify a signature on ``message``, possibly decrypting first.
440 >>> from pgp_mime.email import encodedMIMEText
441 >>> message = encodedMIMEText('Hi\nBye')
442 >>> message['To'] = 'pgp-mime-test <pgp-mime@invalid.com>'
443 >>> encrypted = sign_and_encrypt(message, signers=['pgp-mime@invalid.com'],
444 ... always_trust=True)
445 >>> decrypted,verified,signatures = verify(encrypted)
446 >>> print(decrypted.as_string().replace('\r\n', '\n'))
447 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
448 Content-Type: text/plain; charset="us-ascii"
450 Content-Transfer-Encoding: 7bit
451 Content-Disposition: inline
452 To: pgp-mime-test <pgp-mime@invalid.com>
458 >>> for s in signatures:
459 ... print(s.dumps()) # doctest: +REPORT_UDIFF
460 ... # doctest: +REPORT_UDIFF, +ELLIPSIS
461 DECC812C8795ADD60538B0CD171008BA2F73DE2E signature:
471 signature expired: False
476 expiration timestamp: None
477 wrong key usage: False
478 pka trust: not available
481 validity reason: success
482 public key algorithm: RSA
483 hash algorithm: SHA256
485 >>> from email.mime.multipart import MIMEMultipart
486 >>> message = MIMEMultipart()
487 >>> message.attach(encodedMIMEText('Part A'))
488 >>> message.attach(encodedMIMEText('Part B'))
489 >>> signed = sign(message, signers=['pgp-mime@invalid.com'])
490 >>> decrypted,verified,signatures = verify(signed)
491 >>> decrypted.set_boundary('boundsep')
492 >>> print(decrypted.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
493 Content-Type: multipart/mixed; boundary="boundsep"
497 Content-Type: text/plain; charset="us-ascii"
499 Content-Transfer-Encoding: 7bit
500 Content-Disposition: inline
504 Content-Type: text/plain; charset="us-ascii"
506 Content-Transfer-Encoding: 7bit
507 Content-Disposition: inline
513 >>> for s in signatures:
514 ... print(s.dumps()) # doctest: +REPORT_UDIFF
515 ... # doctest: +REPORT_UDIFF, +ELLIPSIS
516 DECC812C8795ADD60538B0CD171008BA2F73DE2E signature:
526 signature expired: False
531 expiration timestamp: None
532 wrong key usage: False
533 pka trust: not available
536 validity reason: success
537 public key algorithm: RSA
540 Test a message generated by Mutt (for sanity):
542 >>> from email import message_from_bytes
543 >>> message_bytes = b'\n'.join([
544 ... b'Return-Path: <pgp-mime@invalid.com>',
545 ... b'Received: by invalid; Tue, 24 Apr 2012 19:46:59 -0400',
546 ... b'Date: Tue, 24 Apr 2012 19:46:59 -0400',
547 ... b'From: pgp-mime-test <pgp-mime@invalid.com',
548 ... b'To: pgp-mime@invalid.com',
549 ... b'Subject: test',
550 ... b'Message-ID: <20120424233415.GA27788@invalid>',
551 ... b'MIME-Version: 1.0',
552 ... b'Content-Type: multipart/signed; micalg=pgp-sha1;',
553 ... b' protocol="application/pgp-signature";',
554 ... b' boundary="kORqDWCi7qDJ0mEj"',
555 ... b'Content-Disposition: inline',
556 ... b'User-Agent: Mutt/1.5.21 (2010-09-15)',
557 ... b'Content-Length: 740',
560 ... b'--kORqDWCi7qDJ0mEj',
561 ... b'Content-Type: text/plain; charset=us-ascii',
562 ... b'Content-Disposition: inline',
566 ... b'--kORqDWCi7qDJ0mEj',
567 ... b'Content-Type: application/pgp-signature; name="signature.asc"',
568 ... b'Content-Description: OpenPGP digital signature',
570 ... b'-----BEGIN PGP SIGNATURE-----',
571 ... b'Version: GnuPG v2.0.17 (GNU/Linux)',
573 ... b'iQEcBAEBAgAGBQJPlztxAAoJEFEa7aZDMrbjwT0H/i9eN6CJ2FIinK7Ps04XYEbL',
574 ... b'PSQV1xCxb+2bk7yA4zQnjAKOPSuMDXfVG669Pbj8yo4DOgUqIgh+lK+voec9uwsJ',
575 ... b'ZgUJcMozSmEFSTPO+Fiyx0S+NjnaLsas6IQrQTVDc6lWiIZttgxuN0crH5DcLomB',
576 ... b'Ip90+ELbzVN3yBAjMJ1Y6xnKd7C0IOKm7VunYu9eCzJ/Rik5qZ0+IacQQnnrFJEN',
577 ... b'04nDvDUzfaKy80Ke7VAQBIRi85XCsM2h0KDXOGUZ0xPQ8L/4eUK9tL6DJaqKqFPl',
578 ... b'zNiwfpue01o6l6kngrQdXZ3tuv0HbLGc4ACzfz5XuGvE5PYTNEsylKLUMiSCIFc=',
580 ... b'-----END PGP SIGNATURE-----',
582 ... b'--kORqDWCi7qDJ0mEj--',
584 >>> message = message_from_bytes(message_bytes)
585 >>> decrypted,verified,signatures = verify(message)
586 >>> print(decrypted.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
587 Content-Type: text/plain; charset=us-ascii
588 Content-Disposition: inline
594 >>> for s in signatures:
595 ... print(s.dumps()) # doctest: +REPORT_UDIFF
596 ... # doctest: +REPORT_UDIFF, +ELLIPSIS
597 B2EDBE0E771A4B8708DD16A7511AEDA64332B6E3 signature:
607 signature expired: False
611 timestamp: Tue Apr 24 23:46:57 2012
612 expiration timestamp: None
613 wrong key usage: False
614 pka trust: not available
617 validity reason: success
618 public key algorithm: RSA
621 ct = message.get_content_type()
622 if ct == 'multipart/encrypted':
623 control,body = _get_encrypted_parts(message)
624 encrypted = body.get_payload(decode=True)
625 if not isinstance(encrypted, bytes):
626 encrypted = encrypted.encode('us-ascii')
627 decrypted,verified,message = _verify_bytes(encrypted)
628 return (_message_from_bytes(decrypted), verified, message)
629 body,signature = _get_signed_parts(message)
630 sig_data = signature.get_payload(decode=True)
631 if not isinstance(sig_data, bytes):
632 sig_data = sig_data.encode('us-ascii')
633 decrypted,verified,result = _verify_bytes(
634 _flatten(body), signature=sig_data, **kwargs)
635 return (_copy.deepcopy(body), verified, result)