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