added basic mutli-language reply generation
[edward.git] / edward
1 #! /usr/bin/env python3
2 # -*- coding: utf-8 -*-
3 """*********************************************************************
4 * Edward is free software: you can redistribute it and/or modify *
5 * it under the terms of the GNU Affero Public License as published by *
6 * the Free Software Foundation, either version 3 of the License, or *
7 * (at your option) any later version. *
8 * *
9 * Edward is distributed in the hope that it will be useful, *
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of *
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
12 * GNU Affero Public License for more details. *
13 * *
14 * You should have received a copy of the GNU Affero Public License *
15 * along with Edward. If not, see <http://www.gnu.org/licenses/>. *
16 * *
17 * Copyright (C) 2014-2015 Andrew Engelbrecht (AGPLv3+) *
18 * Copyright (C) 2014 Josh Drake (AGPLv3+) *
19 * Copyright (C) 2014 Lisa Marie Maginnis (AGPLv3+) *
20 * Copyright (C) 2009-2015 Tails developers <tails@boum.org> ( GPLv3+) *
21 * Copyright (C) 2009 W. Trevor King <wking@drexel.edu> ( GPLv2+) *
22 * *
23 * Special thanks to Josh Drake for writing the original edward bot! :) *
24 * *
25 ************************************************************************
26
27 Code sourced from these projects:
28
29 * http://agpl.fsf.org/emailselfdefense.fsf.org/edward/CURRENT/edward.tar.gz
30 * https://git-tails.immerda.ch/whisperback/tree/whisperBack/encryption.py?h=feature/python3
31 * http://www.physics.drexel.edu/~wking/code/python/send_pgp_mime
32 """
33
34 import sys
35 import gpgme
36 import re
37 import io
38 import os
39 import importlib
40
41 import email.parser
42 import email.message
43 import email.encoders
44
45 from email.mime.multipart import MIMEMultipart
46 from email.mime.application import MIMEApplication
47 from email.mime.nonmultipart import MIMENonMultipart
48
49 import edward_config
50
51 langs = ["an", "de", "el", "en", "fr", "ja", "pt-br", "ro", "ru", "tr"]
52
53 match_types = [('clearsign',
54 '-----BEGIN PGP SIGNED MESSAGE-----.*?-----BEGIN PGP SIGNATURE-----.*?-----END PGP SIGNATURE-----'),
55 ('message',
56 '-----BEGIN PGP MESSAGE-----.*?-----END PGP MESSAGE-----'),
57 ('pubkey',
58 '-----BEGIN PGP PUBLIC KEY BLOCK-----.*?-----END PGP PUBLIC KEY BLOCK-----'),
59 ('detachedsig',
60 '-----BEGIN PGP SIGNATURE-----.*?-----END PGP SIGNATURE-----')]
61
62
63 class EddyMsg (object):
64 def __init__(self):
65 self.multipart = False
66 self.subparts = []
67
68 self.charset = None
69 self.payload_bytes = None
70 self.payload_pieces = []
71
72 self.filename = None
73 self.content_type = None
74 self.description_list = None
75
76
77 class PayloadPiece (object):
78 def __init__(self):
79 self.piece_type = None
80 self.string = None
81 self.gpg_data = None
82
83
84 class GPGData (object):
85 def __init__(self):
86 self.decrypted = False
87
88 self.plainobj = None
89 self.sigs = []
90 self.keys = []
91
92 class ReplyInfo (object):
93 def __init__(self):
94 self.replies = None
95 self.msg_to_quote = ""
96
97 self.success_decrypt = False
98 self.failed_decrypt = False
99 self.public_key_received = False
100 self.no_public_key = False
101 self.sig_success = False
102 self.sig_failure = False
103
104
105 def main ():
106
107 handle_args()
108
109 gpgme_ctx = get_gpg_context(edward_config.gnupghome,
110 edward_config.sign_with_key)
111
112 email_text = sys.stdin.read()
113 email_struct = parse_pgp_mime(email_text, gpgme_ctx)
114
115 email_to, email_from, email_subject = email_to_from_subject(email_text)
116 lang = import_lang(email_to)
117
118 replyinfo_obj = ReplyInfo()
119 replyinfo_obj.replies = lang.replies
120
121 prepare_for_reply(email_struct, replyinfo_obj)
122 reply_plaintext = write_reply(replyinfo_obj)
123
124 print(reply_plaintext)
125
126 # encrypt_to_key = choose_reply_encryption_key(gpgme_ctx, fingerprints)
127 #
128 # reply_mime = generate_encrypted_mime(plaintext, email_from, \
129 # email_subject, encrypt_to_key,
130 # gpgme_ctx)
131
132
133 def get_gpg_context (gnupghome, sign_with_key_fp):
134
135 os.environ['GNUPGHOME'] = gnupghome
136
137 gpgme_ctx = gpgme.Context()
138 gpgme_ctx.armor = True
139
140 try:
141 sign_with_key = gpgme_ctx.get_key(sign_with_key_fp)
142 except:
143 error("unable to load signing key. is the gnupghome "
144 + "and signing key properly set in the edward_config.py?")
145 exit(1)
146
147 gpgme_ctx.signers = [sign_with_key]
148
149 return gpgme_ctx
150
151
152 def parse_pgp_mime (email_text, gpgme_ctx):
153
154 email_struct = email.parser.Parser().parsestr(email_text)
155
156 eddy_obj = parse_mime(email_struct)
157 split_payloads(eddy_obj)
158 gpg_on_payloads(eddy_obj, gpgme_ctx)
159
160 return eddy_obj
161
162
163 def parse_mime(msg_struct):
164
165 eddy_obj = EddyMsg()
166
167 if msg_struct.is_multipart() == True:
168 payloads = msg_struct.get_payload()
169
170 eddy_obj.multipart = True
171 eddy_obj.subparts = list(map(parse_mime, payloads))
172
173 else:
174 eddy_obj = get_subpart_data(msg_struct)
175
176 return eddy_obj
177
178
179 def scan_and_split (payload_piece, match_type, pattern):
180
181 # don't try to re-split pieces containing gpg data
182 if payload_piece.piece_type != "text":
183 return [payload_piece]
184
185 flags = re.DOTALL | re.MULTILINE
186 matches = re.search("(?P<beginning>.*?)(?P<match>" + pattern +
187 ")(?P<rest>.*)", payload_piece.string, flags=flags)
188
189 if matches == None:
190 pieces = [payload_piece]
191
192 else:
193
194 beginning = PayloadPiece()
195 beginning.string = matches.group('beginning')
196 beginning.piece_type = payload_piece.piece_type
197
198 match = PayloadPiece()
199 match.string = matches.group('match')
200 match.piece_type = match_type
201
202 rest = PayloadPiece()
203 rest.string = matches.group('rest')
204 rest.piece_type = payload_piece.piece_type
205
206 more_pieces = scan_and_split(rest, match_type, pattern)
207 pieces = [beginning, match ] + more_pieces
208
209 return pieces
210
211
212 def get_subpart_data (part):
213
214 obj = EddyMsg()
215
216 obj.charset = part.get_content_charset()
217 obj.payload_bytes = part.get_payload(decode=True)
218
219 obj.filename = part.get_filename()
220 obj.content_type = part.get_content_type()
221 obj.description_list = part['content-description']
222
223 # your guess is as good as a-myy-ee-ine...
224 if obj.charset == None:
225 obj.charset = 'utf-8'
226
227 if obj.payload_bytes != None:
228 try:
229 payload = PayloadPiece()
230 payload.string = obj.payload_bytes.decode(obj.charset)
231 payload.piece_type = 'text'
232
233 obj.payload_pieces = [payload]
234 except UnicodeDecodeError:
235 pass
236
237 return obj
238
239
240 def do_to_eddys_pieces (function_to_do, eddy_obj, data):
241
242 if eddy_obj.multipart == True:
243 for sub in eddy_obj.subparts:
244 do_to_eddys_pieces(function_to_do, sub, data)
245 else:
246 function_to_do(eddy_obj, data)
247
248
249 def split_payloads (eddy_obj):
250
251 for match_type in match_types:
252 do_to_eddys_pieces(split_payload_pieces, eddy_obj, match_type)
253
254
255 def split_payload_pieces (eddy_obj, match_type):
256
257 (match_name, pattern) = match_type
258
259 new_pieces_list = []
260 for piece in eddy_obj.payload_pieces:
261 new_pieces_list += scan_and_split(piece, match_name, pattern)
262
263 eddy_obj.payload_pieces = new_pieces_list
264
265
266 def gpg_on_payloads (eddy_obj, gpgme_ctx, prev_parts=[]):
267
268 if eddy_obj.multipart == True:
269 prev_parts=[]
270 for sub in eddy_obj.subparts:
271 gpg_on_payloads (sub, gpgme_ctx, prev_parts)
272 prev_parts += [sub]
273
274 return
275
276 for piece in eddy_obj.payload_pieces:
277
278 if piece.piece_type == "text":
279 # don't transform the plaintext.
280 pass
281
282 elif piece.piece_type == "message":
283 (plaintext, sigs) = decrypt_block (piece.string, gpgme_ctx)
284
285 if plaintext:
286 piece.gpg_data = GPGData()
287 piece.gpg_data.sigs = sigs
288 # recurse!
289 piece.gpg_data.plainobj = parse_pgp_mime(plaintext, gpgme_ctx)
290
291 elif piece.piece_type == "pubkey":
292 key_fps = add_gpg_key(piece.string, gpgme_ctx)
293
294 if key_fps != []:
295 piece.gpg_data = GPGData()
296 piece.gpg_data.keys = key_fps
297
298 elif piece.piece_type == "clearsign":
299 (plaintext, sig_fps) = verify_clear_signature(piece.string, gpgme_ctx)
300
301 if sig_fps != []:
302 piece.gpg_data = GPGData()
303 piece.gpg_data.sigs = sig_fps
304 piece.gpg_data.plainobj = parse_pgp_mime(plaintext, gpgme_ctx)
305
306 elif piece.piece_type == "detachedsig":
307 for prev in prev_parts:
308 payload_bytes = prev.payload_bytes
309 sig_fps = verify_detached_signature(piece.string, payload_bytes, gpgme_ctx)
310
311 if sig_fps != []:
312 piece.gpg_data = GPGData()
313 piece.gpg_data.sigs = sig_fps
314 piece.gpg_data.plainobj = prev
315 break
316 else:
317 pass
318
319
320 def prepare_for_reply (eddy_obj, replyinfo_obj):
321
322 do_to_eddys_pieces(prepare_for_reply_pieces, eddy_obj, replyinfo_obj)
323
324
325 def prepare_for_reply_pieces (eddy_obj, replyinfo_obj):
326
327 for piece in eddy_obj.payload_pieces:
328 if piece.piece_type == "text":
329 # don't quote the plaintext part.
330 pass
331
332 elif piece.piece_type == "message":
333 if piece.gpg_data == None:
334 replyinfo_obj.failed_decrypt = True
335 else:
336 replyinfo_obj.success_decrypt = True
337 # TODO: only quote it if it is also signed by the encrypter.
338 replyinfo_obj.msg_to_quote += flatten_payloads(piece.gpg_data.plainobj)
339
340 prepare_for_reply(piece.gpg_data.plainobj, replyinfo_obj)
341
342 elif piece.piece_type == "pubkey":
343 if piece.gpg_data == None:
344 replyinfo_obj.no_public_key = True
345 else:
346 replyinfo_obj.public_key_received = True
347
348 elif (piece.piece_type == "clearsign") \
349 or (piece.piece_type == "detachedsig"):
350 if piece.gpg_data == None:
351 replyinfo_obj.sig_failure = True
352 else:
353 replyinfo_obj.sig_success = True
354
355
356 def flatten_payloads (eddy_obj):
357
358 flat_string = ""
359
360 if eddy_obj.multipart == True:
361 for sub in eddy_obj.subparts:
362 flat_string += flatten_payloads (sub)
363
364 return flat_string
365
366 for piece in eddy_obj.payload_pieces:
367 if piece.piece_type == "text":
368 flat_string += piece.string
369
370 return flat_string
371
372
373 def write_reply (replyinfo_obj):
374
375 reply_plain = ""
376
377 if replyinfo_obj.success_decrypt == True:
378 quoted_text = email_quote_text(replyinfo_obj.msg_to_quote)
379 reply_plain += replyinfo_obj.replies['success_decrypt']
380 reply_plain += quoted_text
381
382 elif replyinfo_obj.failed_decrypt == True:
383 reply_plain += replyinfo_obj.replies['failed_decrypt']
384
385
386 if replyinfo_obj.sig_success == True:
387 reply_plain += "\n\n"
388 reply_plain += replyinfo_obj.replies['sig_success']
389
390 elif replyinfo_obj.sig_failure == True:
391 reply_plain += "\n\n"
392 reply_plain += replyinfo_obj.replies['sig_failure']
393
394
395 if replyinfo_obj.public_key_received == True:
396 reply_plain += "\n\n"
397 reply_plain += replyinfo_obj.replies['public_key_received']
398
399 elif replyinfo_obj.no_public_key == True:
400 reply_plain += "\n\n"
401 reply_plain += replyinfo_obj.replies['no_public_key']
402
403
404 reply_plain += "\n\n"
405 reply_plain += replyinfo_obj.replies['signature']
406
407 return reply_plain
408
409
410 def add_gpg_key (key_block, gpgme_ctx):
411
412 fp = io.BytesIO(key_block.encode('ascii'))
413
414 result = gpgme_ctx.import_(fp)
415 imports = result.imports
416
417 key_fingerprints = []
418
419 if imports != []:
420 for import_ in imports:
421 fingerprint = import_[0]
422 key_fingerprints += [fingerprint]
423
424 debug("added gpg key: " + fingerprint)
425
426 return key_fingerprints
427
428
429 def verify_clear_signature (sig_block, gpgme_ctx):
430
431 # FIXME: this might require the un-decoded bytes
432 # or the correct re-encoding with the carset of the mime part.
433 msg_fp = io.BytesIO(sig_block.encode('utf-8'))
434 ptxt_fp = io.BytesIO()
435
436 result = gpgme_ctx.verify(msg_fp, None, ptxt_fp)
437
438 # FIXME: this might require using the charset of the mime part.
439 plaintext = ptxt_fp.getvalue().decode('utf-8')
440
441 sig_fingerprints = []
442 for res_ in result:
443 sig_fingerprints += [res_.fpr]
444
445 return plaintext, sig_fingerprints
446
447
448 def verify_detached_signature (detached_sig, plaintext_bytes, gpgme_ctx):
449
450 detached_sig_fp = io.BytesIO(detached_sig.encode('ascii'))
451 plaintext_fp = io.BytesIO(plaintext_bytes)
452 ptxt_fp = io.BytesIO()
453
454 result = gpgme_ctx.verify(detached_sig_fp, plaintext_fp, None)
455
456 sig_fingerprints = []
457 for res_ in result:
458 sig_fingerprints += [res_.fpr]
459
460 return sig_fingerprints
461
462
463 def decrypt_block (msg_block, gpgme_ctx):
464
465 block_b = io.BytesIO(msg_block.encode('ascii'))
466 plain_b = io.BytesIO()
467
468 try:
469 sigs = gpgme_ctx.decrypt_verify(block_b, plain_b)
470 except:
471 return ("",[])
472
473 plaintext = plain_b.getvalue().decode('utf-8')
474 return (plaintext, sigs)
475
476
477 def choose_reply_encryption_key (gpgme_ctx, fingerprints):
478
479 reply_key = None
480 for fp in fingerprints:
481 try:
482 key = gpgme_ctx.get_key(fp)
483
484 if (key.can_encrypt == True):
485 reply_key = key
486 break
487 except:
488 continue
489
490
491 return reply_key
492
493
494 def email_to_from_subject (email_text):
495
496 email_struct = email.parser.Parser().parsestr(email_text)
497
498 email_to = email_struct['To']
499 email_from = email_struct['From']
500 email_subject = email_struct['Subject']
501
502 return email_to, email_from, email_subject
503
504
505 def import_lang(email_to):
506
507 if email_to != None:
508 for lang in langs:
509 if "edward-" + lang in email_to:
510 lang = "lang." + re.sub('-', '_', lang)
511 language = importlib.import_module(lang)
512
513 return language
514
515 return importlib.import_module("lang.en")
516
517
518 def generate_encrypted_mime (plaintext, email_from, email_subject, encrypt_to_key,
519 gpgme_ctx):
520
521
522 reply = "To: " + email_from + "\n"
523 reply += "Subject: " + email_subject + "\n"
524
525 if (encrypt_to_key != None):
526 plaintext_reply = "thanks for the message!\n\n\n"
527 plaintext_reply += email_quote_text(plaintext)
528
529 # quoted printable encoding lets most ascii characters look normal
530 # before the decrypted mime message is decoded.
531 char_set = email.charset.Charset("utf-8")
532 char_set.body_encoding = email.charset.QP
533
534 # MIMEText doesn't allow setting the text encoding
535 # so we use MIMENonMultipart.
536 plaintext_mime = MIMENonMultipart('text', 'plain')
537 plaintext_mime.set_payload(plaintext_reply, charset=char_set)
538
539 encrypted_text = encrypt_sign_message(plaintext_mime.as_string(),
540 encrypt_to_key,
541 gpgme_ctx)
542
543 control_mime = MIMEApplication("Version: 1",
544 _subtype='pgp-encrypted',
545 _encoder=email.encoders.encode_7or8bit)
546 control_mime['Content-Description'] = 'PGP/MIME version identification'
547 control_mime.set_charset('us-ascii')
548
549 encoded_mime = MIMEApplication(encrypted_text,
550 _subtype='octet-stream; name="encrypted.asc"',
551 _encoder=email.encoders.encode_7or8bit)
552 encoded_mime['Content-Description'] = 'OpenPGP encrypted message'
553 encoded_mime['Content-Disposition'] = 'inline; filename="encrypted.asc"'
554 encoded_mime.set_charset('us-ascii')
555
556 message_mime = MIMEMultipart(_subtype="encrypted", protocol="application/pgp-encrypted")
557 message_mime.attach(control_mime)
558 message_mime.attach(encoded_mime)
559 message_mime['Content-Disposition'] = 'inline'
560
561 reply += message_mime.as_string()
562
563 else:
564 reply += "\n"
565 reply += "Sorry, i couldn't find your key.\n"
566 reply += "I'll need that to encrypt a message to you."
567
568 return reply
569
570
571 def email_quote_text (text):
572
573 quoted_message = re.sub(r'^', r'> ', text, flags=re.MULTILINE)
574
575 return quoted_message
576
577
578 def encrypt_sign_message (plaintext, encrypt_to_key, gpgme_ctx):
579
580 plaintext_bytes = io.BytesIO(plaintext.encode('ascii'))
581 encrypted_bytes = io.BytesIO()
582
583 gpgme_ctx.encrypt_sign([encrypt_to_key], gpgme.ENCRYPT_ALWAYS_TRUST,
584 plaintext_bytes, encrypted_bytes)
585
586 encrypted_txt = encrypted_bytes.getvalue().decode('ascii')
587 return encrypted_txt
588
589
590 def error (error_msg):
591
592 sys.stderr.write(progname + ": " + str(error_msg) + "\n")
593
594
595 def debug (debug_msg):
596
597 if edward_config.debug == True:
598 error(debug_msg)
599
600
601 def handle_args ():
602 if __name__ == "__main__":
603
604 global progname
605 progname = sys.argv[0]
606
607 if len(sys.argv) > 1:
608 print(progname + ": error, this program doesn't " \
609 "need any arguments.", file=sys.stderr)
610 exit(1)
611
612
613 main()
614