Force \r\n line endings when performing PGP cryptography.
[pgp-mime.git] / pgp_mime / pgp.py
1 # Copyright
2
3 import copy as _copy
4 from email import message_from_bytes as _message_from_bytes
5 from email.encoders import encode_7or8bit as _encode_7or8bit
6 from email.mime.application import MIMEApplication as _MIMEApplication
7 from email.mime.multipart import MIMEMultipart as _MIMEMultipart
8
9 from . import LOG as _LOG
10 from .crypt import sign_and_encrypt_bytes as _sign_and_encrypt_bytes
11 from .crypt import verify_bytes as _verify_bytes
12 from .email import email_targets as _email_targets
13 from .email import strip_bcc as _strip_bcc
14
15
16 def sign(message, signers=None, allow_default_signer=False):
17     r"""Sign a ``Message``, returning the signed version.
18
19     multipart/signed
20     +-> text/plain                 (body)
21     +-> application/pgp-signature  (signature)
22
23     >>> from pgp_mime.email import encodedMIMEText
24     >>> message = encodedMIMEText('Hi\nBye')
25     >>> signed = sign(message, signers=['pgp-mime@invalid.com'])
26     >>> signed.set_boundary('boundsep')
27     >>> print(signed.as_string())  # doctest: +ELLIPSIS, +REPORT_UDIFF
28     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="boundsep"
29     MIME-Version: 1.0
30     Content-Disposition: inline
31     <BLANKLINE>
32     --boundsep
33     Content-Type: text/plain; charset="us-ascii"
34     MIME-Version: 1.0
35     Content-Transfer-Encoding: 7bit
36     Content-Disposition: inline
37     <BLANKLINE>
38     Hi
39     Bye
40     --boundsep
41     MIME-Version: 1.0
42     Content-Transfer-Encoding: 7bit
43     Content-Description: OpenPGP digital signature
44     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
45     <BLANKLINE>
46     -----BEGIN PGP SIGNATURE-----
47     Version: GnuPG...
48     -----END PGP SIGNATURE-----
49     <BLANKLINE>
50     --boundsep--
51
52     >>> from email.mime.multipart import MIMEMultipart
53     >>> message = MIMEMultipart()
54     >>> message.attach(encodedMIMEText('Part A'))
55     >>> message.attach(encodedMIMEText('Part B'))
56     >>> signed = sign(message, signers=['pgp-mime@invalid.com'])
57     >>> signed.set_boundary('boundsep')
58     >>> print(signed.as_string())  # doctest: +ELLIPSIS, +REPORT_UDIFF
59     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="boundsep"
60     MIME-Version: 1.0
61     Content-Disposition: inline
62     <BLANKLINE>
63     --boundsep
64     Content-Type: multipart/mixed; boundary="===============...=="
65     MIME-Version: 1.0
66     <BLANKLINE>
67     --===============...==
68     Content-Type: text/plain; charset="us-ascii"
69     MIME-Version: 1.0
70     Content-Transfer-Encoding: 7bit
71     Content-Disposition: inline
72     <BLANKLINE>
73     Part A
74     --===============...==
75     Content-Type: text/plain; charset="us-ascii"
76     MIME-Version: 1.0
77     Content-Transfer-Encoding: 7bit
78     Content-Disposition: inline
79     <BLANKLINE>
80     Part B
81     --===============...==--
82     --boundsep
83     MIME-Version: 1.0
84     Content-Transfer-Encoding: 7bit
85     Content-Description: OpenPGP digital signature
86     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
87     <BLANKLINE>
88     -----BEGIN PGP SIGNATURE-----
89     Version: GnuPG...
90     -----END PGP SIGNATURE-----
91     <BLANKLINE>
92     --boundsep--
93     """
94     body = message.as_string().encode('us-ascii').replace(b'\n', b'\r\n')
95     # use email.policy.SMTP once we get Python 3.3
96     signature = str(_sign_and_encrypt_bytes(
97             data=body, signers=signers,
98             allow_default_signer=allow_default_signer), 'us-ascii')
99     sig = _MIMEApplication(
100         _data=signature,
101         _subtype='pgp-signature; name="signature.asc"',
102         _encoder=_encode_7or8bit)
103     sig['Content-Description'] = 'OpenPGP digital signature'
104     sig.set_charset('us-ascii')
105
106     msg = _MIMEMultipart(
107         'signed', micalg='pgp-sha1', protocol='application/pgp-signature')
108     msg.attach(message)
109     msg.attach(sig)
110     msg['Content-Disposition'] = 'inline'
111     return msg
112
113 def encrypt(message, recipients=None, always_trust=True):
114     r"""Encrypt a ``Message``, returning the encrypted version.
115
116     multipart/encrypted
117     +-> application/pgp-encrypted  (control information)
118     +-> application/octet-stream   (body)
119
120     >>> from pgp_mime.email import encodedMIMEText
121     >>> message = encodedMIMEText('Hi\nBye')
122     >>> message['To'] = 'pgp-mime-test <pgp-mime@invalid.com>'
123     >>> encrypted = encrypt(message)
124     >>> encrypted.set_boundary('boundsep')
125     >>> print(encrypted.as_string())  # doctest: +ELLIPSIS, +REPORT_UDIFF
126     Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="boundsep"
127     MIME-Version: 1.0
128     Content-Disposition: inline
129     <BLANKLINE>
130     --boundsep
131     MIME-Version: 1.0
132     Content-Transfer-Encoding: 7bit
133     Content-Type: application/pgp-encrypted; charset="us-ascii"
134     <BLANKLINE>
135     Version: 1
136     <BLANKLINE>
137     --boundsep
138     MIME-Version: 1.0
139     Content-Transfer-Encoding: 7bit
140     Content-Description: OpenPGP encrypted message
141     Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
142     <BLANKLINE>
143     -----BEGIN PGP MESSAGE-----
144     Version: GnuPG...
145     -----END PGP MESSAGE-----
146     <BLANKLINE>
147     --boundsep--
148
149     >>> from email.mime.multipart import MIMEMultipart
150     >>> message = MIMEMultipart()
151     >>> message.attach(encodedMIMEText('Part A'))
152     >>> message.attach(encodedMIMEText('Part B'))
153     >>> encrypted = encrypt(
154     ...     message, recipients=['pgp-mime@invalid.com'], always_trust=True)
155     >>> encrypted.set_boundary('boundsep')
156     >>> print(encrypted.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
157     Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="boundsep"
158     MIME-Version: 1.0
159     Content-Disposition: inline
160     <BLANKLINE>
161     --boundsep
162     MIME-Version: 1.0
163     Content-Transfer-Encoding: 7bit
164     Content-Type: application/pgp-encrypted; charset="us-ascii"
165     <BLANKLINE>
166     Version: 1
167     <BLANKLINE>
168     --boundsep
169     MIME-Version: 1.0
170     Content-Transfer-Encoding: 7bit
171     Content-Description: OpenPGP encrypted message
172     Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
173     <BLANKLINE>
174     -----BEGIN PGP MESSAGE-----
175     Version: GnuPG...
176     -----END PGP MESSAGE-----
177     <BLANKLINE>
178     --boundsep--
179     """
180     body = message.as_string().encode('us-ascii').replace(b'\n', b'\r\n')
181     # use email.policy.SMTP once we get Python 3.3
182     if recipients is None:
183         recipients = [email for name,email in _email_targets(message)]
184         _LOG.debug('extracted encryption recipients: {}'.format(recipients))
185     encrypted = str(_sign_and_encrypt_bytes(
186             data=body, recipients=recipients,
187             always_trust=always_trust), 'us-ascii')
188     enc = _MIMEApplication(
189         _data=encrypted,
190         _subtype='octet-stream; name="encrypted.asc"',
191         _encoder=_encode_7or8bit)
192     enc['Content-Description'] = 'OpenPGP encrypted message'
193     enc.set_charset('us-ascii')
194     control = _MIMEApplication(
195         _data='Version: 1\n',
196         _subtype='pgp-encrypted',
197         _encoder=_encode_7or8bit)
198     control.set_charset('us-ascii')
199     msg = _MIMEMultipart(
200         'encrypted',
201         micalg='pgp-sha1',
202         protocol='application/pgp-encrypted')
203     msg.attach(control)
204     msg.attach(enc)
205     msg['Content-Disposition'] = 'inline'
206     return msg
207
208 def sign_and_encrypt(message, signers=None, recipients=None,
209                      always_trust=False, allow_default_signer=False):
210     r"""Sign and encrypt a ``Message``, returning the encrypted version.
211
212     multipart/encrypted
213      +-> application/pgp-encrypted  (control information)
214      +-> application/octet-stream   (body)
215
216     >>> from pgp_mime.email import encodedMIMEText
217     >>> message = encodedMIMEText('Hi\nBye')
218     >>> message['To'] = 'pgp-mime-test <pgp-mime@invalid.com>'
219     >>> encrypted = sign_and_encrypt(
220     ...     message, signers=['pgp-mime@invalid.com'], always_trust=True)
221     >>> encrypted.set_boundary('boundsep')
222     >>> print(encrypted.as_string())  # doctest: +ELLIPSIS, +REPORT_UDIFF
223     Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="boundsep"
224     MIME-Version: 1.0
225     Content-Disposition: inline
226     <BLANKLINE>
227     --boundsep
228     MIME-Version: 1.0
229     Content-Transfer-Encoding: 7bit
230     Content-Type: application/pgp-encrypted; charset="us-ascii"
231     <BLANKLINE>
232     Version: 1
233     <BLANKLINE>
234     --boundsep
235     MIME-Version: 1.0
236     Content-Transfer-Encoding: 7bit
237     Content-Description: OpenPGP encrypted message
238     Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
239     <BLANKLINE>
240     -----BEGIN PGP MESSAGE-----
241     Version: GnuPG...
242     -----END PGP MESSAGE-----
243     <BLANKLINE>
244     --boundsep--
245
246     >>> from email.mime.multipart import MIMEMultipart
247     >>> message = MIMEMultipart()
248     >>> message.attach(encodedMIMEText('Part A'))
249     >>> message.attach(encodedMIMEText('Part B'))
250     >>> encrypted = sign_and_encrypt(
251     ...     message, signers=['pgp-mime@invalid.com'],
252     ...     recipients=['pgp-mime@invalid.com'], always_trust=True)
253     >>> encrypted.set_boundary('boundsep')
254     >>> print(encrypted.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
255     Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="boundsep"
256     MIME-Version: 1.0
257     Content-Disposition: inline
258     <BLANKLINE>
259     --boundsep
260     MIME-Version: 1.0
261     Content-Transfer-Encoding: 7bit
262     Content-Type: application/pgp-encrypted; charset="us-ascii"
263     <BLANKLINE>
264     Version: 1
265     <BLANKLINE>
266     --boundsep
267     MIME-Version: 1.0
268     Content-Transfer-Encoding: 7bit
269     Content-Description: OpenPGP encrypted message
270     Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
271     <BLANKLINE>
272     -----BEGIN PGP MESSAGE-----
273     Version: GnuPG...
274     -----END PGP MESSAGE-----
275     <BLANKLINE>
276     --boundsep--
277     """
278     _strip_bcc(message=message)
279     body = message.as_string().encode('us-ascii').replace(b'\n', b'\r\n')
280     # use email.policy.SMTP once we get Python 3.3
281     if recipients is None:
282         recipients = [email for name,email in _email_targets(message)]
283         _LOG.debug('extracted encryption recipients: {}'.format(recipients))
284     encrypted = str(_sign_and_encrypt_bytes(
285             data=body, signers=signers, recipients=recipients,
286             always_trust=always_trust,
287             allow_default_signer=allow_default_signer), 'us-ascii')
288     enc = _MIMEApplication(
289         _data=encrypted,
290         _subtype='octet-stream; name="encrypted.asc"',
291         _encoder=_encode_7or8bit)
292     enc['Content-Description'] = 'OpenPGP encrypted message'
293     enc.set_charset('us-ascii')
294     control = _MIMEApplication(
295         _data='Version: 1\n',
296         _subtype='pgp-encrypted',
297         _encoder=_encode_7or8bit)
298     control.set_charset('us-ascii')
299     msg = _MIMEMultipart(
300         'encrypted',
301         micalg='pgp-sha1',
302         protocol='application/pgp-encrypted')
303     msg.attach(control)
304     msg.attach(enc)
305     msg['Content-Disposition'] = 'inline'
306     return msg
307
308 def _get_encrypted_parts(message):
309     ct = message.get_content_type()
310     assert ct == 'multipart/encrypted', ct
311     params = dict(message.get_params())
312     assert params.get('protocol', None) == 'application/pgp-encrypted', params
313     assert message.is_multipart(), message
314     control = body = None
315     for part in message.get_payload():
316         if part == message:
317             continue
318         assert part.is_multipart() == False, part
319         ct = part.get_content_type()
320         if ct == 'application/pgp-encrypted':
321             if control:
322                 raise ValueError('multiple application/pgp-encrypted parts')
323             control = part
324         elif ct == 'application/octet-stream':
325             if body:
326                 raise ValueError('multiple application/octet-stream parts')
327             body = part
328         else:
329             raise ValueError('unnecessary {} part'.format(ct))
330     if not control:
331         raise ValueError('missing application/pgp-encrypted part')
332     if not body:
333         raise ValueError('missing application/octet-stream part')
334     return (control, body)
335
336 def _get_signed_parts(message):
337     ct = message.get_content_type()
338     assert ct == 'multipart/signed', ct
339     params = dict(message.get_params())
340     assert params.get('protocol', None) == 'application/pgp-signature', params
341     assert message.is_multipart(), message
342     body = signature = None
343     for part in message.get_payload():
344         if part == message:
345             continue
346         ct = part.get_content_type()
347         if ct == 'application/pgp-signature':
348             if signature:
349                 raise ValueError('multiple application/pgp-signature parts')
350             signature = part
351         else:
352             if body:
353                 raise ValueError('multiple non-signature parts')
354             body = part
355     if not body:
356         raise ValueError('missing body part')
357     if not signature:
358         raise ValueError('missing application/pgp-signature part')
359     return (body, signature)
360
361 def decrypt(message):
362     r"""Decrypt a multipart/encrypted message.
363
364     >>> from pgp_mime.email import encodedMIMEText
365     >>> message = encodedMIMEText('Hi\nBye')
366     >>> encrypted = encrypt(message, recipients=['<pgp-mime@invalid.com>'])
367     >>> decrypted = decrypt(encrypted)
368     >>> print(decrypted.as_string().replace('\r\n', '\n'))
369     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
370     Content-Type: text/plain; charset="us-ascii"
371     MIME-Version: 1.0
372     Content-Transfer-Encoding: 7bit
373     Content-Disposition: inline
374     <BLANKLINE>
375     Hi
376     Bye
377
378     >>> from email.mime.multipart import MIMEMultipart
379     >>> message = MIMEMultipart()
380     >>> message.attach(encodedMIMEText('Part A'))
381     >>> message.attach(encodedMIMEText('Part B'))
382     >>> encrypted = encrypt(
383     ...     message, recipients=['pgp-mime@invalid.com'], always_trust=True)
384     >>> decrypted = decrypt(encrypted)
385     >>> decrypted.set_boundary('boundsep')
386     >>> print(decrypted.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
387     Content-Type: multipart/mixed; boundary="boundsep"
388     MIME-Version: 1.0
389     <BLANKLINE>
390     --boundsep
391     Content-Type: text/plain; charset="us-ascii"
392     MIME-Version: 1.0
393     Content-Transfer-Encoding: 7bit
394     Content-Disposition: inline
395     <BLANKLINE>
396     Part A
397     --boundsep
398     Content-Type: text/plain; charset="us-ascii"
399     MIME-Version: 1.0
400     Content-Transfer-Encoding: 7bit
401     Content-Disposition: inline
402     <BLANKLINE>
403     Part B
404     --boundsep--
405     <BLANKLINE>
406     """
407     control,body = _get_encrypted_parts(message)
408     encrypted = body.get_payload(decode=True)
409     if not isinstance(encrypted, bytes):
410         encrypted = encrypted.encode('us-ascii')
411     decrypted,verified,result = _verify_bytes(encrypted)
412     return _message_from_bytes(decrypted)
413
414 def verify(message):
415     r"""Verify a signature on ``message``, possibly decrypting first.
416
417     >>> from pgp_mime.email import encodedMIMEText
418     >>> message = encodedMIMEText('Hi\nBye')
419     >>> message['To'] = 'pgp-mime-test <pgp-mime@invalid.com>'
420     >>> encrypted = sign_and_encrypt(message, signers=['pgp-mime@invalid.com'],
421     ...     always_trust=True)
422     >>> decrypted,verified,result = verify(encrypted)
423     >>> print(decrypted.as_string().replace('\r\n', '\n'))
424     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
425     Content-Type: text/plain; charset="us-ascii"
426     MIME-Version: 1.0
427     Content-Transfer-Encoding: 7bit
428     Content-Disposition: inline
429     To: pgp-mime-test <pgp-mime@invalid.com>
430     <BLANKLINE>
431     Hi
432     Bye
433     >>> verified
434     False
435     >>> print(str(result, 'utf-8').replace('\x00', ''))
436     ... # doctest: +REPORT_UDIFF, +ELLIPSIS
437     <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
438     <gpgme>
439       <verify-result>
440         <signatures>
441           <signature>
442             <summary value="0x0" />
443             <fpr>B2EDBE0E771A4B8708DD16A7511AEDA64332B6E3</fpr>
444             <status value="0x0">Success &lt;Unspecified source&gt;</status>
445             <timestamp unix="..." />
446             <exp-timestamp unix="0i" />
447             <wrong-key-usage value="0x0" />
448             <pka-trust value="0x0" />
449             <chain-model value="0x0" />
450             <validity value="0x0" />
451             <validity-reason value="0x0">Success &lt;Unspecified source&gt;</validity-reason>
452             <pubkey-algo value="0x1">RSA</pubkey-algo>
453             <hash-algo value="0x8">SHA256</hash-algo>
454           </signature>
455         </signatures>
456       </verify-result>
457     </gpgme>
458     <BLANKLINE>
459
460     >>> from email.mime.multipart import MIMEMultipart
461     >>> message = MIMEMultipart()
462     >>> message.attach(encodedMIMEText('Part A'))
463     >>> message.attach(encodedMIMEText('Part B'))
464     >>> signed = sign(message, signers=['pgp-mime@invalid.com'])
465     >>> decrypted,verified,result = verify(signed)
466     >>> decrypted.set_boundary('boundsep')
467     >>> print(decrypted.as_string())  # doctest: +ELLIPSIS, +REPORT_UDIFF
468     Content-Type: multipart/mixed; boundary="boundsep"
469     MIME-Version: 1.0
470     <BLANKLINE>
471     --boundsep
472     Content-Type: text/plain; charset="us-ascii"
473     MIME-Version: 1.0
474     Content-Transfer-Encoding: 7bit
475     Content-Disposition: inline
476     <BLANKLINE>
477     Part A
478     --boundsep
479     Content-Type: text/plain; charset="us-ascii"
480     MIME-Version: 1.0
481     Content-Transfer-Encoding: 7bit
482     Content-Disposition: inline
483     <BLANKLINE>
484     Part B
485     --boundsep--
486     >>> verified
487     False
488     >>> print(str(result, 'utf-8').replace('\x00', ''))
489     ... # doctest: +REPORT_UDIFF, +ELLIPSIS
490     <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
491     <gpgme>
492       <verify-result>
493         <signatures>
494           <signature>
495             <summary value="0x0" />
496             <fpr>B2EDBE0E771A4B8708DD16A7511AEDA64332B6E3</fpr>
497             <status value="0x0">Success &lt;Unspecified source&gt;</status>
498             <timestamp unix="..." />
499             <exp-timestamp unix="0i" />
500             <wrong-key-usage value="0x0" />
501             <pka-trust value="0x0" />
502             <chain-model value="0x0" />
503             <validity value="0x0" />
504             <validity-reason value="0x0">Success &lt;Unspecified source&gt;</validity-reason>
505             <pubkey-algo value="0x1">RSA</pubkey-algo>
506             <hash-algo value="0x2">SHA1</hash-algo>
507           </signature>
508         </signatures>
509       </verify-result>
510     </gpgme>
511     <BLANKLINE>
512     """
513     ct = message.get_content_type()
514     if ct == 'multipart/encrypted':
515         control,body = _get_encrypted_parts(message)
516         encrypted = body.get_payload(decode=True)
517         if not isinstance(encrypted, bytes):
518             encrypted = encrypted.encode('us-ascii')
519         decrypted,verified,message = _verify_bytes(encrypted)
520         return (_message_from_bytes(decrypted), verified, message)
521     body,signature = _get_signed_parts(message)
522     sig_data = signature.get_payload(decode=True)
523     if not isinstance(sig_data, bytes):
524         sig_data = sig_data.encode('us-ascii')
525     decrypted,verified,result = _verify_bytes(
526         body.as_string().encode('us-ascii').replace(b'\n', b'\r\n'),
527         signature=sig_data)
528     # use email.policy.SMTP once we get Python 3.3
529     return (_copy.deepcopy(body), verified, result)