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.mime.text import MIMEText
39 from email.mime.multipart import MIMEMultipart
40 from email.mime.application import MIMEApplication
41 from email.encoders import encode_7or8bit
42 from email.generator import Generator
43 from email.parser import Parser
44 from email.utils import getaddress
46 # adjust to old python 2.4
47 from email.MIMEText import MIMEText
48 from email.MIMEMultipart import MIMEMultipart
49 from email.MIMENonMultipart import MIMENonMultipart
50 from email.Encoders import encode_7or8bit
51 from email.Generator import Generator
52 from email.parser import Parser
53 from email.Utils import getaddresses
55 getaddress = getaddresses
56 class MIMEApplication (MIMENonMultipart):
57 def __init__(self, _data, _subtype, _encoder, **params):
58 MIMENonMultipart.__init__(self, 'application', _subtype, **params)
59 self.set_payload(_data)
62 usage="""usage: %prog [options]
64 Scriptable PGP MIME email using gpg.
66 You can use gpg-agent for passphrase caching if your key requires a
67 passphrase (it better!). Example usage would be to install gpg-agent,
70 eval $(gpg-agent --daemon)
71 in your shell before invoking this script. See gpg-agent(1) for more
72 details. Alternatively, you can send your passphrase in on stdin
73 echo 'passphrase' | %prog [options]
74 or use the --passphrase-file option
75 %prog [options] --passphrase-file FILE [more options]
76 Both of these alternatives are much less secure than gpg-agent. You
84 # The following commands are adapted from my .mutt/pgp configuration
86 # Printf-like sequences:
87 # %a The value of PGP_SIGN_AS.
88 # %f Expands to the name of a file with text to be signed/encrypted.
89 # %p Expands to the passphrase argument.
90 # %R A string with some number (0 on up) of pgp_reciepient_arg
92 # %r One key ID (e.g. recipient email address) to build a
93 # pgp_reciepient_arg string.
95 # The above sequences can be used to optionally print a string if
96 # their length is nonzero. For example, you may only want to pass the
97 # -u/--local-user argument to gpg if PGP_SIGN_AS is defined. To
98 # optionally print a string based upon one of the above sequences, the
99 # following construct is used
100 # %?<sequence_char>?<optional_string>?
101 # where sequence_char is a character from the table above, and
102 # optional_string is the string you would like printed if status_char
103 # is nonzero. optional_string may contain other sequence as well as
104 # normal text, but it may not contain any question marks.
106 # see http://codesorcery.net/old/mutt/mutt-gnupg-howto
107 # http://www.mutt.org/doc/manual/manual-6.html#pgp_autosign
108 # http://tldp.org/HOWTO/Mutt-GnuPG-PGP-HOWTO-8.html
111 pgp_recipient_arg='-r "%r"'
112 pgp_stdin_passphrase_arg='--passphrase-fd 0'
113 pgp_sign_command='/usr/bin/gpg --no-verbose --quiet --batch %p --output - --detach-sign --armor --textmode %?a?-u "%a"? %f'
114 pgp_encrypt_only_command='/usr/bin/gpg --no-verbose --quiet --batch --output - --encrypt --armor --textmode --always-trust --encrypt-to "%a" %R -- %f'
115 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'
116 sendmail='/usr/sbin/sendmail -t'
118 def execute(args, stdin=None, expect=(0,)):
120 Execute a command (allows us to drive gpg).
122 if verboseInvoke == True:
123 print >> sys.stderr, '$ '+args
125 p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, close_fds=True)
127 strerror = '%s\nwhile executing %s' % (e.args[1], args)
128 raise Exception, strerror
129 output, error = p.communicate(input=stdin)
131 if verboseInvoke == True:
132 print >> sys.stderr, '(status: %d)\n%s%s' % (status, output, error)
133 if status not in expect:
134 strerror = '%s\nwhile executing %s\n%s\n%d' % (args[1], args, error, status)
135 raise Exception, strerror
136 return status, output, error
138 def replace(template, format_char, replacement_text):
140 >>> replace('--textmode %?a?-u %a? %f', 'f', 'file.in')
141 '--textmode %?a?-u %a? file.in'
142 >>> replace('--textmode %?a?-u %a? %f', 'a', '0xHEXKEY')
143 '--textmode -u 0xHEXKEY %f'
144 >>> replace('--textmode %?a?-u %a? %f', 'a', '')
147 if replacement_text == None:
148 replacement_text = ""
149 regexp = re.compile('%[?]'+format_char+'[?]([^?]*)[?]')
150 if len(replacement_text) > 0:
151 str = regexp.sub('\g<1>', template)
153 str = regexp.sub('', template)
154 regexp = re.compile('%'+format_char)
155 str = regexp.sub(replacement_text, str)
160 Produce flat text output from an email Message instance.
164 g = Generator(fp, mangle_from_=False)
167 encoding = msg.get_content_charset()
168 return unicode(text, encoding=encoding)
170 def source_email(msg, return_realname=False):
172 Search the header of an email Message instance to find the
173 sender's email address.
175 froms = msg.get_all('from', [])
176 from_tuples = getaddresses(froms) # [(realname, email_address), ...]
177 assert len(from_tuples) == 1
178 if return_realname == True:
179 return from_tuples[0] # (realname, email_address)
180 return from_tuples[0][1] # email_address
182 def target_emails(msg):
184 Search the header of an email Message instance to find a
185 list of recipient's email addresses.
187 tos = msg.get_all('to', [])
188 ccs = msg.get_all('cc', [])
189 bccs = msg.get_all('bcc', [])
190 resent_tos = msg.get_all('resent-to', [])
191 resent_ccs = msg.get_all('resent-cc', [])
192 resent_bccs = msg.get_all('resent-bcc', [])
193 all_recipients = getaddresses(tos + ccs + bccs + resent_tos + resent_ccs + resent_bccs)
194 return [addr[1] for addr in all_recipients]
196 def mail(msg, sendmail=None):
198 Send an email Message instance on its merry way.
200 We can shell out to the user specified sendmail in case
201 the local host doesn't have an SMTP server set up
202 for easy smtplib usage.
205 execute(sendmail, stdin=flatten(msg))
209 s.sendmail(from_addr=source_email(msg),
210 to_addrs=target_emails(msg),
216 See http://www.ietf.org/rfc/rfc3156.txt for specification details.
217 >>> m = Mail('\\n'.join(['From: me@big.edu','To: you@big.edu','Subject: testing']), 'check 1 2\\ncheck 1 2\\n')
218 >>> print m.sourceEmail()
220 >>> print m.targetEmails()
222 >>> print flatten(m.clearBodyPart())
223 Content-Type: text/plain; charset="us-ascii"
225 Content-Transfer-Encoding: 7bit
226 Content-Disposition: inline
231 >>> print flatten(m.plain())
232 Content-Type: text/plain; charset="us-ascii"
234 Content-Transfer-Encoding: 7bit
243 >>> signed.set_boundary('boundsep')
244 >>> print m.stripSig(flatten(signed)).replace('\\t', ' '*4)
245 Content-Type: multipart/signed;
246 protocol="application/pgp-signature";
247 micalg="pgp-sha1"; boundary="boundsep"
252 Content-Disposition: inline
255 Content-Type: text/plain; charset="us-ascii"
257 Content-Transfer-Encoding: 7bit
258 Content-Type: text/plain
259 Content-Disposition: inline
266 Content-Transfer-Encoding: 7bit
267 Content-Description: signature
268 Content-Type: application/pgp-signature; name="signature.asc";
271 -----BEGIN PGP SIGNATURE-----
272 SIGNATURE STRIPPED (depends on current time)
273 -----END PGP SIGNATURE-----
276 >>> encrypted = m.encrypt()
277 >>> encrypted.set_boundary('boundsep')
278 >>> print m.stripPGP(flatten(encrypted)).replace('\\t', ' '*4)
279 Content-Type: multipart/encrypted;
280 protocol="application/pgp-encrypted";
281 micalg="pgp-sha1"; boundary="boundsep"
286 Content-Disposition: inline
289 Content-Type: application/pgp-encrypted
291 Content-Transfer-Encoding: 7bit
297 Content-Transfer-Encoding: 7bit
298 Content-Type: application/octet-stream; charset="us-ascii"
300 -----BEGIN PGP MESSAGE-----
301 MESSAGE STRIPPED (depends on current time)
302 -----END PGP MESSAGE-----
305 >>> signedAndEncrypted = m.signAndEncrypt()
306 >>> signedAndEncrypted.set_boundary('boundsep')
307 >>> print m.stripPGP(flatten(signedAndEncrypted)).replace('\\t', ' '*4)
308 Content-Type: multipart/encrypted;
309 protocol="application/pgp-encrypted";
310 micalg="pgp-sha1"; boundary="boundsep"
315 Content-Disposition: inline
318 Content-Type: application/pgp-encrypted
320 Content-Transfer-Encoding: 7bit
326 Content-Transfer-Encoding: 7bit
327 Content-Type: application/octet-stream; charset="us-ascii"
329 -----BEGIN PGP MESSAGE-----
330 MESSAGE STRIPPED (depends on current time)
331 -----END PGP MESSAGE-----
335 def __init__(self, header, body):
336 self.header = header.strip()
338 if type(self.header) == types.UnicodeType:
339 self.header = self.header.encode("ascii")
341 self.headermsg = p.parsestr(self.header, headersonly=True)
342 def sourceEmail(self):
343 return source_email(self.headermsg)
344 def targetEmails(self):
345 return target_emails(self.headermsg)
346 def encodedMIMEText(self, body, encoding=None):
348 if type(body) == types.StringType:
349 encoding = "US-ASCII"
350 elif type(body) == types.UnicodeType:
351 for encoding in ["US-ASCII", "ISO-8859-1", "UTF-8"]:
353 body.encode(encoding)
358 assert encoding != None
359 # Create the message ('plain' stands for Content-Type: text/plain)
360 if encoding == "US-ASCII":
361 return MIMEText(body)
363 return MIMEText(body.encode(encoding), 'plain', encoding)
364 def clearBodyPart(self):
365 body = self.encodedMIMEText(self.body)
366 body.add_header('Content-Disposition', 'inline')
368 def passphrase_arg(self, passphrase=None):
369 if passphrase == None and PASSPHRASE != None:
370 passphrase = PASSPHRASE
371 if passphrase == None:
373 return (passphrase, pgp_stdin_passphrase_arg)
378 msg = self.encodedMIMEText(self.body)
379 for k,v in self.headermsg.items():
382 def sign(self, passphrase=None):
385 +-> text/plain (body)
386 +-> application/pgp-signature (signature)
388 passphrase,pass_arg = self.passphrase_arg(passphrase)
389 body = self.clearBodyPart()
390 bfile = tempfile.NamedTemporaryFile()
391 bfile.write(flatten(body))
394 args = replace(pgp_sign_command, 'f', bfile.name)
395 if PGP_SIGN_AS == None:
396 pgp_sign_as = '<%s>' % self.sourceEmail()
398 pgp_sign_as = PGP_SIGN_AS
399 args = replace(args, 'a', pgp_sign_as)
400 args = replace(args, 'p', pass_arg)
401 status,output,error = execute(args, stdin=passphrase)
404 sig = MIMEApplication(_data=signature, _subtype='pgp-signature; name="signature.asc"', _encoder=encode_7or8bit)
405 sig['Content-Description'] = 'signature'
406 sig.set_charset('us-ascii')
408 msg = MIMEMultipart('signed', micalg='pgp-sha1', protocol='application/pgp-signature')
412 for k,v in self.headermsg.items():
414 msg['Content-Disposition'] = 'inline'
416 def encrypt(self, passphrase=None):
419 +-> application/pgp-encrypted (control information)
420 +-> application/octet-stream (body)
422 body = self.clearBodyPart()
423 bfile = tempfile.NamedTemporaryFile()
424 bfile.write(flatten(body))
427 recipient_string = ' '.join([replace(pgp_recipient_arg, 'r', recipient) for recipient in self.targetEmails()])
428 args = replace(pgp_encrypt_only_command, 'R', recipient_string)
429 args = replace(args, 'f', bfile.name)
430 if PGP_SIGN_AS == None:
431 pgp_sign_as = '<%s>' % self.sourceEmail()
433 pgp_sign_as = PGP_SIGN_AS
434 args = replace(args, 'a', pgp_sign_as)
435 status,output,error = execute(args)
438 enc = MIMEApplication(_data=encrypted, _subtype='octet-stream', _encoder=encode_7or8bit)
439 enc.set_charset('us-ascii')
441 control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted', _encoder=encode_7or8bit)
443 msg = MIMEMultipart('encrypted', micalg='pgp-sha1', protocol='application/pgp-encrypted')
447 for k,v in self.headermsg.items():
449 msg['Content-Disposition'] = 'inline'
451 def signAndEncrypt(self, passphrase=None):
454 +-> application/pgp-encrypted (control information)
455 +-> application/octet-stream (body)
457 passphrase,pass_arg = self.passphrase_arg(passphrase)
459 body.__delitem__('Bcc')
460 bfile = tempfile.NamedTemporaryFile()
461 bfile.write(flatten(body))
464 recipient_string = ' '.join([replace(pgp_recipient_arg, 'r', recipient) for recipient in self.targetEmails()])
465 args = replace(pgp_encrypt_only_command, 'R', recipient_string)
466 args = replace(args, 'f', bfile.name)
467 if PGP_SIGN_AS == None:
468 pgp_sign_as = '<%s>' % self.sourceEmail()
470 pgp_sign_as = PGP_SIGN_AS
471 args = replace(args, 'a', pgp_sign_as)
472 args = replace(args, 'p', pass_arg)
473 status,output,error = execute(args, stdin=passphrase)
476 enc = MIMEApplication(_data=encrypted, _subtype='octet-stream', _encoder=encode_7or8bit)
477 enc.set_charset('us-ascii')
479 control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted', _encoder=encode_7or8bit)
481 msg = MIMEMultipart('encrypted', micalg='pgp-sha1', protocol='application/pgp-encrypted')
485 for k,v in self.headermsg.items():
487 msg['Content-Disposition'] = 'inline'
489 def stripChanging(self, text, start, stop, replacement):
492 for line in text.splitlines():
494 if stripping == False:
498 lines.append(replacement)
503 return '\n'.join(lines)
504 def stripSig(self, text):
505 return self.stripChanging(text,
506 '-----BEGIN PGP SIGNATURE-----',
507 '-----END PGP SIGNATURE-----',
508 'SIGNATURE STRIPPED (depends on current time)')
509 def stripPGP(self, text):
510 return self.stripChanging(text,
511 '-----BEGIN PGP MESSAGE-----',
512 '-----END PGP MESSAGE-----',
513 'MESSAGE STRIPPED (depends on current time)')
520 if __name__ == '__main__':
521 from optparse import OptionParser
523 parser = OptionParser(usage=usage)
524 parser.add_option('-t', '--test', dest='test', action='store_true',
525 help='Run doctests and exit')
527 parser.add_option('-H', '--header-file', dest='header_filename',
528 help='file containing email header', metavar='FILE')
529 parser.add_option('-B', '--body-file', dest='body_filename',
530 help='file containing email body', metavar='FILE')
532 parser.add_option('-P', '--passphrase-file', dest='passphrase_file',
533 help='file containing gpg passphrase', metavar='FILE')
534 parser.add_option('-p', '--passphrase-fd', dest='passphrase_fd',
535 help='file descriptor from which to read gpg passphrase (0 for stdin)',
536 type="int", metavar='DESCRIPTOR')
538 parser.add_option('--mode', dest='mode', default='sign',
539 help="One of 'sign', 'encrypt', 'sign-encrypt', or 'plain'. Defaults to %default.",
542 parser.add_option('-a', '--sign-as', dest='sign_as',
543 help="The gpg key to sign with (gpg's -u/--local-user)",
546 parser.add_option('--output', dest='output', action='store_true',
547 help="Don't mail the generated message, print it to stdout instead.")
549 (options, args) = parser.parse_args()
553 if options.passphrase_file != None:
554 PASSPHRASE = file(options.passphrase_file, 'r').read()
555 elif options.passphrase_fd != None:
556 if options.passphrase_fd == 0:
558 PASSPHRASE = sys.stdin.read()
560 PASSPHRASE = os.read(options.passphrase_fd)
563 PGP_SIGN_AS = options.sign_as
565 if options.test == True:
570 if options.header_filename != None:
571 if options.header_filename == '-':
572 assert stdin_used == False
574 header = sys.stdin.read()
576 header = file(options.header_filename, 'r').read()
578 raise Exception, "missing header"
580 if options.body_filename != None:
581 if options.body_filename == '-':
582 assert stdin_used == False
584 body = sys.stdin.read()
586 body = file(options.body_filename, 'r').read()
588 raise Exception, "missing body"
590 m = Mail(header, body)
591 if options.mode == "sign":
593 elif options.mode == "encrypt":
594 message = m.encrypt()
595 elif options.mode == "sign-encrypt":
596 message = m.signAndEncrypt()
597 elif options.mode == "plain":
600 print "Unrecognized mode '%s'" % options.mode
602 if options.output == True:
603 message = flatten(message)
606 mail(message, sendmail)