Added some comments to send_pgp_mime
[pgp-mime.git] / interfaces / email / interactive / send_pgp_mime
1 #!/usr/bin/python
2 #
3 # Copyright
4 """
5 Python module and command line tool for sending pgp/mime email.
6
7 Mostly uses subprocess to call gpg and a sendmail-compatible mailer
8 (defaults to msmtp).  If you lack gpg, either don't use the encryption
9 functions or adjust the pgp_* commands.  If you don't use msmtp,
10 adjust the sendmail command.
11 """
12
13 from cStringIO import StringIO
14 import os
15 import re
16 #import GnuPGInterface # Maybe should use this instead of subprocess
17 import smtplib
18 import subprocess
19 import sys
20 import tempfile
21
22 try:
23     from email.mime.text import MIMEText
24     from email.mime.multipart import MIMEMultipart
25     from email.mime.application import MIMEApplication
26     from email.generator import Generator
27     from email.encoders import encode_7or8bit
28     from email.utils import getaddress
29     from email import message_from_string
30 except ImportError:
31     # adjust to old python 2.4
32     from email.MIMEText import MIMEText
33     from email.MIMEMultipart import MIMEMultipart
34     from email.MIMENonMultipart import MIMENonMultipart
35     from email.Generator import Generator
36     from email.Encoders import encode_7or8bit
37     from email.Utils import getaddresses
38     from email import message_from_string
39     
40     getaddress = getaddresses
41     class MIMEApplication (MIMENonMultipart):
42         def __init__(self, _data, _subtype, _encoder, **params):
43             MIMENonMultipart.__init__(self, 'application', _subtype, **params)
44             self.set_payload(_data)
45             _encoder(self)
46
47 usage="""usage: %prog [options]
48
49 Scriptable PGP MIME email using gpg.
50
51 You can use gpg-agent for passphrase caching if your key requires a
52 passphrase (it better!).  Example usage would be to install gpg-agent,
53 and then run
54   export GPG_TTY=`tty`
55   eval $(gpg-agent --daemon)
56 in your shell before invoking this script.  See gpg-agent(1) for more
57 details.  Alternatively, you can send your passphrase in on stdin
58   echo 'passphrase' | %prog [options]
59 or use the --passphrase-file option
60   %prog [options] --passphrase-file FILE [more options]  
61 Both of these alternatives are much less secure than gpg-agent.  You
62 have been warned.
63 """
64
65 verboseInvoke = False
66 PGP_SIGN_AS = None
67 PASSPHRASE = None
68
69 # The following commands are adapted from my .mutt/pgp configuration
70
71 # Printf-like sequences:
72 #   %a The value of PGP_SIGN_AS.
73 #   %f Expands to the name of a file with text to be signed/encrypted.
74 #   %p Expands to the passphrase argument.
75 #   %R A string with some number (0 on up) of pgp_reciepient_arg
76 #      strings.
77 #   %r One key ID (e.g. recipient email address) to build a
78 #      pgp_reciepient_arg string.
79
80 # The above sequences can be used to optionally print a string if
81 # their length is nonzero. For example, you may only want to pass the
82 # -u/--local-user argument to gpg if PGP_SIGN_AS is defined.  To
83 # optionally print a string based upon one of the above sequences, the
84 # following construct is used
85 #   %?<sequence_char>?<optional_string>?
86 # where sequence_char is a character from the table above, and
87 # optional_string is the string you would like printed if status_char
88 # is nonzero. optional_string may contain other sequence as well as
89 # normal text, but it may not contain any question marks.
90 #
91 # see http://codesorcery.net/old/mutt/mutt-gnupg-howto
92 #     http://www.mutt.org/doc/manual/manual-6.html#pgp_autosign
93 #     http://tldp.org/HOWTO/Mutt-GnuPG-PGP-HOWTO-8.html
94 # for more details
95
96 pgp_recipient_arg='-r "%r"'
97 pgp_stdin_passphrase_arg='--passphrase-fd 0'
98 pgp_sign_command='/usr/bin/gpg --no-verbose --quiet --batch %p --output - --detach-sign --armor --textmode %?a?-u "%a"? %f'
99 pgp_encrypt_only_command='/usr/bin/gpg --no-verbose --quiet --batch --output - --encrypt --armor --textmode --always-trust --encrypt-to "%a" %R -- %f'
100 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'
101 sendmail='/usr/bin/msmtp -t'
102
103 def execute(args, stdin=None, expect=(0,)):
104     """
105     Execute a command (allows us to drive gpg).
106     """
107     if verboseInvoke == True:
108         print >> sys.stderr, '$ '+args
109     try:
110         p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, close_fds=True)
111     except OSError, e:
112         strerror = '%s\nwhile executing %s' % (e.args[1], args)
113         raise Exception, strerror
114     output, error = p.communicate(input=stdin)
115     status = p.wait()
116     if verboseInvoke == True:
117         print >> sys.stderr, '(status: %d)\n%s%s' % (status, output, error)
118     if status not in expect:
119         strerror = '%s\nwhile executing %s\n%s\n%d' % (args[1], args, error, status)
120         raise Exception, strerror
121     return status, output, error
122
123 def replace(template, format_char, replacement_text):
124     """
125     >>> replace('--textmode %?a?-u %a? %f', 'f', 'file.in')
126     '--textmode %?a?-u %a? file.in'
127     >>> replace('--textmode %?a?-u %a? %f', 'a', '0xHEXKEY')
128     '--textmode -u 0xHEXKEY %f'
129     >>> replace('--textmode %?a?-u %a? %f', 'a', '')
130     '--textmode  %f'
131     """
132     if replacement_text == None:
133         replacement_text = ""
134     regexp = re.compile('%[?]'+format_char+'[?]([^?]*)[?]') 
135     if len(replacement_text) > 0:
136         str = regexp.sub('\g<1>', template)
137     else:
138         str = regexp.sub('', template)
139     regexp = re.compile('%'+format_char)
140     str = regexp.sub(replacement_text, str)
141     return str
142
143 def flatten(msg):
144     """
145     Produce flat text output from an email Message instance.
146     """
147     assert msg != None
148     fp = StringIO()
149     g = Generator(fp, mangle_from_=False, maxheaderlen=60)
150     g.flatten(msg)
151     text = fp.getvalue()
152     return text    
153
154 def source_email(msg):
155     """
156     Search the header of an email Message instance to find the
157     sender's email address.
158     """
159     froms = msg.get_all('from', [])
160     from_tuples = getaddresses(froms) # [(realname, email_address), ...]
161     assert len(from_tuples) == 1
162     return [addr[1] for addr in from_tuples][0]
163
164 def target_emails(msg):
165     """
166     Search the header of an email Message instance to find a
167     list of recipient's email addresses.
168     """
169     tos = msg.get_all('to', [])
170     ccs = msg.get_all('cc', [])
171     bccs = msg.get_all('bcc', [])
172     resent_tos = msg.get_all('resent-to', [])
173     resent_ccs = msg.get_all('resent-cc', [])
174     resent_bccs = msg.get_all('resent-bcc', [])
175     all_recipients = getaddresses(tos + ccs + bccs + resent_tos + resent_ccs + resent_bccs)
176     return [addr[1] for addr in all_recipients]
177
178 def mail(msg, sendmail=None):
179     """
180     Send an email Message instance on its merry way.
181     
182     We can shell out to the user specified sendmail in case
183     the local host doesn't have an SMTP server set up
184     for easy smtplib usage.
185     """
186     if sendmail != None:
187         execute(sendmail, stdin=flatten(msg))
188         return None
189     s = smtplib.SMTP()
190     s.connect()
191     s.sendmail(from_addr=source_email(msg),
192                to_addrs=target_emails(msg),
193                msg=flatten(msg))
194     s.close()
195
196 class Mail (object):
197     """
198     See http://www.ietf.org/rfc/rfc3156.txt for specification details.
199     >>> m = Mail('\\n'.join(['From: me@big.edu','To: you@big.edu','Subject: testing']), 'check 1 2\\ncheck 1 2\\n')
200     >>> print m.sourceEmail()
201     me@big.edu
202     >>> print m.targetEmails()
203     ['you@big.edu']
204     >>> print flatten(m.clearBodyPart())
205     Content-Type: text/plain; charset="us-ascii"
206     MIME-Version: 1.0
207     Content-Transfer-Encoding: 7bit
208     Content-Type: text/plain
209     Content-Disposition: inline
210     <BLANKLINE>
211     check 1 2
212     check 1 2
213     <BLANKLINE>
214     >>> signed = m.sign()
215     >>> signed.set_boundary('boundsep')
216     >>> print m.stripSig(flatten(signed)).replace('\\t', ' '*4)
217     Content-Type: multipart/signed;
218         protocol="application/pgp-signature";
219         micalg="pgp-sha1"; boundary="boundsep"
220     MIME-Version: 1.0
221     From: me@big.edu
222     To: you@big.edu
223     Subject: testing
224     Content-Disposition: inline
225     <BLANKLINE>
226     --boundsep
227     Content-Type: text/plain; charset="us-ascii"
228     MIME-Version: 1.0
229     Content-Transfer-Encoding: 7bit
230     Content-Type: text/plain
231     Content-Disposition: inline
232     <BLANKLINE>
233     check 1 2
234     check 1 2
235     <BLANKLINE>
236     --boundsep
237     MIME-Version: 1.0
238     Content-Transfer-Encoding: 7bit
239     Content-Description: signature
240     Content-Type: application/pgp-signature; name="signature.asc";
241         charset="us-ascii"
242     <BLANKLINE>
243     -----BEGIN PGP SIGNATURE-----
244     SIGNATURE STRIPPED (depends on current time)
245     -----END PGP SIGNATURE-----
246     <BLANKLINE>
247     --boundsep--
248     >>> encrypted = m.encrypt()
249     >>> encrypted.set_boundary('boundsep')
250     >>> print m.stripPGP(flatten(encrypted)).replace('\\t', ' '*4)
251     Content-Type: multipart/encrypted;
252         protocol="application/pgp-encrypted";
253         micalg="pgp-sha1"; boundary="boundsep"
254     MIME-Version: 1.0
255     From: me@big.edu
256     To: you@big.edu
257     Subject: testing
258     Content-Disposition: inline
259     <BLANKLINE>
260     --boundsep
261     Content-Type: application/pgp-encrypted
262     MIME-Version: 1.0
263     Content-Transfer-Encoding: 7bit
264     <BLANKLINE>
265     Version: 1
266     <BLANKLINE>
267     --boundsep
268     MIME-Version: 1.0
269     Content-Transfer-Encoding: 7bit
270     Content-Type: application/octet-stream; charset="us-ascii"
271     <BLANKLINE>
272     -----BEGIN PGP MESSAGE-----
273     MESSAGE STRIPPED (depends on current time)
274     -----END PGP MESSAGE-----
275     <BLANKLINE>
276     --boundsep--
277     >>> signedAndEncrypted = m.signAndEncrypt()
278     >>> signedAndEncrypted.set_boundary('boundsep')
279     >>> print m.stripPGP(flatten(signedAndEncrypted)).replace('\\t', ' '*4)
280     Content-Type: multipart/encrypted;
281         protocol="application/pgp-encrypted";
282         micalg="pgp-sha1"; boundary="boundsep"
283     MIME-Version: 1.0
284     From: me@big.edu
285     To: you@big.edu
286     Subject: testing
287     Content-Disposition: inline
288     <BLANKLINE>
289     --boundsep
290     Content-Type: application/pgp-encrypted
291     MIME-Version: 1.0
292     Content-Transfer-Encoding: 7bit
293     <BLANKLINE>
294     Version: 1
295     <BLANKLINE>
296     --boundsep
297     MIME-Version: 1.0
298     Content-Transfer-Encoding: 7bit
299     Content-Type: application/octet-stream; charset="us-ascii"
300     <BLANKLINE>
301     -----BEGIN PGP MESSAGE-----
302     MESSAGE STRIPPED (depends on current time)
303     -----END PGP MESSAGE-----
304     <BLANKLINE>
305     --boundsep--
306     """
307     def __init__(self, header, body):
308         self.header = header
309         self.body = body
310
311         self.headermsg = message_from_string(self.header)
312     def sourceEmail(self):
313         return source_email(self.headermsg)
314     def targetEmails(self):
315         return target_emails(self.headermsg)
316     def clearBodyPart(self):
317         body = MIMEText(self.body)
318         body.add_header('Content-Type', 'text/plain')
319         body.add_header('Content-Disposition', 'inline')
320         body.set_charset('us-ascii')
321         return body
322     def passphrase_arg(self, passphrase=None):
323         if passphrase == None and PASSPHRASE != None:
324             passphrase = PASSPHRASE
325         if passphrase == None:
326             return (None,'')
327         return (passphrase, pgp_stdin_passphrase_arg)
328     def sign(self, passphrase=None):
329         """
330         multipart/signed
331           +-> text/plain                 (body)
332           +-> application/pgp-signature  (signature)
333         """        
334         passphrase,pass_arg = self.passphrase_arg(passphrase)
335         body = self.clearBodyPart()
336         bfile = tempfile.NamedTemporaryFile()
337         bfile.write(flatten(body))
338         bfile.flush()
339
340         args = replace(pgp_sign_command, 'f', bfile.name)
341         if PGP_SIGN_AS == None:
342             pgp_sign_as = '<%s>' % self.sourceEmail()
343         else:
344             pgp_sign_as = PGP_SIGN_AS
345         args = replace(args, 'a', pgp_sign_as)
346         args = replace(args, 'p', pass_arg)
347         status,output,error = execute(args, stdin=passphrase)
348         signature = output
349         
350         sig = MIMEApplication(_data=signature, _subtype='pgp-signature; name="signature.asc"', _encoder=encode_7or8bit)
351         sig['Content-Description'] = 'signature'
352         sig.set_charset('us-ascii')
353         
354         msg = MIMEMultipart('signed', micalg='pgp-sha1', protocol='application/pgp-signature')
355         msg.attach(body)
356         msg.attach(sig)
357         
358         for k,v in self.headermsg.items():
359             msg[k] = v
360         msg['Content-Disposition'] = 'inline'
361         return msg
362     def encrypt(self, passphrase=None):
363         """
364         multipart/encrypted
365          +-> application/pgp-encrypted  (control information)
366          +-> application/octet-stream   (body)
367         """
368         body = self.clearBodyPart()
369         bfile = tempfile.NamedTemporaryFile()
370         bfile.write(flatten(body))
371         bfile.flush()
372         
373         recipient_string = ' '.join([replace(pgp_recipient_arg, 'r', recipient) for recipient in self.targetEmails()])
374         args = replace(pgp_encrypt_only_command, 'R', recipient_string)
375         args = replace(args, 'f', bfile.name)
376         if PGP_SIGN_AS == None:
377             pgp_sign_as = '<%s>' % self.sourceEmail()
378         else:
379             pgp_sign_as = PGP_SIGN_AS
380         args = replace(args, 'a', pgp_sign_as)
381         status,output,error = execute(args)
382         encrypted = output
383         
384         enc = MIMEApplication(_data=encrypted, _subtype='octet-stream', _encoder=encode_7or8bit)
385         enc.set_charset('us-ascii')
386         
387         control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted', _encoder=encode_7or8bit)
388         
389         msg = MIMEMultipart('encrypted', micalg='pgp-sha1', protocol='application/pgp-encrypted')
390         msg.attach(control)
391         msg.attach(enc)
392         
393         for k,v in self.headermsg.items():
394             msg[k] = v
395         msg['Content-Disposition'] = 'inline'
396         return msg
397     def signAndEncrypt(self, passphrase=None):
398         """
399         multipart/encrypted
400          +-> application/pgp-encrypted  (control information)
401          +-> application/octet-stream   (body)
402         """
403         passphrase,pass_arg = self.passphrase_arg(passphrase)
404         body = self.sign()
405         body.__delitem__('Bcc')
406         bfile = tempfile.NamedTemporaryFile()
407         bfile.write(flatten(body))
408         bfile.flush()
409         
410         recipient_string = ' '.join([replace(pgp_recipient_arg, 'r', recipient) for recipient in self.targetEmails()])
411         args = replace(pgp_encrypt_only_command, 'R', recipient_string)
412         args = replace(args, 'f', bfile.name)
413         if PGP_SIGN_AS == None:
414             pgp_sign_as = '<%s>' % self.sourceEmail()
415         else:
416             pgp_sign_as = PGP_SIGN_AS
417         args = replace(args, 'a', pgp_sign_as)
418         args = replace(args, 'p', pass_arg)
419         status,output,error = execute(args, stdin=passphrase)
420         encrypted = output
421         
422         enc = MIMEApplication(_data=encrypted, _subtype='octet-stream', _encoder=encode_7or8bit)
423         enc.set_charset('us-ascii')
424         
425         control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted', _encoder=encode_7or8bit)
426         
427         msg = MIMEMultipart('encrypted', micalg='pgp-sha1', protocol='application/pgp-encrypted')
428         msg.attach(control)
429         msg.attach(enc)
430         
431         for k,v in self.headermsg.items():
432             msg[k] = v
433         msg['Content-Disposition'] = 'inline'
434         return msg
435     def stripChanging(self, text, start, stop, replacement):
436         stripping = False
437         lines = []
438         for line in text.splitlines():
439             line.strip()
440             if stripping == False:
441                 lines.append(line)
442                 if line == start:
443                     stripping = True
444                     lines.append(replacement)
445             else:
446                 if line == stop:
447                     stripping = False
448                     lines.append(line)
449         return '\n'.join(lines)
450     def stripSig(self, text):
451         return self.stripChanging(text,
452                                   '-----BEGIN PGP SIGNATURE-----',
453                                   '-----END PGP SIGNATURE-----',
454                                   'SIGNATURE STRIPPED (depends on current time)')
455     def stripPGP(self, text):
456         return self.stripChanging(text,
457                                   '-----BEGIN PGP MESSAGE-----',
458                                   '-----END PGP MESSAGE-----',
459                                   'MESSAGE STRIPPED (depends on current time)')
460
461 def test():
462     import doctest
463     doctest.testmod()
464
465
466 if __name__ == '__main__':
467     from optparse import OptionParser
468     
469     parser = OptionParser(usage=usage)
470     parser.add_option('-t', '--test', dest='test', action='store_true',
471                       help='Run doctests and exit')
472     
473     parser.add_option('-H', '--header-file', dest='header_filename',
474                       help='file containing email header', metavar='FILE')
475     parser.add_option('-B', '--body-file', dest='body_filename',
476                       help='file containing email body', metavar='FILE')
477     
478     parser.add_option('-P', '--passphrase-file', dest='passphrase_file',
479                       help='file containing gpg passphrase', metavar='FILE')
480     parser.add_option('-p', '--passphrase-fd', dest='passphrase_fd',
481                       help='file descriptor from which to read gpg passphrase (0 for stdin)',
482                       type="int", metavar='DESCRIPTOR')
483     
484     parser.add_option('--mode', dest='mode', default='sign',
485                       help="One of 'sign', 'encrypt', or 'sign-encrypt'.  Defaults to %default.",
486                       metavar='MODE')
487
488     parser.add_option('-a', '--sign-as', dest='sign_as',
489                       help="The gpg key to sign with (gpg's -u/--local-user)",
490                       metavar='KEY')
491     
492     parser.add_option('--output', dest='output', action='store_true',
493                       help="Don't mail the generated message, print it to stdout instead.")
494     
495     (options, args) = parser.parse_args()
496     
497     stdin_used = False
498     
499     if options.passphrase_file != None:
500         PASSPHRASE = file(options.passphrase_file, 'r').read()
501     elif options.passphrase_fd != None:
502         if options.passphrase_fd == 0:
503             stdin_used = True
504             PASSPHRASE = sys.stdin.read()
505         else:
506             PASSPHRASE = os.read(options.passphrase_fd)
507     
508     if options.sign_as:
509         PGP_SIGN_AS = options.sign_as
510
511     if options.test == True:
512         test()
513         sys.exit(0)
514     
515     header = None
516     if options.header_filename != None:
517         if options.header_filename == '-':
518             assert stdin_used == False 
519             stdin_used = True
520             header = sys.stdin.read()
521         else:
522             header = file(options.header_filename, 'r').read()
523     if header == None:
524         raise Exception, "missing header"
525     body = None
526     if options.body_filename != None:
527         if options.body_filename == '-':
528             assert stdin_used == False 
529             stdin_used = True
530             body = sys.stdin.read()
531         else:
532             body = file(options.body_filename, 'r').read()
533     if body == None:
534         raise Exception, "missing body"
535
536     m = Mail(header, body)
537     if options.mode == "sign":
538         message = m.sign()
539     elif options.mode == "encrypt":
540         message = m.encrypt()
541     elif options.mode == "sign-encrypt":
542         message = m.signAndEncrypt()
543     else:
544         print "Unrecognized mode '%s'" % options.mode
545     
546     if options.output == True:
547         message = flatten(message)
548         print message
549     else:
550         mail(message, sendmail)