3 # Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License along
16 # with this program; if not, write to the Free Software Foundation, Inc.,
17 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
19 Python module and command line tool for sending pgp/mime email.
21 Mostly uses subprocess to call gpg and a sendmail-compatible mailer.
22 If you lack gpg, either don't use the encryption functions or adjust
23 the pgp_* commands. You may need to adjust the sendmail command to
24 point to whichever sendmail-compatible mailer you have on your system.
27 from cStringIO import StringIO
30 #import GnuPGInterface # Maybe should use this instead of subprocess
38 from email import Message
39 from email.mime.text import MIMEText
40 from email.mime.multipart import MIMEMultipart
41 from email.mime.application import MIMEApplication
42 from email.encoders import encode_7or8bit
43 from email.generator import Generator
44 from email.parser import Parser
45 from email.utils import getaddress
47 # adjust to old python 2.4
48 from email import Message
49 from email.MIMEText import MIMEText
50 from email.MIMEMultipart import MIMEMultipart
51 from email.MIMENonMultipart import MIMENonMultipart
52 from email.Encoders import encode_7or8bit
53 from email.Generator import Generator
54 from email.parser import Parser
55 from email.Utils import getaddresses
57 getaddress = getaddresses
58 class MIMEApplication (MIMENonMultipart):
59 def __init__(self, _data, _subtype, _encoder, **params):
60 MIMENonMultipart.__init__(self, 'application', _subtype, **params)
61 self.set_payload(_data)
64 usage="""usage: %prog [options]
66 Scriptable PGP MIME email using gpg.
68 You can use gpg-agent for passphrase caching if your key requires a
69 passphrase (it better!). Example usage would be to install gpg-agent,
72 eval $(gpg-agent --daemon)
73 in your shell before invoking this script. See gpg-agent(1) for more
74 details. Alternatively, you can send your passphrase in on stdin
75 echo 'passphrase' | %prog [options]
76 or use the --passphrase-file option
77 %prog [options] --passphrase-file FILE [more options]
78 Both of these alternatives are much less secure than gpg-agent. You
86 # The following commands are adapted from my .mutt/pgp configuration
88 # Printf-like sequences:
89 # %a The value of PGP_SIGN_AS.
90 # %f Expands to the name of a file with text to be signed/encrypted.
91 # %p Expands to the passphrase argument.
92 # %R A string with some number (0 on up) of pgp_reciepient_arg
94 # %r One key ID (e.g. recipient email address) to build a
95 # pgp_reciepient_arg string.
97 # The above sequences can be used to optionally print a string if
98 # their length is nonzero. For example, you may only want to pass the
99 # -u/--local-user argument to gpg if PGP_SIGN_AS is defined. To
100 # optionally print a string based upon one of the above sequences, the
101 # following construct is used
102 # %?<sequence_char>?<optional_string>?
103 # where sequence_char is a character from the table above, and
104 # optional_string is the string you would like printed if status_char
105 # is nonzero. optional_string may contain other sequence as well as
106 # normal text, but it may not contain any question marks.
108 # see http://codesorcery.net/old/mutt/mutt-gnupg-howto
109 # http://www.mutt.org/doc/manual/manual-6.html#pgp_autosign
110 # http://tldp.org/HOWTO/Mutt-GnuPG-PGP-HOWTO-8.html
113 pgp_recipient_arg='-r "%r"'
114 pgp_stdin_passphrase_arg='--passphrase-fd 0'
115 pgp_sign_command='/usr/bin/gpg --no-verbose --quiet --batch %p --output - --detach-sign --armor --textmode %?a?-u "%a"? %f'
116 pgp_encrypt_only_command='/usr/bin/gpg --no-verbose --quiet --batch --output - --encrypt --armor --textmode --always-trust --encrypt-to "%a" %R -- %f'
117 pgp_encrypt_sign_command='/usr/bin/gpg --no-verbose --quiet --batch %p --output - --encrypt --sign %?a?-u "%a"? --armor --textmode --always-trust --encrypt-to "%a" %R -- %f'
118 sendmail='/usr/sbin/sendmail -t'
120 def mail(msg, sendmail=None):
122 Send an email Message instance on its merry way.
124 We can shell out to the user specified sendmail in case
125 the local host doesn't have an SMTP server set up
126 for easy smtplib usage.
129 execute(sendmail, stdin=flatten(msg))
133 s.sendmail(from_addr=source_email(msg),
134 to_addrs=target_emails(msg),
138 def header_from_text(text, encoding="us-ascii"):
140 Simple wrapper for instantiating an email.Message from text.
141 >>> header = header_from_text('\\n'.join(['From: me@big.edu','To: you@big.edu','Subject: testing']))
142 >>> print flatten(header)
150 if type(text) == types.UnicodeType:
151 text = text.encode(encoding)
152 # assume StringType arguments are already encoded
154 return p.parsestr(text, headersonly=True)
156 def guess_encoding(text):
157 if type(text) == types.StringType:
158 encoding = "us-ascii"
159 elif type(text) == types.UnicodeType:
160 for encoding in ["us-ascii", "iso-8859-1", "utf-8"]:
162 text.encode(encoding)
167 assert encoding != None
170 def encodedMIMEText(body, encoding=None):
172 encoding = guess_encoding(body)
173 if encoding == "us-ascii":
174 return MIMEText(body)
176 # Create the message ('plain' stands for Content-Type: text/plain)
177 return MIMEText(body.encode(encoding), 'plain', encoding)
179 def append_text(text_part, new_text):
180 original_payload = text_part.get_payload(decode=True)
181 new_payload = u"%s%s" % (original_payload, new_text)
182 new_encoding = guess_encoding(new_payload)
183 text_part.set_payload(new_payload.encode(new_encoding), new_encoding)
185 def attach_root(header, root_part):
187 Attach the email.Message root_part to the email.Message header
188 without generating a multi-part message.
190 for k,v in header.items():
194 def execute(args, stdin=None, expect=(0,)):
196 Execute a command (allows us to drive gpg).
198 if verboseInvoke == True:
199 print >> sys.stderr, '$ '+args
201 p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, close_fds=True)
203 strerror = '%s\nwhile executing %s' % (e.args[1], args)
204 raise Exception, strerror
205 output, error = p.communicate(input=stdin)
207 if verboseInvoke == True:
208 print >> sys.stderr, '(status: %d)\n%s%s' % (status, output, error)
209 if status not in expect:
210 strerror = '%s\nwhile executing %s\n%s\n%d' % (args[1], args, error, status)
211 raise Exception, strerror
212 return status, output, error
214 def replace(template, format_char, replacement_text):
216 >>> replace('--textmode %?a?-u %a? %f', 'f', 'file.in')
217 '--textmode %?a?-u %a? file.in'
218 >>> replace('--textmode %?a?-u %a? %f', 'a', '0xHEXKEY')
219 '--textmode -u 0xHEXKEY %f'
220 >>> replace('--textmode %?a?-u %a? %f', 'a', '')
223 if replacement_text == None:
224 replacement_text = ""
225 regexp = re.compile('%[?]'+format_char+'[?]([^?]*)[?]')
226 if len(replacement_text) > 0:
227 str = regexp.sub('\g<1>', template)
229 str = regexp.sub('', template)
230 regexp = re.compile('%'+format_char)
231 str = regexp.sub(replacement_text, str)
234 def flatten(msg, to_unicode=False):
236 Produce flat text output from an email Message instance.
240 g = Generator(fp, mangle_from_=False)
243 if to_unicode == True:
244 encoding = msg.get_content_charset() or "utf-8"
245 text = unicode(text, encoding=encoding)
248 def source_email(msg, return_realname=False):
250 Search the header of an email Message instance to find the
251 sender's email address.
253 froms = msg.get_all('from', [])
254 from_tuples = getaddresses(froms) # [(realname, email_address), ...]
255 assert len(from_tuples) == 1
256 if return_realname == True:
257 return from_tuples[0] # (realname, email_address)
258 return from_tuples[0][1] # email_address
260 def target_emails(msg):
262 Search the header of an email Message instance to find a
263 list of recipient's email addresses.
265 tos = msg.get_all('to', [])
266 ccs = msg.get_all('cc', [])
267 bccs = msg.get_all('bcc', [])
268 resent_tos = msg.get_all('resent-to', [])
269 resent_ccs = msg.get_all('resent-cc', [])
270 resent_bccs = msg.get_all('resent-bcc', [])
271 all_recipients = getaddresses(tos + ccs + bccs + resent_tos
272 + resent_ccs + resent_bccs)
273 return [addr[1] for addr in all_recipients]
275 class PGPMimeMessageFactory (object):
277 See http://www.ietf.org/rfc/rfc3156.txt for specification details.
278 >>> from_addr = "me@big.edu"
279 >>> to_addr = "you@you.edu"
280 >>> header = header_from_text('\\n'.join(['From: %s'%from_addr,'To: %s'%to_addr,'Subject: testing']))
281 >>> source_email(header) == from_addr
283 >>> target_emails(header) == [to_addr]
285 >>> m = PGPMimeMessageFactory('check 1 2\\ncheck 1 2\\n')
286 >>> print flatten(m.clearBodyPart())
287 Content-Type: text/plain; charset="us-ascii"
289 Content-Transfer-Encoding: 7bit
290 Content-Disposition: inline
295 >>> print flatten(m.plain())
296 Content-Type: text/plain; charset="us-ascii"
298 Content-Transfer-Encoding: 7bit
303 >>> signed = m.sign(header)
304 >>> signed.set_boundary('boundsep')
305 >>> print flatten(signed).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
306 Content-Type: multipart/signed; protocol="application/pgp-signature";
307 micalg="pgp-sha1"; boundary="boundsep"
309 Content-Disposition: inline
312 Content-Type: text/plain; charset="us-ascii"
314 Content-Transfer-Encoding: 7bit
315 Content-Disposition: inline
322 Content-Transfer-Encoding: 7bit
323 Content-Description: signature
324 Content-Type: application/pgp-signature; name="signature.asc";
327 -----BEGIN PGP SIGNATURE-----
329 -----END PGP SIGNATURE-----
332 >>> encrypted = m.encrypt(header)
333 >>> encrypted.set_boundary('boundsep')
334 >>> print flatten(encrypted).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
335 Content-Type: multipart/encrypted;
336 protocol="application/pgp-encrypted";
337 micalg="pgp-sha1"; boundary="boundsep"
339 Content-Disposition: inline
342 Content-Type: application/pgp-encrypted
344 Content-Transfer-Encoding: 7bit
350 Content-Transfer-Encoding: 7bit
351 Content-Type: application/octet-stream; charset="us-ascii"
353 -----BEGIN PGP MESSAGE-----
355 -----END PGP MESSAGE-----
358 >>> signedAndEncrypted = m.signAndEncrypt(header)
359 >>> signedAndEncrypted.set_boundary('boundsep')
360 >>> print flatten(signedAndEncrypted).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
361 Content-Type: multipart/encrypted;
362 protocol="application/pgp-encrypted";
363 micalg="pgp-sha1"; boundary="boundsep"
365 Content-Disposition: inline
368 Content-Type: application/pgp-encrypted
370 Content-Transfer-Encoding: 7bit
376 Content-Transfer-Encoding: 7bit
377 Content-Type: application/octet-stream; charset="us-ascii"
379 -----BEGIN PGP MESSAGE-----
381 -----END PGP MESSAGE-----
385 def __init__(self, body):
387 def clearBodyPart(self):
388 body = encodedMIMEText(self.body)
389 body.add_header('Content-Disposition', 'inline')
391 def passphrase_arg(self, passphrase=None):
392 if passphrase == None and PASSPHRASE != None:
393 passphrase = PASSPHRASE
394 if passphrase == None:
396 return (passphrase, pgp_stdin_passphrase_arg)
401 return encodedMIMEText(self.body)
402 def sign(self, header, passphrase=None):
405 +-> text/plain (body)
406 +-> application/pgp-signature (signature)
408 passphrase,pass_arg = self.passphrase_arg(passphrase)
409 body = self.clearBodyPart()
410 bfile = tempfile.NamedTemporaryFile()
411 bfile.write(flatten(body))
414 args = replace(pgp_sign_command, 'f', bfile.name)
415 if PGP_SIGN_AS == None:
416 pgp_sign_as = '<%s>' % source_email(header)
418 pgp_sign_as = PGP_SIGN_AS
419 args = replace(args, 'a', pgp_sign_as)
420 args = replace(args, 'p', pass_arg)
421 status,output,error = execute(args, stdin=passphrase)
424 sig = MIMEApplication(_data=signature,
425 _subtype='pgp-signature; name="signature.asc"',
426 _encoder=encode_7or8bit)
427 sig['Content-Description'] = 'signature'
428 sig.set_charset('us-ascii')
430 msg = MIMEMultipart('signed', micalg='pgp-sha1',
431 protocol='application/pgp-signature')
435 msg['Content-Disposition'] = 'inline'
437 def encrypt(self, header, passphrase=None):
440 +-> application/pgp-encrypted (control information)
441 +-> application/octet-stream (body)
443 body = self.clearBodyPart()
444 bfile = tempfile.NamedTemporaryFile()
445 bfile.write(flatten(body))
448 recipients = [replace(pgp_recipient_arg, 'r', recipient)
449 for recipient in target_emails(header)]
450 recipient_string = ' '.join(recipients)
451 args = replace(pgp_encrypt_only_command, 'R', recipient_string)
452 args = replace(args, 'f', bfile.name)
453 if PGP_SIGN_AS == None:
454 pgp_sign_as = '<%s>' % source_email(header)
456 pgp_sign_as = PGP_SIGN_AS
457 args = replace(args, 'a', pgp_sign_as)
458 status,output,error = execute(args)
461 enc = MIMEApplication(_data=encrypted, _subtype='octet-stream',
462 _encoder=encode_7or8bit)
463 enc.set_charset('us-ascii')
465 control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted',
466 _encoder=encode_7or8bit)
468 msg = MIMEMultipart('encrypted', micalg='pgp-sha1',
469 protocol='application/pgp-encrypted')
473 msg['Content-Disposition'] = 'inline'
475 def signAndEncrypt(self, header, passphrase=None):
478 +-> application/pgp-encrypted (control information)
479 +-> application/octet-stream (body)
481 passphrase,pass_arg = self.passphrase_arg(passphrase)
482 body = self.sign(header, passphrase)
483 body.__delitem__('Bcc')
484 bfile = tempfile.NamedTemporaryFile()
485 bfile.write(flatten(body))
488 recipients = [replace(pgp_recipient_arg, 'r', recipient)
489 for recipient in target_emails(header)]
490 recipient_string = ' '.join(recipients)
491 args = replace(pgp_encrypt_only_command, 'R', recipient_string)
492 args = replace(args, 'f', bfile.name)
493 if PGP_SIGN_AS == None:
494 pgp_sign_as = '<%s>' % source_email(header)
496 pgp_sign_as = PGP_SIGN_AS
497 args = replace(args, 'a', pgp_sign_as)
498 args = replace(args, 'p', pass_arg)
499 status,output,error = execute(args, stdin=passphrase)
502 enc = MIMEApplication(_data=encrypted, _subtype='octet-stream',
503 _encoder=encode_7or8bit)
504 enc.set_charset('us-ascii')
506 control = MIMEApplication(_data='Version: 1\n',
507 _subtype='pgp-encrypted',
508 _encoder=encode_7or8bit)
510 msg = MIMEMultipart('encrypted', micalg='pgp-sha1',
511 protocol='application/pgp-encrypted')
515 msg['Content-Disposition'] = 'inline'
523 if __name__ == '__main__':
524 from optparse import OptionParser
526 parser = OptionParser(usage=usage)
527 parser.add_option('-t', '--test', dest='test', action='store_true',
528 help='Run doctests and exit')
530 parser.add_option('-H', '--header-file', dest='header_filename',
531 help='file containing email header', metavar='FILE')
532 parser.add_option('-B', '--body-file', dest='body_filename',
533 help='file containing email body', metavar='FILE')
535 parser.add_option('-P', '--passphrase-file', dest='passphrase_file',
536 help='file containing gpg passphrase', metavar='FILE')
537 parser.add_option('-p', '--passphrase-fd', dest='passphrase_fd',
538 help='file descriptor from which to read gpg passphrase (0 for stdin)',
539 type="int", metavar='DESCRIPTOR')
541 parser.add_option('--mode', dest='mode', default='sign',
542 help="One of 'sign', 'encrypt', 'sign-encrypt', or 'plain'. Defaults to %default.",
545 parser.add_option('-a', '--sign-as', dest='sign_as',
546 help="The gpg key to sign with (gpg's -u/--local-user)",
549 parser.add_option('--output', dest='output', action='store_true',
550 help="Don't mail the generated message, print it to stdout instead.")
552 (options, args) = parser.parse_args()
556 if options.passphrase_file != None:
557 PASSPHRASE = file(options.passphrase_file, 'r').read()
558 elif options.passphrase_fd != None:
559 if options.passphrase_fd == 0:
561 PASSPHRASE = sys.stdin.read()
563 PASSPHRASE = os.read(options.passphrase_fd)
566 PGP_SIGN_AS = options.sign_as
568 if options.test == True:
573 if options.header_filename != None:
574 if options.header_filename == '-':
575 assert stdin_used == False
577 header = sys.stdin.read()
579 header = file(options.header_filename, 'r').read()
581 raise Exception, "missing header"
582 headermsg = header_from_text(header)
584 if options.body_filename != None:
585 if options.body_filename == '-':
586 assert stdin_used == False
588 body = sys.stdin.read()
590 body = file(options.body_filename, 'r').read()
592 raise Exception, "missing body"
594 m = PGPMimeMessageFactory(body)
595 if options.mode == "sign":
596 bodymsg = m.sign(header)
597 elif options.mode == "encrypt":
598 bodymsg = m.encrypt(header)
599 elif options.mode == "sign-encrypt":
600 bodymsg = m.signAndEncrypt(header)
601 elif options.mode == "plain":
604 print "Unrecognized mode '%s'" % options.mode
606 message = attach_root(headermsg, bodymsg)
607 if options.output == True:
608 message = flatten(message)
611 mail(message, sendmail)