Add a test key (and creation scripts) so others can test decryption and verification.
[pgp-mime.git] / pgp_mime.py
1 # -*- coding: utf-8 -*-
2 # Copyright (C) 2012 W. Trevor King <wking@drexel.edu>
3 #
4 # This file is part of pgp-mime.
5 #
6 # pgp-mime is free software: you can redistribute it and/or modify it under the
7 # terms of the GNU General Public License as published by the Free Software
8 # Foundation, either version 3 of the License, or (at your option) any later
9 # version.
10 #
11 # pgp-mime is distributed in the hope that it will be useful, but WITHOUT ANY
12 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
13 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License along with
16 # pgp-mime.  If not, see <http://www.gnu.org/licenses/>.
17 """Python module and for constructing and sending pgp/mime email.
18
19 Mostly uses subprocess to call ``gpg`` and sends mail using either
20 SMTP or a sendmail-compatible mailer.  If you lack ``gpg``, either
21 don't use the encryption functions, adjust the ``GPG_*`` constants, or
22 adjust the ``*_bytes`` commands.
23 """
24
25 import configparser as _configparser
26 import io as _io
27 import logging as _logging
28 import os as _os
29 import re as _re
30 import smtplib as _smtplib
31 import smtplib as _smtplib
32 import subprocess as _subprocess
33
34 from email.encoders import encode_7or8bit as _encode_7or8bit
35 from email.generator import Generator as _Generator
36 from email.header import decode_header as _decode_header
37 from email.message import Message as _Message
38 from email.mime.application import MIMEApplication as _MIMEApplication
39 from email.mime.multipart import MIMEMultipart as _MIMEMultipart
40 from email.mime.text import MIMEText as _MIMEText
41 from email.parser import Parser as _Parser
42 from email.utils import formataddr as _formataddr
43 from email.utils import getaddresses as _getaddresses
44
45
46 __version__ = '0.2'
47
48
49 LOG = _logging.getLogger('pgp-mime')
50 LOG.setLevel(_logging.ERROR)
51 LOG.addHandler(_logging.StreamHandler())
52
53 ENCODING = 'utf-8'
54 #ENCODING = 'iso-8859-1'
55
56 GPG_ARGS = [
57     '/usr/bin/gpg', '--no-verbose', '--quiet', '--batch', '--output', '-',
58     '--armor', '--textmode']
59 GPG_SIGN_ARGS = ['--detach-sign']
60 GPG_ENCRYPT_ARGS = ['--encrypt', '--always-trust']
61 GPG_SIGN_AND_ENCRYPT_ARGS = ['--sign', '--encrypt', '--always-trust']
62 SENDMAIL = ['/usr/sbin/sendmail', '-t']
63
64
65 def get_smtp_params(config):
66     r"""Retrieve SMTP paramters from a config file.
67
68     >>> from configparser import ConfigParser
69     >>> config = ConfigParser()
70     >>> config.read_string('\n'.join([
71     ...             '[smtp]',
72     ...             'host: smtp.mail.uu.edu',
73     ...             'port: 587',
74     ...             'starttls: yes',
75     ...             'username: rincewind',
76     ...             'password: 7ugg@g3',
77     ...             ]))
78     >>> get_smtp_params(config)
79     ('smtp.mail.uu.edu', 587, True, 'rincewind', '7ugg@g3')
80     >>> config = ConfigParser()
81     >>> get_smtp_params(ConfigParser())
82     (None, None, None, None, None)
83     """
84     try:
85         host = config.get('smtp', 'host')
86     except _configparser.NoSectionError:
87         return (None, None, None, None, None)
88     except _configparser.NoOptionError:
89         host = None
90     try:
91         port = config.getint('smtp', 'port')
92     except _configparser.NoOptionError:
93         port = None
94     try:
95         starttls = config.getboolean('smtp', 'starttls')
96     except _configparser.NoOptionError:
97         starttls = None
98     try:
99         username = config.get('smtp', 'username')
100     except _configparser.NoOptionError:
101         username = None
102     try:
103         password = config.get('smtp', 'password')
104     except _configparser.NoOptionError:
105         password = None
106     return (host, port, starttls, username, password)
107
108 def get_smtp(host=None, port=None, starttls=None, username=None,
109              password=None):
110     """Connect to an SMTP host using the given parameters.
111
112     >>> import smtplib
113     >>> try:  # doctest: +SKIP
114     ...     smtp = get_smtp(host='smtp.gmail.com', port=587, starttls=True,
115     ...         username='rincewind@uu.edu', password='7ugg@g3')
116     ... except smtplib.SMTPAuthenticationError as error:
117     ...     print('that was not a real account')
118     that was not a real account
119     >>> smtp = get_smtp()  # doctest: +SKIP
120     >>> smtp.quit()  # doctest: +SKIP
121     """
122     if host is None:
123         host = 'localhost'
124     if port is None:
125         port = _smtplib.SMTP_PORT
126     if username and not starttls:
127         raise ValueError(
128             'sending passwords in the clear is unsafe!  Use STARTTLS.')
129     LOG.info('connect to SMTP server at {}:{}'.format(host, port))
130     smtp = _smtplib.SMTP(host=host, port=port)
131     smtp.ehlo()
132     if starttls:
133         smtp.starttls()
134     if username:
135         smtp.login(username, password)
136     #smtp.set_debuglevel(1)
137     return smtp
138
139 def mail(message, smtp=None, sendmail=None):
140     """Send an email ``Message`` instance on its merry way.
141
142     We can shell out to the user specified sendmail in case
143     the local host doesn't have an SMTP server set up
144     for easy ``smtplib`` usage.
145
146     >>> message = encodedMIMEText('howdy!')
147     >>> message['From'] = 'John Doe <jdoe@a.gov.ru>'
148     >>> message['To'] = 'Jack <jack@hill.org>, Jill <jill@hill.org>'
149     >>> mail(message=message, sendmail=SENDMAIL)
150     """
151     LOG.info('send message {} -> {}'.format(message['from'], message['to']))
152     if smtp:
153         smtp.send_message(msg=message)
154     elif sendmail:
155         execute(sendmail, stdin=message.as_string().encode('us-ascii'))
156     else:
157         smtp = _smtplib.SMTP()
158         smtp.connect()
159         smtp.send_message(msg=message)
160         smtp.close()
161
162 def header_from_text(text):
163     r"""Simple wrapper for instantiating a ``Message`` from text.
164
165     >>> text = '\n'.join(
166     ...     ['From: me@big.edu','To: you@big.edu','Subject: testing'])
167     >>> header = header_from_text(text=text)
168     >>> print(header.as_string())  # doctest: +REPORT_UDIFF
169     From: me@big.edu
170     To: you@big.edu
171     Subject: testing
172     <BLANKLINE>
173     <BLANKLINE>
174     """
175     text = text.strip()
176     p = _Parser()
177     return p.parsestr(text, headersonly=True)
178
179 def guess_encoding(text):
180     r"""
181     >>> guess_encoding('hi there')
182     'us-ascii'
183     >>> guess_encoding('✉')
184     'utf-8'
185     """
186     for encoding in ['us-ascii', ENCODING, 'utf-8']:
187         try:
188             text.encode(encoding)
189         except UnicodeEncodeError:
190             pass
191         else:
192             return encoding
193     raise ValueError(text)
194
195 def encodedMIMEText(body, encoding=None):
196     """Wrap ``MIMEText`` with ``guess_encoding`` detection.
197
198     >>> message = encodedMIMEText('Hello')
199     >>> print(message.as_string())  # doctest: +REPORT_UDIFF
200     Content-Type: text/plain; charset="us-ascii"
201     MIME-Version: 1.0
202     Content-Transfer-Encoding: 7bit
203     Content-Disposition: inline
204     <BLANKLINE>
205     Hello
206     >>> message = encodedMIMEText('Джон Доу')
207     >>> print(message.as_string())  # doctest: +REPORT_UDIFF
208     Content-Type: text/plain; charset="utf-8"
209     MIME-Version: 1.0
210     Content-Transfer-Encoding: base64
211     Content-Disposition: inline
212     <BLANKLINE>
213     0JTQttC+0L0g0JTQvtGD
214     <BLANKLINE>
215     """
216     if encoding == None:
217         encoding = guess_encoding(body)
218     if encoding == 'us-ascii':
219         message = _MIMEText(body)
220     else:
221         # Create the message ('plain' stands for Content-Type: text/plain)
222         message = _MIMEText(body, 'plain', encoding)
223     message.add_header('Content-Disposition', 'inline')
224     return message
225
226 def strip_bcc(message):
227     """Remove the Bcc field from a ``Message`` in preparation for mailing
228
229     >>> message = encodedMIMEText('howdy!')
230     >>> message['To'] = 'John Doe <jdoe@a.gov.ru>'
231     >>> message['Bcc'] = 'Jack <jack@hill.org>, Jill <jill@hill.org>'
232     >>> message = strip_bcc(message)
233     >>> print(message.as_string())  # doctest: +REPORT_UDIFF
234     Content-Type: text/plain; charset="us-ascii"
235     MIME-Version: 1.0
236     Content-Transfer-Encoding: 7bit
237     Content-Disposition: inline
238     To: John Doe <jdoe@a.gov.ru>
239     <BLANKLINE>
240     howdy!
241     """
242     del message['bcc']
243     del message['resent-bcc']
244     return message
245
246 def append_text(text_part, new_text):
247     r"""Append text to the body of a ``plain/text`` part.
248
249     Updates encoding as necessary.
250
251     >>> message = encodedMIMEText('Hello')
252     >>> append_text(message, ' John Doe')
253     >>> print(message.as_string())  # doctest: +REPORT_UDIFF
254     Content-Type: text/plain; charset="us-ascii"
255     MIME-Version: 1.0
256     Content-Disposition: inline
257     Content-Transfer-Encoding: 7bit
258     <BLANKLINE>
259     Hello John Doe
260     >>> append_text(message, ', Джон Доу')
261     >>> print(message.as_string())  # doctest: +REPORT_UDIFF
262     MIME-Version: 1.0
263     Content-Disposition: inline
264     Content-Type: text/plain; charset="utf-8"
265     Content-Transfer-Encoding: base64
266     <BLANKLINE>
267     SGVsbG8gSm9obiBEb2UsINCU0LbQvtC9INCU0L7Rgw==
268     <BLANKLINE>
269     >>> append_text(message, ', and Jane Sixpack.')
270     >>> print(message.as_string())  # doctest: +REPORT_UDIFF
271     MIME-Version: 1.0
272     Content-Disposition: inline
273     Content-Type: text/plain; charset="utf-8"
274     Content-Transfer-Encoding: base64
275     <BLANKLINE>
276     SGVsbG8gSm9obiBEb2UsINCU0LbQvtC9INCU0L7RgywgYW5kIEphbmUgU2l4cGFjay4=
277     <BLANKLINE>
278     """
279     original_encoding = text_part.get_charset().input_charset
280     original_payload = str(
281         text_part.get_payload(decode=True), original_encoding)
282     new_payload = '{}{}'.format(original_payload, new_text)
283     new_encoding = guess_encoding(new_payload)
284     if text_part.get('content-transfer-encoding', None):
285         # clear CTE so set_payload will set it properly for the new encoding
286         del text_part['content-transfer-encoding']
287     text_part.set_payload(new_payload, new_encoding)
288
289 def attach_root(header, root_part):
290     r"""Copy headers from ``header`` onto ``root_part``.
291
292     >>> header = header_from_text('From: me@big.edu\n')
293     >>> body = encodedMIMEText('Hello')
294     >>> message = attach_root(header, body)
295     >>> print(message.as_string())  # doctest: +REPORT_UDIFF
296     Content-Type: text/plain; charset="us-ascii"
297     MIME-Version: 1.0
298     Content-Transfer-Encoding: 7bit
299     Content-Disposition: inline
300     From: me@big.edu
301     <BLANKLINE>
302     Hello
303     """
304     for k,v in header.items():
305         root_part[k] = v
306     return root_part    
307
308 def execute(args, stdin=None, expect=(0,), env=_os.environ):
309     """Execute a command (allows us to drive gpg).
310     """
311     LOG.debug('$ {}'.format(args))
312     try:
313         p = _subprocess.Popen(
314             args, stdin=_subprocess.PIPE, stdout=_subprocess.PIPE,
315             stderr=_subprocess.PIPE, shell=False, close_fds=True, env=env)
316     except OSError as e:
317         raise Exception('{}\nwhile executing {}'.format(e.args[1], args))
318     output,error = p.communicate(input=stdin)
319     status = p.wait()
320     LOG.debug('(status: {})\n{}{}'.format(status, output, error))
321     if status not in expect:
322         raise Exception('unexpected status while executing {}\n{}\n{}'.format(
323                 args, error, status))
324     return (status, output, error)
325
326 def getaddresses(addresses):
327     """A decoding version of ``email.utils.getaddresses``.
328
329     >>> text = ('To: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>, '
330     ...     'Jack <jack@hill.org>')
331     >>> header = header_from_text(text=text)
332     >>> list(getaddresses(header.get_all('to', [])))
333     [('Джон Доу', 'jdoe@a.gov.ru'), ('Jack', 'jack@hill.org')]
334     """
335     for (name,address) in _getaddresses(addresses):
336         n = []
337         for b,encoding in _decode_header(name):
338             if encoding is None:
339                 n.append(b)
340             else:
341                 n.append(str(b, encoding))
342         yield (' '.join(n), address)
343
344 def email_sources(message):
345     """Extract author address from an email ``Message``
346
347     Search the header of an email Message instance to find the
348     senders' email addresses (or sender's address).
349
350     >>> text = ('From: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>, '
351     ...     'Jack <jack@hill.org>')
352     >>> header = header_from_text(text=text)
353     >>> list(email_sources(header))
354     [('Джон Доу', 'jdoe@a.gov.ru'), ('Jack', 'jack@hill.org')]
355     """
356     froms = message.get_all('from', [])
357     return getaddresses(froms) # [(name, address), ...]
358
359 def email_targets(message):
360     """Extract recipient addresses from an email ``Message``
361
362     Search the header of an email Message instance to find a
363     list of recipient's email addresses.
364
365     >>> text = ('To: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>, '
366     ...     'Jack <jack@hill.org>')
367     >>> header = header_from_text(text=text)
368     >>> list(email_targets(header))
369     [('Джон Доу', 'jdoe@a.gov.ru'), ('Jack', 'jack@hill.org')]
370     """
371     tos = message.get_all('to', [])
372     ccs = message.get_all('cc', [])
373     bccs = message.get_all('bcc', [])
374     resent_tos = message.get_all('resent-to', [])
375     resent_ccs = message.get_all('resent-cc', [])
376     resent_bccs = message.get_all('resent-bcc', [])
377     return getaddresses(
378         tos + ccs + bccs + resent_tos + resent_ccs + resent_bccs)
379
380 def sign_bytes(bytes, sign_as=None):
381     r"""Sign ``bytes`` as ``sign_as``.
382
383     >>> print(sign_bytes(bytes(b'Hello'), 'wking@drexel.edu'))
384     ... # doctest: +ELLIPSIS
385     b'-----BEGIN PGP SIGNATURE-----\n...-----END PGP SIGNATURE-----\n'
386     """
387     args = GPG_ARGS + GPG_SIGN_ARGS
388     if sign_as:
389         args.extend(['--local-user', sign_as])
390     status,output,error = execute(args, stdin=bytes)
391     return output
392
393 def encrypt_bytes(bytes, recipients):
394     r"""Encrypt ``bytes`` to ``recipients``.
395
396     >>> encrypt_bytes(bytes(b'Hello'), ['wking@drexel.edu'])
397     ... # doctest: +ELLIPSIS
398     b'-----BEGIN PGP MESSAGE-----\n...-----END PGP MESSAGE-----\n'
399     """
400     args = GPG_ARGS + GPG_ENCRYPT_ARGS
401     if not recipients:
402         raise ValueError('no recipients specified for encryption')
403     for recipient in recipients:
404         args.extend(['--recipient', recipient])
405     status,output,error = execute(args, stdin=bytes)
406     return output
407
408 def sign_and_encrypt_bytes(bytes, sign_as=None, recipients=None):
409     r"""Sign ``bytes`` as ``sign_as`` and encrypt to ``recipients``.
410
411     >>> sign_and_encrypt_bytes(
412     ...     bytes(b'Hello'), 'wking@drexel.edu', ['wking@drexel.edu'])
413     ... # doctest: +ELLIPSIS
414     b'-----BEGIN PGP MESSAGE-----\n...-----END PGP MESSAGE-----\n'
415     """
416     args = GPG_ARGS + GPG_SIGN_AND_ENCRYPT_ARGS
417     if sign_as:
418         args.extend(['--local-user', sign_as])
419     if not recipients:
420         raise ValueError('no recipients specified for encryption')
421     for recipient in recipients:
422         args.extend(['--recipient', recipient])
423     status,output,error = execute(args, stdin=bytes)
424     return output
425
426 def sign(message, sign_as=None):
427     r"""Sign a ``Message``, returning the signed version.
428
429     multipart/signed
430     +-> text/plain                 (body)
431     +-> application/pgp-signature  (signature)
432
433     >>> message = encodedMIMEText('Hi\nBye')
434     >>> signed = sign(message, sign_as='0xFC29BDCDF15F5BE8')
435     >>> signed.set_boundary('boundsep')
436     >>> print(signed.as_string())  # doctest: +ELLIPSIS, +REPORT_UDIFF
437     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="boundsep"
438     MIME-Version: 1.0
439     Content-Disposition: inline
440     <BLANKLINE>
441     --boundsep
442     Content-Type: text/plain; charset="us-ascii"
443     MIME-Version: 1.0
444     Content-Transfer-Encoding: 7bit
445     Content-Disposition: inline
446     <BLANKLINE>
447     Hi
448     Bye
449     --boundsep
450     MIME-Version: 1.0
451     Content-Transfer-Encoding: 7bit
452     Content-Description: OpenPGP digital signature
453     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
454     <BLANKLINE>
455     -----BEGIN PGP SIGNATURE-----
456     Version: GnuPG...
457     -----END PGP SIGNATURE-----
458     <BLANKLINE>
459     --boundsep--
460
461     >>> from email.mime.multipart import MIMEMultipart
462     >>> message = MIMEMultipart()
463     >>> message.attach(encodedMIMEText('Part A'))
464     >>> message.attach(encodedMIMEText('Part B'))
465     >>> signed = sign(message, sign_as='0xFC29BDCDF15F5BE8')
466     >>> signed.set_boundary('boundsep')
467     >>> print(signed.as_string())  # doctest: +ELLIPSIS, +REPORT_UDIFF
468     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="boundsep"
469     MIME-Version: 1.0
470     Content-Disposition: inline
471     <BLANKLINE>
472     --boundsep
473     Content-Type: multipart/mixed; boundary="===============...=="
474     MIME-Version: 1.0
475     <BLANKLINE>
476     --===============...==
477     Content-Type: text/plain; charset="us-ascii"
478     MIME-Version: 1.0
479     Content-Transfer-Encoding: 7bit
480     Content-Disposition: inline
481     <BLANKLINE>
482     Part A
483     --===============...==
484     Content-Type: text/plain; charset="us-ascii"
485     MIME-Version: 1.0
486     Content-Transfer-Encoding: 7bit
487     Content-Disposition: inline
488     <BLANKLINE>
489     Part B
490     --===============...==--
491     --boundsep
492     MIME-Version: 1.0
493     Content-Transfer-Encoding: 7bit
494     Content-Description: OpenPGP digital signature
495     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
496     <BLANKLINE>
497     -----BEGIN PGP SIGNATURE-----
498     Version: GnuPG...
499     -----END PGP SIGNATURE-----
500     <BLANKLINE>
501     --boundsep--
502     """
503     body = message.as_string().encode('us-ascii')
504     signature = str(sign_bytes(body, sign_as), 'us-ascii')
505     sig = _MIMEApplication(
506         _data=signature,
507         _subtype='pgp-signature; name="signature.asc"',
508         _encoder=_encode_7or8bit)
509     sig['Content-Description'] = 'OpenPGP digital signature'
510     sig.set_charset('us-ascii')
511
512     msg = _MIMEMultipart(
513         'signed', micalg='pgp-sha1', protocol='application/pgp-signature')
514     msg.attach(message)
515     msg.attach(sig)
516     msg['Content-Disposition'] = 'inline'
517     return msg
518
519 def encrypt(message, recipients=None):
520     r"""Encrypt a ``Message``, returning the encrypted version.
521
522     multipart/encrypted
523     +-> application/pgp-encrypted  (control information)
524     +-> application/octet-stream   (body)
525
526     >>> message = encodedMIMEText('Hi\nBye')
527     >>> message['To'] = '"W. Trevor King" <wking@drexel.edu>'
528     >>> encrypted = encrypt(message)
529     >>> encrypted.set_boundary('boundsep')
530     >>> print(encrypted.as_string())  # doctest: +ELLIPSIS, +REPORT_UDIFF
531     Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="boundsep"
532     MIME-Version: 1.0
533     Content-Disposition: inline
534     <BLANKLINE>
535     --boundsep
536     MIME-Version: 1.0
537     Content-Transfer-Encoding: 7bit
538     Content-Type: application/pgp-encrypted; charset="us-ascii"
539     <BLANKLINE>
540     Version: 1
541     <BLANKLINE>
542     --boundsep
543     MIME-Version: 1.0
544     Content-Transfer-Encoding: 7bit
545     Content-Description: OpenPGP encrypted message
546     Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
547     <BLANKLINE>
548     -----BEGIN PGP MESSAGE-----
549     Version: GnuPG...
550     -----END PGP MESSAGE-----
551     <BLANKLINE>
552     --boundsep--
553
554     >>> from email.mime.multipart import MIMEMultipart
555     >>> message = MIMEMultipart()
556     >>> message.attach(encodedMIMEText('Part A'))
557     >>> message.attach(encodedMIMEText('Part B'))
558     >>> encrypted = encrypt(message, recipients=['F15F5BE8'])
559     >>> encrypted.set_boundary('boundsep')
560     >>> print(encrypted.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
561     Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="boundsep"
562     MIME-Version: 1.0
563     Content-Disposition: inline
564     <BLANKLINE>
565     --boundsep
566     MIME-Version: 1.0
567     Content-Transfer-Encoding: 7bit
568     Content-Type: application/pgp-encrypted; charset="us-ascii"
569     <BLANKLINE>
570     Version: 1
571     <BLANKLINE>
572     --boundsep
573     MIME-Version: 1.0
574     Content-Transfer-Encoding: 7bit
575     Content-Description: OpenPGP encrypted message
576     Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
577     <BLANKLINE>
578     -----BEGIN PGP MESSAGE-----
579     Version: GnuPG...
580     -----END PGP MESSAGE-----
581     <BLANKLINE>
582     --boundsep--
583     """
584     body = message.as_string().encode('us-ascii')
585     if recipients is None:
586         recipients = [email for name,email in email_targets(message)]
587         LOG.debug('extracted encryption recipients: {}'.format(recipients))
588     encrypted = str(encrypt_bytes(body, recipients), 'us-ascii')
589     enc = _MIMEApplication(
590         _data=encrypted,
591         _subtype='octet-stream; name="encrypted.asc"',
592         _encoder=_encode_7or8bit)
593     enc['Content-Description'] = 'OpenPGP encrypted message'
594     enc.set_charset('us-ascii')
595     control = _MIMEApplication(
596         _data='Version: 1\n',
597         _subtype='pgp-encrypted',
598         _encoder=_encode_7or8bit)
599     control.set_charset('us-ascii')
600     msg = _MIMEMultipart(
601         'encrypted',
602         micalg='pgp-sha1',
603         protocol='application/pgp-encrypted')
604     msg.attach(control)
605     msg.attach(enc)
606     msg['Content-Disposition'] = 'inline'
607     return msg
608
609 def sign_and_encrypt(message, sign_as=None, recipients=None):
610     r"""Sign and encrypt a ``Message``, returning the encrypted version.
611
612     multipart/encrypted
613      +-> application/pgp-encrypted  (control information)
614      +-> application/octet-stream   (body)
615
616     >>> message = encodedMIMEText('Hi\nBye')
617     >>> message['To'] = '"W. Trevor King" <wking@drexel.edu>'
618     >>> encrypted = sign_and_encrypt(message)
619     >>> encrypted.set_boundary('boundsep')
620     >>> print(encrypted.as_string())  # doctest: +ELLIPSIS, +REPORT_UDIFF
621     Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="boundsep"
622     MIME-Version: 1.0
623     Content-Disposition: inline
624     <BLANKLINE>
625     --boundsep
626     MIME-Version: 1.0
627     Content-Transfer-Encoding: 7bit
628     Content-Type: application/pgp-encrypted; charset="us-ascii"
629     <BLANKLINE>
630     Version: 1
631     <BLANKLINE>
632     --boundsep
633     MIME-Version: 1.0
634     Content-Transfer-Encoding: 7bit
635     Content-Description: OpenPGP encrypted message
636     Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
637     <BLANKLINE>
638     -----BEGIN PGP MESSAGE-----
639     Version: GnuPG...
640     -----END PGP MESSAGE-----
641     <BLANKLINE>
642     --boundsep--
643
644     >>> from email.mime.multipart import MIMEMultipart
645     >>> message = MIMEMultipart()
646     >>> message.attach(encodedMIMEText('Part A'))
647     >>> message.attach(encodedMIMEText('Part B'))
648     >>> encrypted = sign_and_encrypt(message, recipients=['F15F5BE8'])
649     >>> encrypted.set_boundary('boundsep')
650     >>> print(encrypted.as_string()) # doctest: +ELLIPSIS, +REPORT_UDIFF
651     Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="boundsep"
652     MIME-Version: 1.0
653     Content-Disposition: inline
654     <BLANKLINE>
655     --boundsep
656     MIME-Version: 1.0
657     Content-Transfer-Encoding: 7bit
658     Content-Type: application/pgp-encrypted; charset="us-ascii"
659     <BLANKLINE>
660     Version: 1
661     <BLANKLINE>
662     --boundsep
663     MIME-Version: 1.0
664     Content-Transfer-Encoding: 7bit
665     Content-Description: OpenPGP encrypted message
666     Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
667     <BLANKLINE>
668     -----BEGIN PGP MESSAGE-----
669     Version: GnuPG...
670     -----END PGP MESSAGE-----
671     <BLANKLINE>
672     --boundsep--
673     """
674     strip_bcc(message=message)
675     body = message.as_string().encode('us-ascii')
676     if recipients is None:
677         recipients = [email for name,email in email_targets(message)]
678         LOG.debug('extracted encryption recipients: {}'.format(recipients))
679     encrypted = str(sign_and_encrypt_bytes(
680             body, sign_as=sign_as, recipients=recipients), 'us-ascii')
681     enc = _MIMEApplication(
682         _data=encrypted,
683         _subtype='octet-stream; name="encrypted.asc"',
684         _encoder=_encode_7or8bit)
685     enc['Content-Description'] = 'OpenPGP encrypted message'
686     enc.set_charset('us-ascii')
687     control = _MIMEApplication(
688         _data='Version: 1\n',
689         _subtype='pgp-encrypted',
690         _encoder=_encode_7or8bit)
691     control.set_charset('us-ascii')
692     msg = _MIMEMultipart(
693         'encrypted',
694         micalg='pgp-sha1',
695         protocol='application/pgp-encrypted')
696     msg.attach(control)
697     msg.attach(enc)
698     msg['Content-Disposition'] = 'inline'
699     return msg