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, **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().replace(
58 ... 'micalg="pgp-sha1"; protocol="application/pgp-signature"',
59 ... 'protocol="application/pgp-signature"; micalg="pgp-sha1"'))
60 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
61 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="boundsep"
63 Content-Disposition: inline
66 Content-Type: text/plain; charset="us-ascii"
68 Content-Transfer-Encoding: 7bit
69 Content-Disposition: inline
75 Content-Transfer-Encoding: 7bit
76 Content-Description: OpenPGP digital signature
77 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
79 -----BEGIN PGP SIGNATURE-----
81 -----END PGP SIGNATURE-----
85 >>> from email.mime.multipart import MIMEMultipart
86 >>> message = MIMEMultipart()
87 >>> message.attach(encodedMIMEText('Part A'))
88 >>> message.attach(encodedMIMEText('Part B'))
89 >>> signed = sign(message, signers=['pgp-mime@invalid.com'])
90 >>> signed.set_boundary('boundsep')
91 >>> print(signed.as_string().replace(
92 ... 'micalg="pgp-sha1"; protocol="application/pgp-signature"',
93 ... 'protocol="application/pgp-signature"; micalg="pgp-sha1"'))
94 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
95 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="boundsep"
97 Content-Disposition: inline
100 Content-Type: multipart/mixed; boundary="===============...=="
103 --===============...==
104 Content-Type: text/plain; charset="us-ascii"
106 Content-Transfer-Encoding: 7bit
107 Content-Disposition: inline
110 --===============...==
111 Content-Type: text/plain; charset="us-ascii"
113 Content-Transfer-Encoding: 7bit
114 Content-Disposition: inline
117 --===============...==--
120 Content-Transfer-Encoding: 7bit
121 Content-Description: OpenPGP digital signature
122 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
124 -----BEGIN PGP SIGNATURE-----
126 -----END PGP SIGNATURE-----
130 body = _flatten(message)
131 signature = str(_sign_and_encrypt_bytes(data=body, **kwargs), 'us-ascii')
132 sig = _MIMEApplication(
134 _subtype='pgp-signature; name="signature.asc"',
135 _encoder=_encode_7or8bit)
136 sig['Content-Description'] = 'OpenPGP digital signature'
137 sig.set_charset('us-ascii')
139 msg = _MIMEMultipart(
140 'signed', micalg='pgp-sha1', protocol='application/pgp-signature')
143 msg['Content-Disposition'] = 'inline'
146 def encrypt(message, recipients=None, **kwargs):
147 r"""Encrypt a ``Message``, returning the encrypted version.
150 +-> application/pgp-encrypted (control information)
151 +-> application/octet-stream (body)
153 >>> from pgp_mime.email import encodedMIMEText
154 >>> message = encodedMIMEText('Hi\nBye')
155 >>> message['To'] = 'pgp-mime-test <pgp-mime@invalid.com>'
156 >>> encrypted = encrypt(message, always_trust=True)
157 >>> encrypted.set_boundary('boundsep')
158 >>> print(encrypted.as_string().replace(
159 ... 'micalg="pgp-sha1"; protocol="application/pgp-encrypted"',
160 ... 'protocol="application/pgp-encrypted"; micalg="pgp-sha1"'))
161 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
162 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="boundsep"
164 Content-Disposition: inline
168 Content-Transfer-Encoding: 7bit
169 Content-Type: application/pgp-encrypted; charset="us-ascii"
175 Content-Transfer-Encoding: 7bit
176 Content-Description: OpenPGP encrypted message
177 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
179 -----BEGIN PGP MESSAGE-----
181 -----END PGP MESSAGE-----
185 >>> from email.mime.multipart import MIMEMultipart
186 >>> message = MIMEMultipart()
187 >>> message.attach(encodedMIMEText('Part A'))
188 >>> message.attach(encodedMIMEText('Part B'))
189 >>> encrypted = encrypt(
190 ... message, recipients=['pgp-mime@invalid.com'], always_trust=True)
191 >>> encrypted.set_boundary('boundsep')
192 >>> print(encrypted.as_string().replace(
193 ... 'micalg="pgp-sha1"; protocol="application/pgp-encrypted"',
194 ... 'protocol="application/pgp-encrypted"; micalg="pgp-sha1"'))
195 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
196 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="boundsep"
198 Content-Disposition: inline
202 Content-Transfer-Encoding: 7bit
203 Content-Type: application/pgp-encrypted; charset="us-ascii"
209 Content-Transfer-Encoding: 7bit
210 Content-Description: OpenPGP encrypted message
211 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
213 -----BEGIN PGP MESSAGE-----
215 -----END PGP MESSAGE-----
219 body = _flatten(message)
220 if recipients is None:
221 recipients = [email for name,email in _email_targets(message)]
222 _LOG.debug('extracted encryption recipients: {}'.format(recipients))
223 encrypted = str(_sign_and_encrypt_bytes(
224 data=body, recipients=recipients, **kwargs), 'us-ascii')
225 enc = _MIMEApplication(
227 _subtype='octet-stream; name="encrypted.asc"',
228 _encoder=_encode_7or8bit)
229 enc['Content-Description'] = 'OpenPGP encrypted message'
230 enc.set_charset('us-ascii')
231 control = _MIMEApplication(
232 _data='Version: 1\n',
233 _subtype='pgp-encrypted',
234 _encoder=_encode_7or8bit)
235 control.set_charset('us-ascii')
236 msg = _MIMEMultipart(
239 protocol='application/pgp-encrypted')
242 msg['Content-Disposition'] = 'inline'
245 def sign_and_encrypt(message, signers=None, recipients=None, **kwargs):
246 r"""Sign and encrypt a ``Message``, returning the encrypted version.
249 +-> application/pgp-encrypted (control information)
250 +-> application/octet-stream (body)
252 >>> from pgp_mime.email import encodedMIMEText
253 >>> message = encodedMIMEText('Hi\nBye')
254 >>> message['To'] = 'pgp-mime-test <pgp-mime@invalid.com>'
255 >>> encrypted = sign_and_encrypt(
256 ... message, signers=['pgp-mime@invalid.com'], always_trust=True)
257 >>> encrypted.set_boundary('boundsep')
258 >>> print(encrypted.as_string().replace(
259 ... 'micalg="pgp-sha1"; protocol="application/pgp-encrypted"',
260 ... 'protocol="application/pgp-encrypted"; micalg="pgp-sha1"'))
261 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
262 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="boundsep"
264 Content-Disposition: inline
268 Content-Transfer-Encoding: 7bit
269 Content-Type: application/pgp-encrypted; charset="us-ascii"
275 Content-Transfer-Encoding: 7bit
276 Content-Description: OpenPGP encrypted message
277 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
279 -----BEGIN PGP MESSAGE-----
281 -----END PGP MESSAGE-----
285 >>> from email.mime.multipart import MIMEMultipart
286 >>> message = MIMEMultipart()
287 >>> message.attach(encodedMIMEText('Part A'))
288 >>> message.attach(encodedMIMEText('Part B'))
289 >>> encrypted = sign_and_encrypt(
290 ... message, signers=['pgp-mime@invalid.com'],
291 ... recipients=['pgp-mime@invalid.com'], always_trust=True)
292 >>> encrypted.set_boundary('boundsep')
293 >>> print(encrypted.as_string().replace(
294 ... 'micalg="pgp-sha1"; protocol="application/pgp-encrypted"',
295 ... 'protocol="application/pgp-encrypted"; micalg="pgp-sha1"'))
296 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
297 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="boundsep"
299 Content-Disposition: inline
303 Content-Transfer-Encoding: 7bit
304 Content-Type: application/pgp-encrypted; charset="us-ascii"
310 Content-Transfer-Encoding: 7bit
311 Content-Description: OpenPGP encrypted message
312 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
314 -----BEGIN PGP MESSAGE-----
316 -----END PGP MESSAGE-----
320 _strip_bcc(message=message)
321 body = _flatten(message)
322 if recipients is None:
323 recipients = [email for name,email in _email_targets(message)]
324 _LOG.debug('extracted encryption recipients: {}'.format(recipients))
326 _sign_and_encrypt_bytes(
327 data=body, signers=signers, recipients=recipients, **kwargs),
329 enc = _MIMEApplication(
331 _subtype='octet-stream; name="encrypted.asc"',
332 _encoder=_encode_7or8bit)
333 enc['Content-Description'] = 'OpenPGP encrypted message'
334 enc.set_charset('us-ascii')
335 control = _MIMEApplication(
336 _data='Version: 1\n',
337 _subtype='pgp-encrypted',
338 _encoder=_encode_7or8bit)
339 control.set_charset('us-ascii')
340 msg = _MIMEMultipart(
343 protocol='application/pgp-encrypted')
346 msg['Content-Disposition'] = 'inline'
349 def _get_encrypted_parts(message):
350 ct = message.get_content_type()
351 assert ct == 'multipart/encrypted', ct
352 params = dict(message.get_params())
353 assert params.get('protocol', None) == 'application/pgp-encrypted', params
354 assert message.is_multipart(), message
355 control = body = None
356 for part in message.get_payload():
359 assert part.is_multipart() == False, part
360 ct = part.get_content_type()
361 if ct == 'application/pgp-encrypted':
363 raise ValueError('multiple application/pgp-encrypted parts')
365 elif ct == 'application/octet-stream':
367 raise ValueError('multiple application/octet-stream parts')
370 raise ValueError('unnecessary {} part'.format(ct))
372 raise ValueError('missing application/pgp-encrypted part')
374 raise ValueError('missing application/octet-stream part')
375 return (control, body)
377 def _get_signed_parts(message):
378 ct = message.get_content_type()
379 assert ct == 'multipart/signed', ct
380 params = dict(message.get_params())
381 assert params.get('protocol', None) == 'application/pgp-signature', params
382 assert message.is_multipart(), message
383 body = signature = None
384 for part in message.get_payload():
387 ct = part.get_content_type()
388 if ct == 'application/pgp-signature':
390 raise ValueError('multiple application/pgp-signature parts')
394 raise ValueError('multiple non-signature parts')
397 raise ValueError('missing body part')
399 raise ValueError('missing application/pgp-signature part')
400 return (body, signature)
402 def decrypt(message, **kwargs):
403 r"""Decrypt a multipart/encrypted message.
405 >>> from pgp_mime.email import encodedMIMEText
406 >>> message = encodedMIMEText('Hi\nBye')
407 >>> encrypted = encrypt(
408 ... message, recipients=['<pgp-mime@invalid.com>'], always_trust=True)
409 >>> decrypted = decrypt(encrypted)
410 >>> print(decrypted.as_string().replace('\r\n', '\n'))
411 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
412 Content-Type: text/plain; charset="us-ascii"
414 Content-Transfer-Encoding: 7bit
415 Content-Disposition: inline
420 >>> from email.mime.multipart import MIMEMultipart
421 >>> message = MIMEMultipart()
422 >>> message.attach(encodedMIMEText('Part A'))
423 >>> message.attach(encodedMIMEText('Part B'))
424 >>> encrypted = encrypt(
425 ... message, recipients=['pgp-mime@invalid.com'], always_trust=True)
426 >>> decrypted = decrypt(encrypted)
427 >>> decrypted.set_boundary('boundsep')
428 >>> print(decrypted.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
429 Content-Type: multipart/mixed; boundary="boundsep"
433 Content-Type: text/plain; charset="us-ascii"
435 Content-Transfer-Encoding: 7bit
436 Content-Disposition: inline
440 Content-Type: text/plain; charset="us-ascii"
442 Content-Transfer-Encoding: 7bit
443 Content-Disposition: inline
449 control,body = _get_encrypted_parts(message)
450 encrypted = body.get_payload(decode=True)
451 if not isinstance(encrypted, bytes):
452 encrypted = encrypted.encode('us-ascii')
453 decrypted,verified,result = _verify_bytes(encrypted, **kwargs)
454 return _message_from_bytes(decrypted)
456 def verify(message, **kwargs):
457 r"""Verify a signature on ``message``, possibly decrypting first.
459 >>> from pgp_mime.email import encodedMIMEText
460 >>> message = encodedMIMEText('Hi\nBye')
461 >>> message['To'] = 'pgp-mime-test <pgp-mime@invalid.com>'
462 >>> encrypted = sign_and_encrypt(message, signers=['pgp-mime@invalid.com'],
463 ... always_trust=True)
464 >>> decrypted,verified,signatures = verify(encrypted)
465 >>> print(decrypted.as_string().replace('\r\n', '\n'))
466 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
467 Content-Type: text/plain; charset="us-ascii"
469 Content-Transfer-Encoding: 7bit
470 Content-Disposition: inline
471 To: pgp-mime-test <pgp-mime@invalid.com>
477 >>> for s in signatures:
478 ... print(s.dumps()) # doctest: +REPORT_UDIFF
479 ... # doctest: +REPORT_UDIFF, +ELLIPSIS
480 DECC812C8795ADD60538B0CD171008BA2F73DE2E signature:
490 signature expired: False
495 expiration timestamp: None
496 wrong key usage: False
497 pka trust: not available
500 validity reason: success
501 public key algorithm: RSA
502 hash algorithm: SHA256
504 >>> from email.mime.multipart import MIMEMultipart
505 >>> message = MIMEMultipart()
506 >>> message.attach(encodedMIMEText('Part A'))
507 >>> message.attach(encodedMIMEText('Part B'))
508 >>> signed = sign(message, signers=['pgp-mime@invalid.com'])
509 >>> decrypted,verified,signatures = verify(signed)
510 >>> decrypted.set_boundary('boundsep')
511 >>> print(decrypted.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
512 Content-Type: multipart/mixed; boundary="boundsep"
516 Content-Type: text/plain; charset="us-ascii"
518 Content-Transfer-Encoding: 7bit
519 Content-Disposition: inline
523 Content-Type: text/plain; charset="us-ascii"
525 Content-Transfer-Encoding: 7bit
526 Content-Disposition: inline
532 >>> for s in signatures:
533 ... print(s.dumps()) # doctest: +REPORT_UDIFF
534 ... # doctest: +REPORT_UDIFF, +ELLIPSIS
535 DECC812C8795ADD60538B0CD171008BA2F73DE2E signature:
545 signature expired: False
550 expiration timestamp: None
551 wrong key usage: False
552 pka trust: not available
555 validity reason: success
556 public key algorithm: RSA
559 Test a message generated by Mutt (for sanity):
561 >>> from email import message_from_bytes
562 >>> message_bytes = b'\n'.join([
563 ... b'Return-Path: <pgp-mime@invalid.com>',
564 ... b'Received: by invalid; Tue, 24 Apr 2012 19:46:59 -0400',
565 ... b'Date: Tue, 24 Apr 2012 19:46:59 -0400',
566 ... b'From: pgp-mime-test <pgp-mime@invalid.com',
567 ... b'To: pgp-mime@invalid.com',
568 ... b'Subject: test',
569 ... b'Message-ID: <20120424233415.GA27788@invalid>',
570 ... b'MIME-Version: 1.0',
571 ... b'Content-Type: multipart/signed; micalg=pgp-sha1;',
572 ... b' protocol="application/pgp-signature";',
573 ... b' boundary="kORqDWCi7qDJ0mEj"',
574 ... b'Content-Disposition: inline',
575 ... b'User-Agent: Mutt/1.5.21 (2010-09-15)',
576 ... b'Content-Length: 740',
579 ... b'--kORqDWCi7qDJ0mEj',
580 ... b'Content-Type: text/plain; charset=us-ascii',
581 ... b'Content-Disposition: inline',
585 ... b'--kORqDWCi7qDJ0mEj',
586 ... b'Content-Type: application/pgp-signature; name="signature.asc"',
587 ... b'Content-Description: OpenPGP digital signature',
589 ... b'-----BEGIN PGP SIGNATURE-----',
590 ... b'Version: GnuPG v2.0.17 (GNU/Linux)',
592 ... b'iQEcBAEBAgAGBQJPlztxAAoJEFEa7aZDMrbjwT0H/i9eN6CJ2FIinK7Ps04XYEbL',
593 ... b'PSQV1xCxb+2bk7yA4zQnjAKOPSuMDXfVG669Pbj8yo4DOgUqIgh+lK+voec9uwsJ',
594 ... b'ZgUJcMozSmEFSTPO+Fiyx0S+NjnaLsas6IQrQTVDc6lWiIZttgxuN0crH5DcLomB',
595 ... b'Ip90+ELbzVN3yBAjMJ1Y6xnKd7C0IOKm7VunYu9eCzJ/Rik5qZ0+IacQQnnrFJEN',
596 ... b'04nDvDUzfaKy80Ke7VAQBIRi85XCsM2h0KDXOGUZ0xPQ8L/4eUK9tL6DJaqKqFPl',
597 ... b'zNiwfpue01o6l6kngrQdXZ3tuv0HbLGc4ACzfz5XuGvE5PYTNEsylKLUMiSCIFc=',
599 ... b'-----END PGP SIGNATURE-----',
601 ... b'--kORqDWCi7qDJ0mEj--',
603 >>> message = message_from_bytes(message_bytes)
604 >>> decrypted,verified,signatures = verify(message)
605 >>> print(decrypted.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
606 Content-Type: text/plain; charset=us-ascii
607 Content-Disposition: inline
613 >>> for s in signatures:
614 ... print(s.dumps()) # doctest: +REPORT_UDIFF
615 ... # doctest: +REPORT_UDIFF, +ELLIPSIS
616 B2EDBE0E771A4B8708DD16A7511AEDA64332B6E3 signature:
626 signature expired: False
630 timestamp: Tue Apr 24 23:46:57 2012
631 expiration timestamp: None
632 wrong key usage: False
633 pka trust: not available
636 validity reason: success
637 public key algorithm: RSA
640 ct = message.get_content_type()
641 if ct == 'multipart/encrypted':
642 control,body = _get_encrypted_parts(message)
643 encrypted = body.get_payload(decode=True)
644 if not isinstance(encrypted, bytes):
645 encrypted = encrypted.encode('us-ascii')
646 decrypted,verified,message = _verify_bytes(encrypted)
647 return (_message_from_bytes(decrypted), verified, message)
648 body,signature = _get_signed_parts(message)
649 sig_data = signature.get_payload(decode=True)
650 if not isinstance(sig_data, bytes):
651 sig_data = sig_data.encode('us-ascii')
652 decrypted,verified,result = _verify_bytes(
653 _flatten(body), signature=sig_data, **kwargs)
654 return (_copy.deepcopy(body), verified, result)