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 attach_root(header, root_part):
158 Attach the email.Message root_part to the email.Message header
159 without generating a multi-part message.
161 for k,v in header.items():
165 def execute(args, stdin=None, expect=(0,)):
167 Execute a command (allows us to drive gpg).
169 if verboseInvoke == True:
170 print >> sys.stderr, '$ '+args
172 p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, close_fds=True)
174 strerror = '%s\nwhile executing %s' % (e.args[1], args)
175 raise Exception, strerror
176 output, error = p.communicate(input=stdin)
178 if verboseInvoke == True:
179 print >> sys.stderr, '(status: %d)\n%s%s' % (status, output, error)
180 if status not in expect:
181 strerror = '%s\nwhile executing %s\n%s\n%d' % (args[1], args, error, status)
182 raise Exception, strerror
183 return status, output, error
185 def replace(template, format_char, replacement_text):
187 >>> replace('--textmode %?a?-u %a? %f', 'f', 'file.in')
188 '--textmode %?a?-u %a? file.in'
189 >>> replace('--textmode %?a?-u %a? %f', 'a', '0xHEXKEY')
190 '--textmode -u 0xHEXKEY %f'
191 >>> replace('--textmode %?a?-u %a? %f', 'a', '')
194 if replacement_text == None:
195 replacement_text = ""
196 regexp = re.compile('%[?]'+format_char+'[?]([^?]*)[?]')
197 if len(replacement_text) > 0:
198 str = regexp.sub('\g<1>', template)
200 str = regexp.sub('', template)
201 regexp = re.compile('%'+format_char)
202 str = regexp.sub(replacement_text, str)
205 def flatten(msg, to_unicode=False):
207 Produce flat text output from an email Message instance.
211 g = Generator(fp, mangle_from_=False)
214 if to_unicode == True:
215 encoding = msg.get_content_charset() or "utf-8"
216 text = unicode(text, encoding=encoding)
219 def source_email(msg, return_realname=False):
221 Search the header of an email Message instance to find the
222 sender's email address.
224 froms = msg.get_all('from', [])
225 from_tuples = getaddresses(froms) # [(realname, email_address), ...]
226 assert len(from_tuples) == 1
227 if return_realname == True:
228 return from_tuples[0] # (realname, email_address)
229 return from_tuples[0][1] # email_address
231 def target_emails(msg):
233 Search the header of an email Message instance to find a
234 list of recipient's email addresses.
236 tos = msg.get_all('to', [])
237 ccs = msg.get_all('cc', [])
238 bccs = msg.get_all('bcc', [])
239 resent_tos = msg.get_all('resent-to', [])
240 resent_ccs = msg.get_all('resent-cc', [])
241 resent_bccs = msg.get_all('resent-bcc', [])
242 all_recipients = getaddresses(tos + ccs + bccs + resent_tos
243 + resent_ccs + resent_bccs)
244 return [addr[1] for addr in all_recipients]
246 class PGPMimeMessageFactory (object):
248 See http://www.ietf.org/rfc/rfc3156.txt for specification details.
249 >>> from_addr = "me@big.edu"
250 >>> to_addr = "you@you.edu"
251 >>> header = header_from_text('\\n'.join(['From: %s'%from_addr,'To: %s'%to_addr,'Subject: testing']))
252 >>> source_email(header) == from_addr
254 >>> target_emails(header) == [to_addr]
256 >>> m = EncryptedMessageFactory('check 1 2\\ncheck 1 2\\n')
257 >>> print flatten(m.clearBodyPart())
258 Content-Type: text/plain; charset="us-ascii"
260 Content-Transfer-Encoding: 7bit
261 Content-Disposition: inline
266 >>> print flatten(m.plain())
267 Content-Type: text/plain; charset="us-ascii"
269 Content-Transfer-Encoding: 7bit
274 >>> signed = m.sign(header)
275 >>> signed.set_boundary('boundsep')
276 >>> print flatten(signed).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
277 Content-Type: multipart/signed; protocol="application/pgp-signature";
278 micalg="pgp-sha1"; boundary="boundsep"
280 Content-Disposition: inline
283 Content-Type: text/plain; charset="us-ascii"
285 Content-Transfer-Encoding: 7bit
286 Content-Disposition: inline
293 Content-Transfer-Encoding: 7bit
294 Content-Description: signature
295 Content-Type: application/pgp-signature; name="signature.asc";
298 -----BEGIN PGP SIGNATURE-----
300 -----END PGP SIGNATURE-----
303 >>> encrypted = m.encrypt(header)
304 >>> encrypted.set_boundary('boundsep')
305 >>> print flatten(encrypted).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
306 Content-Type: multipart/encrypted;
307 protocol="application/pgp-encrypted";
308 micalg="pgp-sha1"; boundary="boundsep"
310 Content-Disposition: inline
313 Content-Type: application/pgp-encrypted
315 Content-Transfer-Encoding: 7bit
321 Content-Transfer-Encoding: 7bit
322 Content-Type: application/octet-stream; charset="us-ascii"
324 -----BEGIN PGP MESSAGE-----
326 -----END PGP MESSAGE-----
329 >>> signedAndEncrypted = m.signAndEncrypt(header)
330 >>> signedAndEncrypted.set_boundary('boundsep')
331 >>> print flatten(signedAndEncrypted).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
332 Content-Type: multipart/encrypted;
333 protocol="application/pgp-encrypted";
334 micalg="pgp-sha1"; boundary="boundsep"
336 Content-Disposition: inline
339 Content-Type: application/pgp-encrypted
341 Content-Transfer-Encoding: 7bit
347 Content-Transfer-Encoding: 7bit
348 Content-Type: application/octet-stream; charset="us-ascii"
350 -----BEGIN PGP MESSAGE-----
352 -----END PGP MESSAGE-----
356 def __init__(self, body):
358 def encodedMIMEText(self, body, encoding=None):
360 if type(body) == types.StringType:
361 encoding = "us-ascii"
362 elif type(body) == types.UnicodeType:
363 for encoding in ["us-ascii", "iso-8859-1", "utf-8"]:
365 body.encode(encoding)
370 assert encoding != None
371 # Create the message ('plain' stands for Content-Type: text/plain)
372 if encoding == "us-ascii":
373 return MIMEText(body)
375 return MIMEText(body.encode(encoding), 'plain', encoding)
376 def clearBodyPart(self):
377 body = self.encodedMIMEText(self.body)
378 body.add_header('Content-Disposition', 'inline')
380 def passphrase_arg(self, passphrase=None):
381 if passphrase == None and PASSPHRASE != None:
382 passphrase = PASSPHRASE
383 if passphrase == None:
385 return (passphrase, pgp_stdin_passphrase_arg)
390 return self.encodedMIMEText(self.body)
391 def sign(self, header, passphrase=None):
394 +-> text/plain (body)
395 +-> application/pgp-signature (signature)
397 passphrase,pass_arg = self.passphrase_arg(passphrase)
398 body = self.clearBodyPart()
399 bfile = tempfile.NamedTemporaryFile()
400 bfile.write(flatten(body))
403 args = replace(pgp_sign_command, 'f', bfile.name)
404 if PGP_SIGN_AS == None:
405 pgp_sign_as = '<%s>' % source_email(header)
407 pgp_sign_as = PGP_SIGN_AS
408 args = replace(args, 'a', pgp_sign_as)
409 args = replace(args, 'p', pass_arg)
410 status,output,error = execute(args, stdin=passphrase)
413 sig = MIMEApplication(_data=signature,
414 _subtype='pgp-signature; name="signature.asc"',
415 _encoder=encode_7or8bit)
416 sig['Content-Description'] = 'signature'
417 sig.set_charset('us-ascii')
419 msg = MIMEMultipart('signed', micalg='pgp-sha1',
420 protocol='application/pgp-signature')
424 msg['Content-Disposition'] = 'inline'
426 def encrypt(self, header, passphrase=None):
429 +-> application/pgp-encrypted (control information)
430 +-> application/octet-stream (body)
432 body = self.clearBodyPart()
433 bfile = tempfile.NamedTemporaryFile()
434 bfile.write(flatten(body))
437 recipients = [replace(pgp_recipient_arg, 'r', recipient)
438 for recipient in target_emails(header)]
439 recipient_string = ' '.join(recipients)
440 args = replace(pgp_encrypt_only_command, 'R', recipient_string)
441 args = replace(args, 'f', bfile.name)
442 if PGP_SIGN_AS == None:
443 pgp_sign_as = '<%s>' % source_email(header)
445 pgp_sign_as = PGP_SIGN_AS
446 args = replace(args, 'a', pgp_sign_as)
447 status,output,error = execute(args)
450 enc = MIMEApplication(_data=encrypted, _subtype='octet-stream',
451 _encoder=encode_7or8bit)
452 enc.set_charset('us-ascii')
454 control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted',
455 _encoder=encode_7or8bit)
457 msg = MIMEMultipart('encrypted', micalg='pgp-sha1',
458 protocol='application/pgp-encrypted')
462 msg['Content-Disposition'] = 'inline'
464 def signAndEncrypt(self, header, passphrase=None):
467 +-> application/pgp-encrypted (control information)
468 +-> application/octet-stream (body)
470 passphrase,pass_arg = self.passphrase_arg(passphrase)
471 body = self.sign(header, passphrase)
472 body.__delitem__('Bcc')
473 bfile = tempfile.NamedTemporaryFile()
474 bfile.write(flatten(body))
477 recipients = [replace(pgp_recipient_arg, 'r', recipient)
478 for recipient in target_emails(header)]
479 recipient_string = ' '.join(recipients)
480 args = replace(pgp_encrypt_only_command, 'R', recipient_string)
481 args = replace(args, 'f', bfile.name)
482 if PGP_SIGN_AS == None:
483 pgp_sign_as = '<%s>' % source_email(header)
485 pgp_sign_as = PGP_SIGN_AS
486 args = replace(args, 'a', pgp_sign_as)
487 args = replace(args, 'p', pass_arg)
488 status,output,error = execute(args, stdin=passphrase)
491 enc = MIMEApplication(_data=encrypted, _subtype='octet-stream',
492 _encoder=encode_7or8bit)
493 enc.set_charset('us-ascii')
495 control = MIMEApplication(_data='Version: 1\n',
496 _subtype='pgp-encrypted',
497 _encoder=encode_7or8bit)
499 msg = MIMEMultipart('encrypted', micalg='pgp-sha1',
500 protocol='application/pgp-encrypted')
504 msg['Content-Disposition'] = 'inline'
512 if __name__ == '__main__':
513 from optparse import OptionParser
515 parser = OptionParser(usage=usage)
516 parser.add_option('-t', '--test', dest='test', action='store_true',
517 help='Run doctests and exit')
519 parser.add_option('-H', '--header-file', dest='header_filename',
520 help='file containing email header', metavar='FILE')
521 parser.add_option('-B', '--body-file', dest='body_filename',
522 help='file containing email body', metavar='FILE')
524 parser.add_option('-P', '--passphrase-file', dest='passphrase_file',
525 help='file containing gpg passphrase', metavar='FILE')
526 parser.add_option('-p', '--passphrase-fd', dest='passphrase_fd',
527 help='file descriptor from which to read gpg passphrase (0 for stdin)',
528 type="int", metavar='DESCRIPTOR')
530 parser.add_option('--mode', dest='mode', default='sign',
531 help="One of 'sign', 'encrypt', 'sign-encrypt', or 'plain'. Defaults to %default.",
534 parser.add_option('-a', '--sign-as', dest='sign_as',
535 help="The gpg key to sign with (gpg's -u/--local-user)",
538 parser.add_option('--output', dest='output', action='store_true',
539 help="Don't mail the generated message, print it to stdout instead.")
541 (options, args) = parser.parse_args()
545 if options.passphrase_file != None:
546 PASSPHRASE = file(options.passphrase_file, 'r').read()
547 elif options.passphrase_fd != None:
548 if options.passphrase_fd == 0:
550 PASSPHRASE = sys.stdin.read()
552 PASSPHRASE = os.read(options.passphrase_fd)
555 PGP_SIGN_AS = options.sign_as
557 if options.test == True:
562 if options.header_filename != None:
563 if options.header_filename == '-':
564 assert stdin_used == False
566 header = sys.stdin.read()
568 header = file(options.header_filename, 'r').read()
570 raise Exception, "missing header"
571 headermsg = header_from_text(header)
573 if options.body_filename != None:
574 if options.body_filename == '-':
575 assert stdin_used == False
577 body = sys.stdin.read()
579 body = file(options.body_filename, 'r').read()
581 raise Exception, "missing body"
583 m = EncryptedMessageFactory(body)
584 if options.mode == "sign":
585 bodymsg = m.sign(header)
586 elif options.mode == "encrypt":
587 bodymsg = m.encrypt(header)
588 elif options.mode == "sign-encrypt":
589 bodymsg = m.signAndEncrypt(header)
590 elif options.mode == "plain":
593 print "Unrecognized mode '%s'" % options.mode
595 message = attach_root(headermsg, bodymsg)
596 if options.output == True:
597 message = flatten(message)
600 mail(message, sendmail)