38a2437b0c7d5eb421334f85aeef6acf2ebbe433
[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 import types
36
37 try:
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
45 except ImportError:
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
54     
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)
60             _encoder(self)
61
62 usage="""usage: %prog [options]
63
64 Scriptable PGP MIME email using gpg.
65
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,
68 and then run
69   export GPG_TTY=`tty`
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
77 have been warned.
78 """
79
80 verboseInvoke = False
81 PGP_SIGN_AS = None
82 PASSPHRASE = None
83
84 # The following commands are adapted from my .mutt/pgp configuration
85
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
91 #      strings.
92 #   %r One key ID (e.g. recipient email address) to build a
93 #      pgp_reciepient_arg string.
94
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.
105 #
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
109 # for more details
110
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'
117
118 def execute(args, stdin=None, expect=(0,)):
119     """
120     Execute a command (allows us to drive gpg).
121     """
122     if verboseInvoke == True:
123         print >> sys.stderr, '$ '+args
124     try:
125         p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, close_fds=True)
126     except OSError, e:
127         strerror = '%s\nwhile executing %s' % (e.args[1], args)
128         raise Exception, strerror
129     output, error = p.communicate(input=stdin)
130     status = p.wait()
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
137
138 def replace(template, format_char, replacement_text):
139     """
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', '')
145     '--textmode  %f'
146     """
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)
152     else:
153         str = regexp.sub('', template)
154     regexp = re.compile('%'+format_char)
155     str = regexp.sub(replacement_text, str)
156     return str
157
158 def flatten(msg):
159     """
160     Produce flat text output from an email Message instance.
161     """
162     assert msg != None
163     fp = StringIO()
164     g = Generator(fp, mangle_from_=False)
165     g.flatten(msg)
166     text = fp.getvalue()
167     encoding = msg.get_content_charset()
168     return unicode(text, encoding=encoding)
169
170 def source_email(msg, return_realname=False):
171     """
172     Search the header of an email Message instance to find the
173     sender's email address.
174     """
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
181
182 def target_emails(msg):
183     """
184     Search the header of an email Message instance to find a
185     list of recipient's email addresses.
186     """
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]
195
196 def mail(msg, sendmail=None):
197     """
198     Send an email Message instance on its merry way.
199     
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.
203     """
204     if sendmail != None:
205         execute(sendmail, stdin=flatten(msg))
206         return None
207     s = smtplib.SMTP()
208     s.connect()
209     s.sendmail(from_addr=source_email(msg),
210                to_addrs=target_emails(msg),
211                msg=flatten(msg))
212     s.close()
213
214 class Mail (object):
215     """
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()
219     me@big.edu
220     >>> print m.targetEmails()
221     ['you@big.edu']
222     >>> print flatten(m.clearBodyPart())
223     Content-Type: text/plain; charset="us-ascii"
224     MIME-Version: 1.0
225     Content-Transfer-Encoding: 7bit
226     Content-Disposition: inline
227     <BLANKLINE>
228     check 1 2
229     check 1 2
230     <BLANKLINE>
231     >>> print flatten(m.plain())
232     Content-Type: text/plain; charset="us-ascii"
233     MIME-Version: 1.0
234     Content-Transfer-Encoding: 7bit
235     From: me@big.edu
236     To: you@big.edu
237     Subject: testing
238     <BLANKLINE>
239     check 1 2
240     check 1 2
241     <BLANKLINE>
242     >>> m.sign()
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"
248     MIME-Version: 1.0
249     From: me@big.edu
250     To: you@big.edu
251     Subject: testing
252     Content-Disposition: inline
253     <BLANKLINE>
254     --boundsep
255     Content-Type: text/plain; charset="us-ascii"
256     MIME-Version: 1.0
257     Content-Transfer-Encoding: 7bit
258     Content-Type: text/plain
259     Content-Disposition: inline
260     <BLANKLINE>
261     check 1 2
262     check 1 2
263     <BLANKLINE>
264     --boundsep
265     MIME-Version: 1.0
266     Content-Transfer-Encoding: 7bit
267     Content-Description: signature
268     Content-Type: application/pgp-signature; name="signature.asc";
269         charset="us-ascii"
270     <BLANKLINE>
271     -----BEGIN PGP SIGNATURE-----
272     SIGNATURE STRIPPED (depends on current time)
273     -----END PGP SIGNATURE-----
274     <BLANKLINE>
275     --boundsep--
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"
282     MIME-Version: 1.0
283     From: me@big.edu
284     To: you@big.edu
285     Subject: testing
286     Content-Disposition: inline
287     <BLANKLINE>
288     --boundsep
289     Content-Type: application/pgp-encrypted
290     MIME-Version: 1.0
291     Content-Transfer-Encoding: 7bit
292     <BLANKLINE>
293     Version: 1
294     <BLANKLINE>
295     --boundsep
296     MIME-Version: 1.0
297     Content-Transfer-Encoding: 7bit
298     Content-Type: application/octet-stream; charset="us-ascii"
299     <BLANKLINE>
300     -----BEGIN PGP MESSAGE-----
301     MESSAGE STRIPPED (depends on current time)
302     -----END PGP MESSAGE-----
303     <BLANKLINE>
304     --boundsep--
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"
311     MIME-Version: 1.0
312     From: me@big.edu
313     To: you@big.edu
314     Subject: testing
315     Content-Disposition: inline
316     <BLANKLINE>
317     --boundsep
318     Content-Type: application/pgp-encrypted
319     MIME-Version: 1.0
320     Content-Transfer-Encoding: 7bit
321     <BLANKLINE>
322     Version: 1
323     <BLANKLINE>
324     --boundsep
325     MIME-Version: 1.0
326     Content-Transfer-Encoding: 7bit
327     Content-Type: application/octet-stream; charset="us-ascii"
328     <BLANKLINE>
329     -----BEGIN PGP MESSAGE-----
330     MESSAGE STRIPPED (depends on current time)
331     -----END PGP MESSAGE-----
332     <BLANKLINE>
333     --boundsep--
334     """
335     def __init__(self, header, body):
336         self.header = header.strip()
337         self.body = body
338         if type(self.header) == types.UnicodeType:
339             self.header = self.header.encode("ascii")
340         p = Parser()
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):
347         if 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"]:
352                     try:
353                         body.encode(encoding)
354                     except UnicodeError:
355                         pass
356                     else:
357                         break
358                 assert encoding != None
359         # Create the message ('plain' stands for Content-Type: text/plain)
360         if encoding == "US-ASCII":
361             return MIMEText(body)
362         else:
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')
367         return body
368     def passphrase_arg(self, passphrase=None):
369         if passphrase == None and PASSPHRASE != None:
370             passphrase = PASSPHRASE
371         if passphrase == None:
372             return (None,'')
373         return (passphrase, pgp_stdin_passphrase_arg)
374     def plain(self):
375         """
376         text/plain
377         """        
378         msg = self.encodedMIMEText(self.body)
379         for k,v in self.headermsg.items():
380             msg[k] = v
381         return msg
382     def sign(self, passphrase=None):
383         """
384         multipart/signed
385           +-> text/plain                 (body)
386           +-> application/pgp-signature  (signature)
387         """        
388         passphrase,pass_arg = self.passphrase_arg(passphrase)
389         body = self.clearBodyPart()
390         bfile = tempfile.NamedTemporaryFile()
391         bfile.write(flatten(body))
392         bfile.flush()
393
394         args = replace(pgp_sign_command, 'f', bfile.name)
395         if PGP_SIGN_AS == None:
396             pgp_sign_as = '<%s>' % self.sourceEmail()
397         else:
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)
402         signature = output
403         
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')
407         
408         msg = MIMEMultipart('signed', micalg='pgp-sha1', protocol='application/pgp-signature')
409         msg.attach(body)
410         msg.attach(sig)
411         
412         for k,v in self.headermsg.items():
413             msg[k] = v
414         msg['Content-Disposition'] = 'inline'
415         return msg
416     def encrypt(self, passphrase=None):
417         """
418         multipart/encrypted
419          +-> application/pgp-encrypted  (control information)
420          +-> application/octet-stream   (body)
421         """
422         body = self.clearBodyPart()
423         bfile = tempfile.NamedTemporaryFile()
424         bfile.write(flatten(body))
425         bfile.flush()
426         
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()
432         else:
433             pgp_sign_as = PGP_SIGN_AS
434         args = replace(args, 'a', pgp_sign_as)
435         status,output,error = execute(args)
436         encrypted = output
437         
438         enc = MIMEApplication(_data=encrypted, _subtype='octet-stream', _encoder=encode_7or8bit)
439         enc.set_charset('us-ascii')
440         
441         control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted', _encoder=encode_7or8bit)
442         
443         msg = MIMEMultipart('encrypted', micalg='pgp-sha1', protocol='application/pgp-encrypted')
444         msg.attach(control)
445         msg.attach(enc)
446         
447         for k,v in self.headermsg.items():
448             msg[k] = v
449         msg['Content-Disposition'] = 'inline'
450         return msg
451     def signAndEncrypt(self, passphrase=None):
452         """
453         multipart/encrypted
454          +-> application/pgp-encrypted  (control information)
455          +-> application/octet-stream   (body)
456         """
457         passphrase,pass_arg = self.passphrase_arg(passphrase)
458         body = self.sign()
459         body.__delitem__('Bcc')
460         bfile = tempfile.NamedTemporaryFile()
461         bfile.write(flatten(body))
462         bfile.flush()
463         
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()
469         else:
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)
474         encrypted = output
475         
476         enc = MIMEApplication(_data=encrypted, _subtype='octet-stream', _encoder=encode_7or8bit)
477         enc.set_charset('us-ascii')
478         
479         control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted', _encoder=encode_7or8bit)
480         
481         msg = MIMEMultipart('encrypted', micalg='pgp-sha1', protocol='application/pgp-encrypted')
482         msg.attach(control)
483         msg.attach(enc)
484         
485         for k,v in self.headermsg.items():
486             msg[k] = v
487         msg['Content-Disposition'] = 'inline'
488         return msg
489     def stripChanging(self, text, start, stop, replacement):
490         stripping = False
491         lines = []
492         for line in text.splitlines():
493             line.strip()
494             if stripping == False:
495                 lines.append(line)
496                 if line == start:
497                     stripping = True
498                     lines.append(replacement)
499             else:
500                 if line == stop:
501                     stripping = False
502                     lines.append(line)
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)')
514
515 def test():
516     import doctest
517     doctest.testmod()
518
519
520 if __name__ == '__main__':
521     from optparse import OptionParser
522     
523     parser = OptionParser(usage=usage)
524     parser.add_option('-t', '--test', dest='test', action='store_true',
525                       help='Run doctests and exit')
526     
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')
531     
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')
537     
538     parser.add_option('--mode', dest='mode', default='sign',
539                       help="One of 'sign', 'encrypt', 'sign-encrypt', or 'plain'.  Defaults to %default.",
540                       metavar='MODE')
541
542     parser.add_option('-a', '--sign-as', dest='sign_as',
543                       help="The gpg key to sign with (gpg's -u/--local-user)",
544                       metavar='KEY')
545     
546     parser.add_option('--output', dest='output', action='store_true',
547                       help="Don't mail the generated message, print it to stdout instead.")
548     
549     (options, args) = parser.parse_args()
550     
551     stdin_used = False
552     
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:
557             stdin_used = True
558             PASSPHRASE = sys.stdin.read()
559         else:
560             PASSPHRASE = os.read(options.passphrase_fd)
561     
562     if options.sign_as:
563         PGP_SIGN_AS = options.sign_as
564
565     if options.test == True:
566         test()
567         sys.exit(0)
568     
569     header = None
570     if options.header_filename != None:
571         if options.header_filename == '-':
572             assert stdin_used == False 
573             stdin_used = True
574             header = sys.stdin.read()
575         else:
576             header = file(options.header_filename, 'r').read()
577     if header == None:
578         raise Exception, "missing header"
579     body = None
580     if options.body_filename != None:
581         if options.body_filename == '-':
582             assert stdin_used == False 
583             stdin_used = True
584             body = sys.stdin.read()
585         else:
586             body = file(options.body_filename, 'r').read()
587     if body == None:
588         raise Exception, "missing body"
589
590     m = Mail(header, body)
591     if options.mode == "sign":
592         message = m.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":
598         message = m.plain()
599     else:
600         print "Unrecognized mode '%s'" % options.mode
601     
602     if options.output == True:
603         message = flatten(message)
604         print message
605     else:
606         mail(message, sendmail)