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
37 from email.mime.text import MIMEText
38 from email.mime.multipart import MIMEMultipart
39 from email.mime.application import MIMEApplication
40 from email.generator import Generator
41 from email.encoders import encode_7or8bit
42 from email.utils import getaddress
43 from email import message_from_string
45 # adjust to old python 2.4
46 from email.MIMEText import MIMEText
47 from email.MIMEMultipart import MIMEMultipart
48 from email.MIMENonMultipart import MIMENonMultipart
49 from email.Generator import Generator
50 from email.Encoders import encode_7or8bit
51 from email.Utils import getaddresses
52 from email import message_from_string
54 getaddress = getaddresses
55 class MIMEApplication (MIMENonMultipart):
56 def __init__(self, _data, _subtype, _encoder, **params):
57 MIMENonMultipart.__init__(self, 'application', _subtype, **params)
58 self.set_payload(_data)
61 usage="""usage: %prog [options]
63 Scriptable PGP MIME email using gpg.
65 You can use gpg-agent for passphrase caching if your key requires a
66 passphrase (it better!). Example usage would be to install gpg-agent,
69 eval $(gpg-agent --daemon)
70 in your shell before invoking this script. See gpg-agent(1) for more
71 details. Alternatively, you can send your passphrase in on stdin
72 echo 'passphrase' | %prog [options]
73 or use the --passphrase-file option
74 %prog [options] --passphrase-file FILE [more options]
75 Both of these alternatives are much less secure than gpg-agent. You
83 # The following commands are adapted from my .mutt/pgp configuration
85 # Printf-like sequences:
86 # %a The value of PGP_SIGN_AS.
87 # %f Expands to the name of a file with text to be signed/encrypted.
88 # %p Expands to the passphrase argument.
89 # %R A string with some number (0 on up) of pgp_reciepient_arg
91 # %r One key ID (e.g. recipient email address) to build a
92 # pgp_reciepient_arg string.
94 # The above sequences can be used to optionally print a string if
95 # their length is nonzero. For example, you may only want to pass the
96 # -u/--local-user argument to gpg if PGP_SIGN_AS is defined. To
97 # optionally print a string based upon one of the above sequences, the
98 # following construct is used
99 # %?<sequence_char>?<optional_string>?
100 # where sequence_char is a character from the table above, and
101 # optional_string is the string you would like printed if status_char
102 # is nonzero. optional_string may contain other sequence as well as
103 # normal text, but it may not contain any question marks.
105 # see http://codesorcery.net/old/mutt/mutt-gnupg-howto
106 # http://www.mutt.org/doc/manual/manual-6.html#pgp_autosign
107 # http://tldp.org/HOWTO/Mutt-GnuPG-PGP-HOWTO-8.html
110 pgp_recipient_arg='-r "%r"'
111 pgp_stdin_passphrase_arg='--passphrase-fd 0'
112 pgp_sign_command='/usr/bin/gpg --no-verbose --quiet --batch %p --output - --detach-sign --armor --textmode %?a?-u "%a"? %f'
113 pgp_encrypt_only_command='/usr/bin/gpg --no-verbose --quiet --batch --output - --encrypt --armor --textmode --always-trust --encrypt-to "%a" %R -- %f'
114 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'
115 sendmail='/usr/sbin/sendmail -t'
117 def execute(args, stdin=None, expect=(0,)):
119 Execute a command (allows us to drive gpg).
121 if verboseInvoke == True:
122 print >> sys.stderr, '$ '+args
124 p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, close_fds=True)
126 strerror = '%s\nwhile executing %s' % (e.args[1], args)
127 raise Exception, strerror
128 output, error = p.communicate(input=stdin)
130 if verboseInvoke == True:
131 print >> sys.stderr, '(status: %d)\n%s%s' % (status, output, error)
132 if status not in expect:
133 strerror = '%s\nwhile executing %s\n%s\n%d' % (args[1], args, error, status)
134 raise Exception, strerror
135 return status, output, error
137 def replace(template, format_char, replacement_text):
139 >>> replace('--textmode %?a?-u %a? %f', 'f', 'file.in')
140 '--textmode %?a?-u %a? file.in'
141 >>> replace('--textmode %?a?-u %a? %f', 'a', '0xHEXKEY')
142 '--textmode -u 0xHEXKEY %f'
143 >>> replace('--textmode %?a?-u %a? %f', 'a', '')
146 if replacement_text == None:
147 replacement_text = ""
148 regexp = re.compile('%[?]'+format_char+'[?]([^?]*)[?]')
149 if len(replacement_text) > 0:
150 str = regexp.sub('\g<1>', template)
152 str = regexp.sub('', template)
153 regexp = re.compile('%'+format_char)
154 str = regexp.sub(replacement_text, str)
159 Produce flat text output from an email Message instance.
163 g = Generator(fp, mangle_from_=False)
168 def source_email(msg):
170 Search the header of an email Message instance to find the
171 sender's email address.
173 froms = msg.get_all('from', [])
174 from_tuples = getaddresses(froms) # [(realname, email_address), ...]
175 assert len(from_tuples) == 1
176 return [addr[1] for addr in from_tuples][0]
178 def target_emails(msg):
180 Search the header of an email Message instance to find a
181 list of recipient's email addresses.
183 tos = msg.get_all('to', [])
184 ccs = msg.get_all('cc', [])
185 bccs = msg.get_all('bcc', [])
186 resent_tos = msg.get_all('resent-to', [])
187 resent_ccs = msg.get_all('resent-cc', [])
188 resent_bccs = msg.get_all('resent-bcc', [])
189 all_recipients = getaddresses(tos + ccs + bccs + resent_tos + resent_ccs + resent_bccs)
190 return [addr[1] for addr in all_recipients]
192 def mail(msg, sendmail=None):
194 Send an email Message instance on its merry way.
196 We can shell out to the user specified sendmail in case
197 the local host doesn't have an SMTP server set up
198 for easy smtplib usage.
201 execute(sendmail, stdin=flatten(msg))
205 s.sendmail(from_addr=source_email(msg),
206 to_addrs=target_emails(msg),
212 See http://www.ietf.org/rfc/rfc3156.txt for specification details.
213 >>> m = Mail('\\n'.join(['From: me@big.edu','To: you@big.edu','Subject: testing']), 'check 1 2\\ncheck 1 2\\n')
214 >>> print m.sourceEmail()
216 >>> print m.targetEmails()
218 >>> print flatten(m.clearBodyPart())
219 Content-Type: text/plain; charset="us-ascii"
221 Content-Transfer-Encoding: 7bit
222 Content-Type: text/plain
223 Content-Disposition: inline
228 >>> signed = m.sign()
229 >>> signed.set_boundary('boundsep')
230 >>> print m.stripSig(flatten(signed)).replace('\\t', ' '*4)
231 Content-Type: multipart/signed;
232 protocol="application/pgp-signature";
233 micalg="pgp-sha1"; boundary="boundsep"
238 Content-Disposition: inline
241 Content-Type: text/plain; charset="us-ascii"
243 Content-Transfer-Encoding: 7bit
244 Content-Type: text/plain
245 Content-Disposition: inline
252 Content-Transfer-Encoding: 7bit
253 Content-Description: signature
254 Content-Type: application/pgp-signature; name="signature.asc";
257 -----BEGIN PGP SIGNATURE-----
258 SIGNATURE STRIPPED (depends on current time)
259 -----END PGP SIGNATURE-----
262 >>> encrypted = m.encrypt()
263 >>> encrypted.set_boundary('boundsep')
264 >>> print m.stripPGP(flatten(encrypted)).replace('\\t', ' '*4)
265 Content-Type: multipart/encrypted;
266 protocol="application/pgp-encrypted";
267 micalg="pgp-sha1"; boundary="boundsep"
272 Content-Disposition: inline
275 Content-Type: application/pgp-encrypted
277 Content-Transfer-Encoding: 7bit
283 Content-Transfer-Encoding: 7bit
284 Content-Type: application/octet-stream; charset="us-ascii"
286 -----BEGIN PGP MESSAGE-----
287 MESSAGE STRIPPED (depends on current time)
288 -----END PGP MESSAGE-----
291 >>> signedAndEncrypted = m.signAndEncrypt()
292 >>> signedAndEncrypted.set_boundary('boundsep')
293 >>> print m.stripPGP(flatten(signedAndEncrypted)).replace('\\t', ' '*4)
294 Content-Type: multipart/encrypted;
295 protocol="application/pgp-encrypted";
296 micalg="pgp-sha1"; boundary="boundsep"
301 Content-Disposition: inline
304 Content-Type: application/pgp-encrypted
306 Content-Transfer-Encoding: 7bit
312 Content-Transfer-Encoding: 7bit
313 Content-Type: application/octet-stream; charset="us-ascii"
315 -----BEGIN PGP MESSAGE-----
316 MESSAGE STRIPPED (depends on current time)
317 -----END PGP MESSAGE-----
321 def __init__(self, header, body):
325 self.headermsg = message_from_string(self.header)
326 def sourceEmail(self):
327 return source_email(self.headermsg)
328 def targetEmails(self):
329 return target_emails(self.headermsg)
330 def clearBodyPart(self):
331 body = MIMEText(self.body)
332 body.add_header('Content-Disposition', 'inline')
334 def passphrase_arg(self, passphrase=None):
335 if passphrase == None and PASSPHRASE != None:
336 passphrase = PASSPHRASE
337 if passphrase == None:
339 return (passphrase, pgp_stdin_passphrase_arg)
344 msg = MIMEText(self.body)
345 for k,v in self.headermsg.items():
348 def sign(self, passphrase=None):
351 +-> text/plain (body)
352 +-> application/pgp-signature (signature)
354 passphrase,pass_arg = self.passphrase_arg(passphrase)
355 body = self.clearBodyPart()
356 bfile = tempfile.NamedTemporaryFile()
357 bfile.write(flatten(body))
360 args = replace(pgp_sign_command, 'f', bfile.name)
361 if PGP_SIGN_AS == None:
362 pgp_sign_as = '<%s>' % self.sourceEmail()
364 pgp_sign_as = PGP_SIGN_AS
365 args = replace(args, 'a', pgp_sign_as)
366 args = replace(args, 'p', pass_arg)
367 status,output,error = execute(args, stdin=passphrase)
370 sig = MIMEApplication(_data=signature, _subtype='pgp-signature; name="signature.asc"', _encoder=encode_7or8bit)
371 sig['Content-Description'] = 'signature'
372 sig.set_charset('us-ascii')
374 msg = MIMEMultipart('signed', micalg='pgp-sha1', protocol='application/pgp-signature')
378 for k,v in self.headermsg.items():
380 msg['Content-Disposition'] = 'inline'
382 def encrypt(self, passphrase=None):
385 +-> application/pgp-encrypted (control information)
386 +-> application/octet-stream (body)
388 body = self.clearBodyPart()
389 bfile = tempfile.NamedTemporaryFile()
390 bfile.write(flatten(body))
393 recipient_string = ' '.join([replace(pgp_recipient_arg, 'r', recipient) for recipient in self.targetEmails()])
394 args = replace(pgp_encrypt_only_command, 'R', recipient_string)
395 args = replace(args, 'f', bfile.name)
396 if PGP_SIGN_AS == None:
397 pgp_sign_as = '<%s>' % self.sourceEmail()
399 pgp_sign_as = PGP_SIGN_AS
400 args = replace(args, 'a', pgp_sign_as)
401 status,output,error = execute(args)
404 enc = MIMEApplication(_data=encrypted, _subtype='octet-stream', _encoder=encode_7or8bit)
405 enc.set_charset('us-ascii')
407 control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted', _encoder=encode_7or8bit)
409 msg = MIMEMultipart('encrypted', micalg='pgp-sha1', protocol='application/pgp-encrypted')
413 for k,v in self.headermsg.items():
415 msg['Content-Disposition'] = 'inline'
417 def signAndEncrypt(self, passphrase=None):
420 +-> application/pgp-encrypted (control information)
421 +-> application/octet-stream (body)
423 passphrase,pass_arg = self.passphrase_arg(passphrase)
425 body.__delitem__('Bcc')
426 bfile = tempfile.NamedTemporaryFile()
427 bfile.write(flatten(body))
430 recipient_string = ' '.join([replace(pgp_recipient_arg, 'r', recipient) for recipient in self.targetEmails()])
431 args = replace(pgp_encrypt_only_command, 'R', recipient_string)
432 args = replace(args, 'f', bfile.name)
433 if PGP_SIGN_AS == None:
434 pgp_sign_as = '<%s>' % self.sourceEmail()
436 pgp_sign_as = PGP_SIGN_AS
437 args = replace(args, 'a', pgp_sign_as)
438 args = replace(args, 'p', pass_arg)
439 status,output,error = execute(args, stdin=passphrase)
442 enc = MIMEApplication(_data=encrypted, _subtype='octet-stream', _encoder=encode_7or8bit)
443 enc.set_charset('us-ascii')
445 control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted', _encoder=encode_7or8bit)
447 msg = MIMEMultipart('encrypted', micalg='pgp-sha1', protocol='application/pgp-encrypted')
451 for k,v in self.headermsg.items():
453 msg['Content-Disposition'] = 'inline'
455 def stripChanging(self, text, start, stop, replacement):
458 for line in text.splitlines():
460 if stripping == False:
464 lines.append(replacement)
469 return '\n'.join(lines)
470 def stripSig(self, text):
471 return self.stripChanging(text,
472 '-----BEGIN PGP SIGNATURE-----',
473 '-----END PGP SIGNATURE-----',
474 'SIGNATURE STRIPPED (depends on current time)')
475 def stripPGP(self, text):
476 return self.stripChanging(text,
477 '-----BEGIN PGP MESSAGE-----',
478 '-----END PGP MESSAGE-----',
479 'MESSAGE STRIPPED (depends on current time)')
486 if __name__ == '__main__':
487 from optparse import OptionParser
489 parser = OptionParser(usage=usage)
490 parser.add_option('-t', '--test', dest='test', action='store_true',
491 help='Run doctests and exit')
493 parser.add_option('-H', '--header-file', dest='header_filename',
494 help='file containing email header', metavar='FILE')
495 parser.add_option('-B', '--body-file', dest='body_filename',
496 help='file containing email body', metavar='FILE')
498 parser.add_option('-P', '--passphrase-file', dest='passphrase_file',
499 help='file containing gpg passphrase', metavar='FILE')
500 parser.add_option('-p', '--passphrase-fd', dest='passphrase_fd',
501 help='file descriptor from which to read gpg passphrase (0 for stdin)',
502 type="int", metavar='DESCRIPTOR')
504 parser.add_option('--mode', dest='mode', default='sign',
505 help="One of 'sign', 'encrypt', 'sign-encrypt', or 'plain'. Defaults to %default.",
508 parser.add_option('-a', '--sign-as', dest='sign_as',
509 help="The gpg key to sign with (gpg's -u/--local-user)",
512 parser.add_option('--output', dest='output', action='store_true',
513 help="Don't mail the generated message, print it to stdout instead.")
515 (options, args) = parser.parse_args()
519 if options.passphrase_file != None:
520 PASSPHRASE = file(options.passphrase_file, 'r').read()
521 elif options.passphrase_fd != None:
522 if options.passphrase_fd == 0:
524 PASSPHRASE = sys.stdin.read()
526 PASSPHRASE = os.read(options.passphrase_fd)
529 PGP_SIGN_AS = options.sign_as
531 if options.test == True:
536 if options.header_filename != None:
537 if options.header_filename == '-':
538 assert stdin_used == False
540 header = sys.stdin.read()
542 header = file(options.header_filename, 'r').read()
544 raise Exception, "missing header"
546 if options.body_filename != None:
547 if options.body_filename == '-':
548 assert stdin_used == False
550 body = sys.stdin.read()
552 body = file(options.body_filename, 'r').read()
554 raise Exception, "missing body"
556 m = Mail(header, body)
557 if options.mode == "sign":
559 elif options.mode == "encrypt":
560 message = m.encrypt()
561 elif options.mode == "sign-encrypt":
562 message = m.signAndEncrypt()
563 elif options.mode == "plain":
566 print "Unrecognized mode '%s'" % options.mode
568 if options.output == True:
569 message = flatten(message)
572 mail(message, sendmail)