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)
158 def flatten(msg, to_unicode=False):
160 Produce flat text output from an email Message instance.
164 g = Generator(fp, mangle_from_=False)
167 if to_unicode == True:
168 encoding = msg.get_content_charset()
169 text = unicode(text, encoding=encoding)
172 def source_email(msg, return_realname=False):
174 Search the header of an email Message instance to find the
175 sender's email address.
177 froms = msg.get_all('from', [])
178 from_tuples = getaddresses(froms) # [(realname, email_address), ...]
179 assert len(from_tuples) == 1
180 if return_realname == True:
181 return from_tuples[0] # (realname, email_address)
182 return from_tuples[0][1] # email_address
184 def target_emails(msg):
186 Search the header of an email Message instance to find a
187 list of recipient's email addresses.
189 tos = msg.get_all('to', [])
190 ccs = msg.get_all('cc', [])
191 bccs = msg.get_all('bcc', [])
192 resent_tos = msg.get_all('resent-to', [])
193 resent_ccs = msg.get_all('resent-cc', [])
194 resent_bccs = msg.get_all('resent-bcc', [])
195 all_recipients = getaddresses(tos + ccs + bccs + resent_tos + resent_ccs + resent_bccs)
196 return [addr[1] for addr in all_recipients]
198 def mail(msg, sendmail=None):
200 Send an email Message instance on its merry way.
202 We can shell out to the user specified sendmail in case
203 the local host doesn't have an SMTP server set up
204 for easy smtplib usage.
207 execute(sendmail, stdin=flatten(msg))
211 s.sendmail(from_addr=source_email(msg),
212 to_addrs=target_emails(msg),
218 See http://www.ietf.org/rfc/rfc3156.txt for specification details.
219 >>> m = Mail('\\n'.join(['From: me@big.edu','To: you@big.edu','Subject: testing']), 'check 1 2\\ncheck 1 2\\n')
220 >>> print m.sourceEmail()
222 >>> print m.targetEmails()
224 >>> print flatten(m.clearBodyPart())
225 Content-Type: text/plain; charset="us-ascii"
227 Content-Transfer-Encoding: 7bit
228 Content-Disposition: inline
233 >>> print flatten(m.plain())
234 Content-Type: text/plain; charset="us-ascii"
236 Content-Transfer-Encoding: 7bit
245 >>> signed.set_boundary('boundsep')
246 >>> print m.stripSig(flatten(signed)).replace('\\t', ' '*4)
247 Content-Type: multipart/signed;
248 protocol="application/pgp-signature";
249 micalg="pgp-sha1"; boundary="boundsep"
254 Content-Disposition: inline
257 Content-Type: text/plain; charset="us-ascii"
259 Content-Transfer-Encoding: 7bit
260 Content-Type: text/plain
261 Content-Disposition: inline
268 Content-Transfer-Encoding: 7bit
269 Content-Description: signature
270 Content-Type: application/pgp-signature; name="signature.asc";
273 -----BEGIN PGP SIGNATURE-----
274 SIGNATURE STRIPPED (depends on current time)
275 -----END PGP SIGNATURE-----
278 >>> encrypted = m.encrypt()
279 >>> encrypted.set_boundary('boundsep')
280 >>> print m.stripPGP(flatten(encrypted)).replace('\\t', ' '*4)
281 Content-Type: multipart/encrypted;
282 protocol="application/pgp-encrypted";
283 micalg="pgp-sha1"; boundary="boundsep"
288 Content-Disposition: inline
291 Content-Type: application/pgp-encrypted
293 Content-Transfer-Encoding: 7bit
299 Content-Transfer-Encoding: 7bit
300 Content-Type: application/octet-stream; charset="us-ascii"
302 -----BEGIN PGP MESSAGE-----
303 MESSAGE STRIPPED (depends on current time)
304 -----END PGP MESSAGE-----
307 >>> signedAndEncrypted = m.signAndEncrypt()
308 >>> signedAndEncrypted.set_boundary('boundsep')
309 >>> print m.stripPGP(flatten(signedAndEncrypted)).replace('\\t', ' '*4)
310 Content-Type: multipart/encrypted;
311 protocol="application/pgp-encrypted";
312 micalg="pgp-sha1"; boundary="boundsep"
317 Content-Disposition: inline
320 Content-Type: application/pgp-encrypted
322 Content-Transfer-Encoding: 7bit
328 Content-Transfer-Encoding: 7bit
329 Content-Type: application/octet-stream; charset="us-ascii"
331 -----BEGIN PGP MESSAGE-----
332 MESSAGE STRIPPED (depends on current time)
333 -----END PGP MESSAGE-----
337 def __init__(self, header, body):
338 self.header = header.strip()
340 if type(self.header) == types.UnicodeType:
341 self.header = self.header.encode("ascii")
343 self.headermsg = p.parsestr(self.header, headersonly=True)
344 def sourceEmail(self):
345 return source_email(self.headermsg)
346 def targetEmails(self):
347 return target_emails(self.headermsg)
348 def encodedMIMEText(self, body, encoding=None):
350 if type(body) == types.StringType:
351 encoding = "US-ASCII"
352 elif type(body) == types.UnicodeType:
353 for encoding in ["US-ASCII", "ISO-8859-1", "UTF-8"]:
355 body.encode(encoding)
360 assert encoding != None
361 # Create the message ('plain' stands for Content-Type: text/plain)
362 if encoding == "US-ASCII":
363 return MIMEText(body)
365 return MIMEText(body.encode(encoding), 'plain', encoding)
366 def clearBodyPart(self):
367 body = self.encodedMIMEText(self.body)
368 body.add_header('Content-Disposition', 'inline')
370 def passphrase_arg(self, passphrase=None):
371 if passphrase == None and PASSPHRASE != None:
372 passphrase = PASSPHRASE
373 if passphrase == None:
375 return (passphrase, pgp_stdin_passphrase_arg)
380 msg = self.encodedMIMEText(self.body)
381 for k,v in self.headermsg.items():
384 def sign(self, passphrase=None):
387 +-> text/plain (body)
388 +-> application/pgp-signature (signature)
390 passphrase,pass_arg = self.passphrase_arg(passphrase)
391 body = self.clearBodyPart()
392 bfile = tempfile.NamedTemporaryFile()
393 bfile.write(flatten(body))
396 args = replace(pgp_sign_command, 'f', bfile.name)
397 if PGP_SIGN_AS == None:
398 pgp_sign_as = '<%s>' % self.sourceEmail()
400 pgp_sign_as = PGP_SIGN_AS
401 args = replace(args, 'a', pgp_sign_as)
402 args = replace(args, 'p', pass_arg)
403 status,output,error = execute(args, stdin=passphrase)
406 sig = MIMEApplication(_data=signature, _subtype='pgp-signature; name="signature.asc"', _encoder=encode_7or8bit)
407 sig['Content-Description'] = 'signature'
408 sig.set_charset('us-ascii')
410 msg = MIMEMultipart('signed', micalg='pgp-sha1', protocol='application/pgp-signature')
414 for k,v in self.headermsg.items():
416 msg['Content-Disposition'] = 'inline'
418 def encrypt(self, passphrase=None):
421 +-> application/pgp-encrypted (control information)
422 +-> application/octet-stream (body)
424 body = self.clearBodyPart()
425 bfile = tempfile.NamedTemporaryFile()
426 bfile.write(flatten(body))
429 recipient_string = ' '.join([replace(pgp_recipient_arg, 'r', recipient) for recipient in self.targetEmails()])
430 args = replace(pgp_encrypt_only_command, 'R', recipient_string)
431 args = replace(args, 'f', bfile.name)
432 if PGP_SIGN_AS == None:
433 pgp_sign_as = '<%s>' % self.sourceEmail()
435 pgp_sign_as = PGP_SIGN_AS
436 args = replace(args, 'a', pgp_sign_as)
437 status,output,error = execute(args)
440 enc = MIMEApplication(_data=encrypted, _subtype='octet-stream', _encoder=encode_7or8bit)
441 enc.set_charset('us-ascii')
443 control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted', _encoder=encode_7or8bit)
445 msg = MIMEMultipart('encrypted', micalg='pgp-sha1', protocol='application/pgp-encrypted')
449 for k,v in self.headermsg.items():
451 msg['Content-Disposition'] = 'inline'
453 def signAndEncrypt(self, passphrase=None):
456 +-> application/pgp-encrypted (control information)
457 +-> application/octet-stream (body)
459 passphrase,pass_arg = self.passphrase_arg(passphrase)
461 body.__delitem__('Bcc')
462 bfile = tempfile.NamedTemporaryFile()
463 bfile.write(flatten(body))
466 recipient_string = ' '.join([replace(pgp_recipient_arg, 'r', recipient) for recipient in self.targetEmails()])
467 args = replace(pgp_encrypt_only_command, 'R', recipient_string)
468 args = replace(args, 'f', bfile.name)
469 if PGP_SIGN_AS == None:
470 pgp_sign_as = '<%s>' % self.sourceEmail()
472 pgp_sign_as = PGP_SIGN_AS
473 args = replace(args, 'a', pgp_sign_as)
474 args = replace(args, 'p', pass_arg)
475 status,output,error = execute(args, stdin=passphrase)
478 enc = MIMEApplication(_data=encrypted, _subtype='octet-stream', _encoder=encode_7or8bit)
479 enc.set_charset('us-ascii')
481 control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted', _encoder=encode_7or8bit)
483 msg = MIMEMultipart('encrypted', micalg='pgp-sha1', protocol='application/pgp-encrypted')
487 for k,v in self.headermsg.items():
489 msg['Content-Disposition'] = 'inline'
491 def stripChanging(self, text, start, stop, replacement):
494 for line in text.splitlines():
496 if stripping == False:
500 lines.append(replacement)
505 return '\n'.join(lines)
506 def stripSig(self, text):
507 return self.stripChanging(text,
508 '-----BEGIN PGP SIGNATURE-----',
509 '-----END PGP SIGNATURE-----',
510 'SIGNATURE STRIPPED (depends on current time)')
511 def stripPGP(self, text):
512 return self.stripChanging(text,
513 '-----BEGIN PGP MESSAGE-----',
514 '-----END PGP MESSAGE-----',
515 'MESSAGE STRIPPED (depends on current time)')
522 if __name__ == '__main__':
523 from optparse import OptionParser
525 parser = OptionParser(usage=usage)
526 parser.add_option('-t', '--test', dest='test', action='store_true',
527 help='Run doctests and exit')
529 parser.add_option('-H', '--header-file', dest='header_filename',
530 help='file containing email header', metavar='FILE')
531 parser.add_option('-B', '--body-file', dest='body_filename',
532 help='file containing email body', metavar='FILE')
534 parser.add_option('-P', '--passphrase-file', dest='passphrase_file',
535 help='file containing gpg passphrase', metavar='FILE')
536 parser.add_option('-p', '--passphrase-fd', dest='passphrase_fd',
537 help='file descriptor from which to read gpg passphrase (0 for stdin)',
538 type="int", metavar='DESCRIPTOR')
540 parser.add_option('--mode', dest='mode', default='sign',
541 help="One of 'sign', 'encrypt', 'sign-encrypt', or 'plain'. Defaults to %default.",
544 parser.add_option('-a', '--sign-as', dest='sign_as',
545 help="The gpg key to sign with (gpg's -u/--local-user)",
548 parser.add_option('--output', dest='output', action='store_true',
549 help="Don't mail the generated message, print it to stdout instead.")
551 (options, args) = parser.parse_args()
555 if options.passphrase_file != None:
556 PASSPHRASE = file(options.passphrase_file, 'r').read()
557 elif options.passphrase_fd != None:
558 if options.passphrase_fd == 0:
560 PASSPHRASE = sys.stdin.read()
562 PASSPHRASE = os.read(options.passphrase_fd)
565 PGP_SIGN_AS = options.sign_as
567 if options.test == True:
572 if options.header_filename != None:
573 if options.header_filename == '-':
574 assert stdin_used == False
576 header = sys.stdin.read()
578 header = file(options.header_filename, 'r').read()
580 raise Exception, "missing header"
582 if options.body_filename != None:
583 if options.body_filename == '-':
584 assert stdin_used == False
586 body = sys.stdin.read()
588 body = file(options.body_filename, 'r').read()
590 raise Exception, "missing body"
592 m = Mail(header, body)
593 if options.mode == "sign":
595 elif options.mode == "encrypt":
596 message = m.encrypt()
597 elif options.mode == "sign-encrypt":
598 message = m.signAndEncrypt()
599 elif options.mode == "plain":
602 print "Unrecognized mode '%s'" % options.mode
604 if options.output == True:
605 message = flatten(message)
608 mail(message, sendmail)