Minor tweaks in send_pgp_mime.py
[pgp-mime.git] / interfaces / email / interactive / send_pgp_mime.py
1 #!/usr/bin/python
2 #
3 # Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
4 #
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.
9 #
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.
14 #
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.
18 """
19 Python module and command line tool for sending pgp/mime email.
20
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.
25 """
26
27 from cStringIO import StringIO
28 import os
29 import re
30 #import GnuPGInterface # Maybe should use this instead of subprocess
31 import smtplib
32 import subprocess
33 import sys
34 import tempfile
35
36 try:
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
44 except ImportError:
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
53     
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)
59             _encoder(self)
60
61 usage="""usage: %prog [options]
62
63 Scriptable PGP MIME email using gpg.
64
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,
67 and then run
68   export GPG_TTY=`tty`
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
76 have been warned.
77 """
78
79 verboseInvoke = False
80 PGP_SIGN_AS = None
81 PASSPHRASE = None
82
83 # The following commands are adapted from my .mutt/pgp configuration
84
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
90 #      strings.
91 #   %r One key ID (e.g. recipient email address) to build a
92 #      pgp_reciepient_arg string.
93
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.
104 #
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
108 # for more details
109
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'
116
117 def execute(args, stdin=None, expect=(0,)):
118     """
119     Execute a command (allows us to drive gpg).
120     """
121     if verboseInvoke == True:
122         print >> sys.stderr, '$ '+args
123     try:
124         p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, close_fds=True)
125     except OSError, e:
126         strerror = '%s\nwhile executing %s' % (e.args[1], args)
127         raise Exception, strerror
128     output, error = p.communicate(input=stdin)
129     status = p.wait()
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
136
137 def replace(template, format_char, replacement_text):
138     """
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', '')
144     '--textmode  %f'
145     """
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)
151     else:
152         str = regexp.sub('', template)
153     regexp = re.compile('%'+format_char)
154     str = regexp.sub(replacement_text, str)
155     return str
156
157 def flatten(msg):
158     """
159     Produce flat text output from an email Message instance.
160     """
161     assert msg != None
162     fp = StringIO()
163     g = Generator(fp, mangle_from_=False)
164     g.flatten(msg)
165     text = fp.getvalue()
166     return text    
167
168 def source_email(msg):
169     """
170     Search the header of an email Message instance to find the
171     sender's email address.
172     """
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]
177
178 def target_emails(msg):
179     """
180     Search the header of an email Message instance to find a
181     list of recipient's email addresses.
182     """
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]
191
192 def mail(msg, sendmail=None):
193     """
194     Send an email Message instance on its merry way.
195     
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.
199     """
200     if sendmail != None:
201         execute(sendmail, stdin=flatten(msg))
202         return None
203     s = smtplib.SMTP()
204     s.connect()
205     s.sendmail(from_addr=source_email(msg),
206                to_addrs=target_emails(msg),
207                msg=flatten(msg))
208     s.close()
209
210 class Mail (object):
211     """
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()
215     me@big.edu
216     >>> print m.targetEmails()
217     ['you@big.edu']
218     >>> print flatten(m.clearBodyPart())
219     Content-Type: text/plain; charset="us-ascii"
220     MIME-Version: 1.0
221     Content-Transfer-Encoding: 7bit
222     Content-Type: text/plain
223     Content-Disposition: inline
224     <BLANKLINE>
225     check 1 2
226     check 1 2
227     <BLANKLINE>
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"
234     MIME-Version: 1.0
235     From: me@big.edu
236     To: you@big.edu
237     Subject: testing
238     Content-Disposition: inline
239     <BLANKLINE>
240     --boundsep
241     Content-Type: text/plain; charset="us-ascii"
242     MIME-Version: 1.0
243     Content-Transfer-Encoding: 7bit
244     Content-Type: text/plain
245     Content-Disposition: inline
246     <BLANKLINE>
247     check 1 2
248     check 1 2
249     <BLANKLINE>
250     --boundsep
251     MIME-Version: 1.0
252     Content-Transfer-Encoding: 7bit
253     Content-Description: signature
254     Content-Type: application/pgp-signature; name="signature.asc";
255         charset="us-ascii"
256     <BLANKLINE>
257     -----BEGIN PGP SIGNATURE-----
258     SIGNATURE STRIPPED (depends on current time)
259     -----END PGP SIGNATURE-----
260     <BLANKLINE>
261     --boundsep--
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"
268     MIME-Version: 1.0
269     From: me@big.edu
270     To: you@big.edu
271     Subject: testing
272     Content-Disposition: inline
273     <BLANKLINE>
274     --boundsep
275     Content-Type: application/pgp-encrypted
276     MIME-Version: 1.0
277     Content-Transfer-Encoding: 7bit
278     <BLANKLINE>
279     Version: 1
280     <BLANKLINE>
281     --boundsep
282     MIME-Version: 1.0
283     Content-Transfer-Encoding: 7bit
284     Content-Type: application/octet-stream; charset="us-ascii"
285     <BLANKLINE>
286     -----BEGIN PGP MESSAGE-----
287     MESSAGE STRIPPED (depends on current time)
288     -----END PGP MESSAGE-----
289     <BLANKLINE>
290     --boundsep--
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"
297     MIME-Version: 1.0
298     From: me@big.edu
299     To: you@big.edu
300     Subject: testing
301     Content-Disposition: inline
302     <BLANKLINE>
303     --boundsep
304     Content-Type: application/pgp-encrypted
305     MIME-Version: 1.0
306     Content-Transfer-Encoding: 7bit
307     <BLANKLINE>
308     Version: 1
309     <BLANKLINE>
310     --boundsep
311     MIME-Version: 1.0
312     Content-Transfer-Encoding: 7bit
313     Content-Type: application/octet-stream; charset="us-ascii"
314     <BLANKLINE>
315     -----BEGIN PGP MESSAGE-----
316     MESSAGE STRIPPED (depends on current time)
317     -----END PGP MESSAGE-----
318     <BLANKLINE>
319     --boundsep--
320     """
321     def __init__(self, header, body):
322         self.header = header
323         self.body = body
324
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')
333         return body
334     def passphrase_arg(self, passphrase=None):
335         if passphrase == None and PASSPHRASE != None:
336             passphrase = PASSPHRASE
337         if passphrase == None:
338             return (None,'')
339         return (passphrase, pgp_stdin_passphrase_arg)
340     def plain(self):
341         """
342         text/plain
343         """        
344         msg = MIMEText(self.body)
345         for k,v in self.headermsg.items():
346             msg[k] = v
347         return msg
348     def sign(self, passphrase=None):
349         """
350         multipart/signed
351           +-> text/plain                 (body)
352           +-> application/pgp-signature  (signature)
353         """        
354         passphrase,pass_arg = self.passphrase_arg(passphrase)
355         body = self.clearBodyPart()
356         bfile = tempfile.NamedTemporaryFile()
357         bfile.write(flatten(body))
358         bfile.flush()
359
360         args = replace(pgp_sign_command, 'f', bfile.name)
361         if PGP_SIGN_AS == None:
362             pgp_sign_as = '<%s>' % self.sourceEmail()
363         else:
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)
368         signature = output
369         
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')
373         
374         msg = MIMEMultipart('signed', micalg='pgp-sha1', protocol='application/pgp-signature')
375         msg.attach(body)
376         msg.attach(sig)
377         
378         for k,v in self.headermsg.items():
379             msg[k] = v
380         msg['Content-Disposition'] = 'inline'
381         return msg
382     def encrypt(self, passphrase=None):
383         """
384         multipart/encrypted
385          +-> application/pgp-encrypted  (control information)
386          +-> application/octet-stream   (body)
387         """
388         body = self.clearBodyPart()
389         bfile = tempfile.NamedTemporaryFile()
390         bfile.write(flatten(body))
391         bfile.flush()
392         
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()
398         else:
399             pgp_sign_as = PGP_SIGN_AS
400         args = replace(args, 'a', pgp_sign_as)
401         status,output,error = execute(args)
402         encrypted = output
403         
404         enc = MIMEApplication(_data=encrypted, _subtype='octet-stream', _encoder=encode_7or8bit)
405         enc.set_charset('us-ascii')
406         
407         control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted', _encoder=encode_7or8bit)
408         
409         msg = MIMEMultipart('encrypted', micalg='pgp-sha1', protocol='application/pgp-encrypted')
410         msg.attach(control)
411         msg.attach(enc)
412         
413         for k,v in self.headermsg.items():
414             msg[k] = v
415         msg['Content-Disposition'] = 'inline'
416         return msg
417     def signAndEncrypt(self, passphrase=None):
418         """
419         multipart/encrypted
420          +-> application/pgp-encrypted  (control information)
421          +-> application/octet-stream   (body)
422         """
423         passphrase,pass_arg = self.passphrase_arg(passphrase)
424         body = self.sign()
425         body.__delitem__('Bcc')
426         bfile = tempfile.NamedTemporaryFile()
427         bfile.write(flatten(body))
428         bfile.flush()
429         
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()
435         else:
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)
440         encrypted = output
441         
442         enc = MIMEApplication(_data=encrypted, _subtype='octet-stream', _encoder=encode_7or8bit)
443         enc.set_charset('us-ascii')
444         
445         control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted', _encoder=encode_7or8bit)
446         
447         msg = MIMEMultipart('encrypted', micalg='pgp-sha1', protocol='application/pgp-encrypted')
448         msg.attach(control)
449         msg.attach(enc)
450         
451         for k,v in self.headermsg.items():
452             msg[k] = v
453         msg['Content-Disposition'] = 'inline'
454         return msg
455     def stripChanging(self, text, start, stop, replacement):
456         stripping = False
457         lines = []
458         for line in text.splitlines():
459             line.strip()
460             if stripping == False:
461                 lines.append(line)
462                 if line == start:
463                     stripping = True
464                     lines.append(replacement)
465             else:
466                 if line == stop:
467                     stripping = False
468                     lines.append(line)
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)')
480
481 def test():
482     import doctest
483     doctest.testmod()
484
485
486 if __name__ == '__main__':
487     from optparse import OptionParser
488     
489     parser = OptionParser(usage=usage)
490     parser.add_option('-t', '--test', dest='test', action='store_true',
491                       help='Run doctests and exit')
492     
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')
497     
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')
503     
504     parser.add_option('--mode', dest='mode', default='sign',
505                       help="One of 'sign', 'encrypt', 'sign-encrypt', or 'plain'.  Defaults to %default.",
506                       metavar='MODE')
507
508     parser.add_option('-a', '--sign-as', dest='sign_as',
509                       help="The gpg key to sign with (gpg's -u/--local-user)",
510                       metavar='KEY')
511     
512     parser.add_option('--output', dest='output', action='store_true',
513                       help="Don't mail the generated message, print it to stdout instead.")
514     
515     (options, args) = parser.parse_args()
516     
517     stdin_used = False
518     
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:
523             stdin_used = True
524             PASSPHRASE = sys.stdin.read()
525         else:
526             PASSPHRASE = os.read(options.passphrase_fd)
527     
528     if options.sign_as:
529         PGP_SIGN_AS = options.sign_as
530
531     if options.test == True:
532         test()
533         sys.exit(0)
534     
535     header = None
536     if options.header_filename != None:
537         if options.header_filename == '-':
538             assert stdin_used == False 
539             stdin_used = True
540             header = sys.stdin.read()
541         else:
542             header = file(options.header_filename, 'r').read()
543     if header == None:
544         raise Exception, "missing header"
545     body = None
546     if options.body_filename != None:
547         if options.body_filename == '-':
548             assert stdin_used == False 
549             stdin_used = True
550             body = sys.stdin.read()
551         else:
552             body = file(options.body_filename, 'r').read()
553     if body == None:
554         raise Exception, "missing body"
555
556     m = Mail(header, body)
557     if options.mode == "sign":
558         message = m.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":
564         message = m.plain()
565     else:
566         print "Unrecognized mode '%s'" % options.mode
567     
568     if options.output == True:
569         message = flatten(message)
570         print message
571     else:
572         mail(message, sendmail)