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