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 encodedMIMEText(body, encoding=None):
158 if type(body) == types.StringType:
159 encoding = "us-ascii"
160 elif type(body) == types.UnicodeType:
161 for encoding in ["us-ascii", "iso-8859-1", "utf-8"]:
163 body.encode(encoding)
168 assert encoding != None
169 # Create the message ('plain' stands for Content-Type: text/plain)
170 if encoding == "us-ascii":
171 return MIMEText(body)
173 return MIMEText(body.encode(encoding), 'plain', encoding)
175 def attach_root(header, root_part):
177 Attach the email.Message root_part to the email.Message header
178 without generating a multi-part message.
180 for k,v in header.items():
184 def execute(args, stdin=None, expect=(0,)):
186 Execute a command (allows us to drive gpg).
188 if verboseInvoke == True:
189 print >> sys.stderr, '$ '+args
191 p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, close_fds=True)
193 strerror = '%s\nwhile executing %s' % (e.args[1], args)
194 raise Exception, strerror
195 output, error = p.communicate(input=stdin)
197 if verboseInvoke == True:
198 print >> sys.stderr, '(status: %d)\n%s%s' % (status, output, error)
199 if status not in expect:
200 strerror = '%s\nwhile executing %s\n%s\n%d' % (args[1], args, error, status)
201 raise Exception, strerror
202 return status, output, error
204 def replace(template, format_char, replacement_text):
206 >>> replace('--textmode %?a?-u %a? %f', 'f', 'file.in')
207 '--textmode %?a?-u %a? file.in'
208 >>> replace('--textmode %?a?-u %a? %f', 'a', '0xHEXKEY')
209 '--textmode -u 0xHEXKEY %f'
210 >>> replace('--textmode %?a?-u %a? %f', 'a', '')
213 if replacement_text == None:
214 replacement_text = ""
215 regexp = re.compile('%[?]'+format_char+'[?]([^?]*)[?]')
216 if len(replacement_text) > 0:
217 str = regexp.sub('\g<1>', template)
219 str = regexp.sub('', template)
220 regexp = re.compile('%'+format_char)
221 str = regexp.sub(replacement_text, str)
224 def flatten(msg, to_unicode=False):
226 Produce flat text output from an email Message instance.
230 g = Generator(fp, mangle_from_=False)
233 if to_unicode == True:
234 encoding = msg.get_content_charset() or "utf-8"
235 text = unicode(text, encoding=encoding)
238 def source_email(msg, return_realname=False):
240 Search the header of an email Message instance to find the
241 sender's email address.
243 froms = msg.get_all('from', [])
244 from_tuples = getaddresses(froms) # [(realname, email_address), ...]
245 assert len(from_tuples) == 1
246 if return_realname == True:
247 return from_tuples[0] # (realname, email_address)
248 return from_tuples[0][1] # email_address
250 def target_emails(msg):
252 Search the header of an email Message instance to find a
253 list of recipient's email addresses.
255 tos = msg.get_all('to', [])
256 ccs = msg.get_all('cc', [])
257 bccs = msg.get_all('bcc', [])
258 resent_tos = msg.get_all('resent-to', [])
259 resent_ccs = msg.get_all('resent-cc', [])
260 resent_bccs = msg.get_all('resent-bcc', [])
261 all_recipients = getaddresses(tos + ccs + bccs + resent_tos
262 + resent_ccs + resent_bccs)
263 return [addr[1] for addr in all_recipients]
265 class PGPMimeMessageFactory (object):
267 See http://www.ietf.org/rfc/rfc3156.txt for specification details.
268 >>> from_addr = "me@big.edu"
269 >>> to_addr = "you@you.edu"
270 >>> header = header_from_text('\\n'.join(['From: %s'%from_addr,'To: %s'%to_addr,'Subject: testing']))
271 >>> source_email(header) == from_addr
273 >>> target_emails(header) == [to_addr]
275 >>> m = EncryptedMessageFactory('check 1 2\\ncheck 1 2\\n')
276 >>> print flatten(m.clearBodyPart())
277 Content-Type: text/plain; charset="us-ascii"
279 Content-Transfer-Encoding: 7bit
280 Content-Disposition: inline
285 >>> print flatten(m.plain())
286 Content-Type: text/plain; charset="us-ascii"
288 Content-Transfer-Encoding: 7bit
293 >>> signed = m.sign(header)
294 >>> signed.set_boundary('boundsep')
295 >>> print flatten(signed).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
296 Content-Type: multipart/signed; protocol="application/pgp-signature";
297 micalg="pgp-sha1"; boundary="boundsep"
299 Content-Disposition: inline
302 Content-Type: text/plain; charset="us-ascii"
304 Content-Transfer-Encoding: 7bit
305 Content-Disposition: inline
312 Content-Transfer-Encoding: 7bit
313 Content-Description: signature
314 Content-Type: application/pgp-signature; name="signature.asc";
317 -----BEGIN PGP SIGNATURE-----
319 -----END PGP SIGNATURE-----
322 >>> encrypted = m.encrypt(header)
323 >>> encrypted.set_boundary('boundsep')
324 >>> print flatten(encrypted).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
325 Content-Type: multipart/encrypted;
326 protocol="application/pgp-encrypted";
327 micalg="pgp-sha1"; boundary="boundsep"
329 Content-Disposition: inline
332 Content-Type: application/pgp-encrypted
334 Content-Transfer-Encoding: 7bit
340 Content-Transfer-Encoding: 7bit
341 Content-Type: application/octet-stream; charset="us-ascii"
343 -----BEGIN PGP MESSAGE-----
345 -----END PGP MESSAGE-----
348 >>> signedAndEncrypted = m.signAndEncrypt(header)
349 >>> signedAndEncrypted.set_boundary('boundsep')
350 >>> print flatten(signedAndEncrypted).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
351 Content-Type: multipart/encrypted;
352 protocol="application/pgp-encrypted";
353 micalg="pgp-sha1"; boundary="boundsep"
355 Content-Disposition: inline
358 Content-Type: application/pgp-encrypted
360 Content-Transfer-Encoding: 7bit
366 Content-Transfer-Encoding: 7bit
367 Content-Type: application/octet-stream; charset="us-ascii"
369 -----BEGIN PGP MESSAGE-----
371 -----END PGP MESSAGE-----
375 def __init__(self, body):
377 def clearBodyPart(self):
378 body = encodedMIMEText(self.body)
379 body.add_header('Content-Disposition', 'inline')
381 def passphrase_arg(self, passphrase=None):
382 if passphrase == None and PASSPHRASE != None:
383 passphrase = PASSPHRASE
384 if passphrase == None:
386 return (passphrase, pgp_stdin_passphrase_arg)
391 return encodedMIMEText(self.body)
392 def sign(self, header, passphrase=None):
395 +-> text/plain (body)
396 +-> application/pgp-signature (signature)
398 passphrase,pass_arg = self.passphrase_arg(passphrase)
399 body = self.clearBodyPart()
400 bfile = tempfile.NamedTemporaryFile()
401 bfile.write(flatten(body))
404 args = replace(pgp_sign_command, 'f', bfile.name)
405 if PGP_SIGN_AS == None:
406 pgp_sign_as = '<%s>' % source_email(header)
408 pgp_sign_as = PGP_SIGN_AS
409 args = replace(args, 'a', pgp_sign_as)
410 args = replace(args, 'p', pass_arg)
411 status,output,error = execute(args, stdin=passphrase)
414 sig = MIMEApplication(_data=signature,
415 _subtype='pgp-signature; name="signature.asc"',
416 _encoder=encode_7or8bit)
417 sig['Content-Description'] = 'signature'
418 sig.set_charset('us-ascii')
420 msg = MIMEMultipart('signed', micalg='pgp-sha1',
421 protocol='application/pgp-signature')
425 msg['Content-Disposition'] = 'inline'
427 def encrypt(self, header, passphrase=None):
430 +-> application/pgp-encrypted (control information)
431 +-> application/octet-stream (body)
433 body = self.clearBodyPart()
434 bfile = tempfile.NamedTemporaryFile()
435 bfile.write(flatten(body))
438 recipients = [replace(pgp_recipient_arg, 'r', recipient)
439 for recipient in target_emails(header)]
440 recipient_string = ' '.join(recipients)
441 args = replace(pgp_encrypt_only_command, 'R', recipient_string)
442 args = replace(args, 'f', bfile.name)
443 if PGP_SIGN_AS == None:
444 pgp_sign_as = '<%s>' % source_email(header)
446 pgp_sign_as = PGP_SIGN_AS
447 args = replace(args, 'a', pgp_sign_as)
448 status,output,error = execute(args)
451 enc = MIMEApplication(_data=encrypted, _subtype='octet-stream',
452 _encoder=encode_7or8bit)
453 enc.set_charset('us-ascii')
455 control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted',
456 _encoder=encode_7or8bit)
458 msg = MIMEMultipart('encrypted', micalg='pgp-sha1',
459 protocol='application/pgp-encrypted')
463 msg['Content-Disposition'] = 'inline'
465 def signAndEncrypt(self, header, passphrase=None):
468 +-> application/pgp-encrypted (control information)
469 +-> application/octet-stream (body)
471 passphrase,pass_arg = self.passphrase_arg(passphrase)
472 body = self.sign(header, passphrase)
473 body.__delitem__('Bcc')
474 bfile = tempfile.NamedTemporaryFile()
475 bfile.write(flatten(body))
478 recipients = [replace(pgp_recipient_arg, 'r', recipient)
479 for recipient in target_emails(header)]
480 recipient_string = ' '.join(recipients)
481 args = replace(pgp_encrypt_only_command, 'R', recipient_string)
482 args = replace(args, 'f', bfile.name)
483 if PGP_SIGN_AS == None:
484 pgp_sign_as = '<%s>' % source_email(header)
486 pgp_sign_as = PGP_SIGN_AS
487 args = replace(args, 'a', pgp_sign_as)
488 args = replace(args, 'p', pass_arg)
489 status,output,error = execute(args, stdin=passphrase)
492 enc = MIMEApplication(_data=encrypted, _subtype='octet-stream',
493 _encoder=encode_7or8bit)
494 enc.set_charset('us-ascii')
496 control = MIMEApplication(_data='Version: 1\n',
497 _subtype='pgp-encrypted',
498 _encoder=encode_7or8bit)
500 msg = MIMEMultipart('encrypted', micalg='pgp-sha1',
501 protocol='application/pgp-encrypted')
505 msg['Content-Disposition'] = 'inline'
513 if __name__ == '__main__':
514 from optparse import OptionParser
516 parser = OptionParser(usage=usage)
517 parser.add_option('-t', '--test', dest='test', action='store_true',
518 help='Run doctests and exit')
520 parser.add_option('-H', '--header-file', dest='header_filename',
521 help='file containing email header', metavar='FILE')
522 parser.add_option('-B', '--body-file', dest='body_filename',
523 help='file containing email body', metavar='FILE')
525 parser.add_option('-P', '--passphrase-file', dest='passphrase_file',
526 help='file containing gpg passphrase', metavar='FILE')
527 parser.add_option('-p', '--passphrase-fd', dest='passphrase_fd',
528 help='file descriptor from which to read gpg passphrase (0 for stdin)',
529 type="int", metavar='DESCRIPTOR')
531 parser.add_option('--mode', dest='mode', default='sign',
532 help="One of 'sign', 'encrypt', 'sign-encrypt', or 'plain'. Defaults to %default.",
535 parser.add_option('-a', '--sign-as', dest='sign_as',
536 help="The gpg key to sign with (gpg's -u/--local-user)",
539 parser.add_option('--output', dest='output', action='store_true',
540 help="Don't mail the generated message, print it to stdout instead.")
542 (options, args) = parser.parse_args()
546 if options.passphrase_file != None:
547 PASSPHRASE = file(options.passphrase_file, 'r').read()
548 elif options.passphrase_fd != None:
549 if options.passphrase_fd == 0:
551 PASSPHRASE = sys.stdin.read()
553 PASSPHRASE = os.read(options.passphrase_fd)
556 PGP_SIGN_AS = options.sign_as
558 if options.test == True:
563 if options.header_filename != None:
564 if options.header_filename == '-':
565 assert stdin_used == False
567 header = sys.stdin.read()
569 header = file(options.header_filename, 'r').read()
571 raise Exception, "missing header"
572 headermsg = header_from_text(header)
574 if options.body_filename != None:
575 if options.body_filename == '-':
576 assert stdin_used == False
578 body = sys.stdin.read()
580 body = file(options.body_filename, 'r').read()
582 raise Exception, "missing body"
584 m = EncryptedMessageFactory(body)
585 if options.mode == "sign":
586 bodymsg = m.sign(header)
587 elif options.mode == "encrypt":
588 bodymsg = m.encrypt(header)
589 elif options.mode == "sign-encrypt":
590 bodymsg = m.signAndEncrypt(header)
591 elif options.mode == "plain":
594 print "Unrecognized mode '%s'" % options.mode
596 message = attach_root(headermsg, bodymsg)
597 if options.output == True:
598 message = flatten(message)
601 mail(message, sendmail)