b790a2a34406d2ea47c6a8d12467ba16775f1aa0
[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
96 self.target_key = None
97 self.fallback_target_key = None
98 self.msg_to_quote = ""
99
100 self.success_decrypt = False
101 self.failed_decrypt = False
102 self.public_key_received = False
103 self.no_public_key = False
104 self.sig_success = False
105 self.sig_failure = False
106
107
108 def main ():
109
110 handle_args()
111
112 gpgme_ctx = get_gpg_context(edward_config.gnupghome,
113 edward_config.sign_with_key)
114
115 email_text = sys.stdin.read()
116 email_struct = parse_pgp_mime(email_text, gpgme_ctx)
117
118 email_to, email_from, email_subject = email_to_from_subject(email_text)
119 lang = import_lang(email_to)
120
121 replyinfo_obj = ReplyInfo()
122 replyinfo_obj.replies = lang.replies
123
124 prepare_for_reply(email_struct, replyinfo_obj)
125 encrypt_to_key = get_key_from_fp(replyinfo_obj, gpgme_ctx)
126 reply_plaintext = write_reply(replyinfo_obj)
127
128 # TODO error handling for missing keys and setting .no_public_key
129 reply_mime = generate_encrypted_mime(reply_plaintext, email_from, \
130 email_subject, encrypt_to_key,
131 gpgme_ctx)
132
133 print(reply_mime)
134
135
136 def get_gpg_context (gnupghome, sign_with_key_fp):
137
138 os.environ['GNUPGHOME'] = gnupghome
139
140 gpgme_ctx = gpgme.Context()
141 gpgme_ctx.armor = True
142
143 try:
144 sign_with_key = gpgme_ctx.get_key(sign_with_key_fp)
145 except:
146 error("unable to load signing key. is the gnupghome "
147 + "and signing key properly set in the edward_config.py?")
148 exit(1)
149
150 gpgme_ctx.signers = [sign_with_key]
151
152 return gpgme_ctx
153
154
155 def parse_pgp_mime (email_text, gpgme_ctx):
156
157 email_struct = email.parser.Parser().parsestr(email_text)
158
159 eddymsg_obj = parse_mime(email_struct)
160 split_payloads(eddymsg_obj)
161 gpg_on_payloads(eddymsg_obj, gpgme_ctx)
162
163 return eddymsg_obj
164
165
166 def parse_mime(msg_struct):
167
168 eddymsg_obj = EddyMsg()
169
170 if msg_struct.is_multipart() == True:
171 payloads = msg_struct.get_payload()
172
173 eddymsg_obj.multipart = True
174 eddymsg_obj.subparts = list(map(parse_mime, payloads))
175
176 else:
177 eddymsg_obj = get_subpart_data(msg_struct)
178
179 return eddymsg_obj
180
181
182 def scan_and_split (payload_piece, match_type, pattern):
183
184 # don't try to re-split pieces containing gpg data
185 if payload_piece.piece_type != "text":
186 return [payload_piece]
187
188 flags = re.DOTALL | re.MULTILINE
189 matches = re.search("(?P<beginning>.*?)(?P<match>" + pattern +
190 ")(?P<rest>.*)", payload_piece.string, flags=flags)
191
192 if matches == None:
193 pieces = [payload_piece]
194
195 else:
196
197 beginning = PayloadPiece()
198 beginning.string = matches.group('beginning')
199 beginning.piece_type = payload_piece.piece_type
200
201 match = PayloadPiece()
202 match.string = matches.group('match')
203 match.piece_type = match_type
204
205 rest = PayloadPiece()
206 rest.string = matches.group('rest')
207 rest.piece_type = payload_piece.piece_type
208
209 more_pieces = scan_and_split(rest, match_type, pattern)
210 pieces = [beginning, match ] + more_pieces
211
212 return pieces
213
214
215 def get_subpart_data (part):
216
217 obj = EddyMsg()
218
219 obj.charset = part.get_content_charset()
220 obj.payload_bytes = part.get_payload(decode=True)
221
222 obj.filename = part.get_filename()
223 obj.content_type = part.get_content_type()
224 obj.description_list = part['content-description']
225
226 # your guess is as good as a-myy-ee-ine...
227 if obj.charset == None:
228 obj.charset = 'utf-8'
229
230 if obj.payload_bytes != None:
231 try:
232 payload = PayloadPiece()
233 payload.string = obj.payload_bytes.decode(obj.charset)
234 payload.piece_type = 'text'
235
236 obj.payload_pieces = [payload]
237 except UnicodeDecodeError:
238 pass
239
240 return obj
241
242
243 def do_to_eddys_pieces (function_to_do, eddymsg_obj, data):
244
245 if eddymsg_obj.multipart == True:
246 for sub in eddymsg_obj.subparts:
247 do_to_eddys_pieces(function_to_do, sub, data)
248 else:
249 function_to_do(eddymsg_obj, data)
250
251
252 def split_payloads (eddymsg_obj):
253
254 for match_type in match_types:
255 do_to_eddys_pieces(split_payload_pieces, eddymsg_obj, match_type)
256
257
258 def split_payload_pieces (eddymsg_obj, match_type):
259
260 (match_name, pattern) = match_type
261
262 new_pieces_list = []
263 for piece in eddymsg_obj.payload_pieces:
264 new_pieces_list += scan_and_split(piece, match_name, pattern)
265
266 eddymsg_obj.payload_pieces = new_pieces_list
267
268
269 def gpg_on_payloads (eddymsg_obj, gpgme_ctx, prev_parts=[]):
270
271 if eddymsg_obj.multipart == True:
272 prev_parts=[]
273 for sub in eddymsg_obj.subparts:
274 gpg_on_payloads (sub, gpgme_ctx, prev_parts)
275 prev_parts += [sub]
276
277 return
278
279 for piece in eddymsg_obj.payload_pieces:
280
281 if piece.piece_type == "text":
282 # don't transform the plaintext.
283 pass
284
285 elif piece.piece_type == "message":
286 (plaintext, sigs) = decrypt_block(piece.string, gpgme_ctx)
287
288 if plaintext:
289 piece.gpg_data = GPGData()
290 piece.gpg_data.decrypted = True
291 piece.gpg_data.sigs = sigs
292 # recurse!
293 piece.gpg_data.plainobj = parse_pgp_mime(plaintext, gpgme_ctx)
294
295 elif piece.piece_type == "pubkey":
296 key_fps = add_gpg_key(piece.string, gpgme_ctx)
297
298 if key_fps != []:
299 piece.gpg_data = GPGData()
300 piece.gpg_data.keys = key_fps
301
302 elif piece.piece_type == "clearsign":
303 (plaintext, sig_fps) = verify_clear_signature(piece.string, gpgme_ctx)
304
305 if sig_fps != []:
306 piece.gpg_data = GPGData()
307 piece.gpg_data.sigs = sig_fps
308 piece.gpg_data.plainobj = parse_pgp_mime(plaintext, gpgme_ctx)
309
310 elif piece.piece_type == "detachedsig":
311 for prev in prev_parts:
312 payload_bytes = prev.payload_bytes
313 sig_fps = verify_detached_signature(piece.string, payload_bytes, gpgme_ctx)
314
315 if sig_fps != []:
316 piece.gpg_data = GPGData()
317 piece.gpg_data.sigs = sig_fps
318 piece.gpg_data.plainobj = prev
319 break
320
321 else:
322 pass
323
324
325 def prepare_for_reply (eddymsg_obj, replyinfo_obj):
326
327 do_to_eddys_pieces(prepare_for_reply_pieces, eddymsg_obj, replyinfo_obj)
328
329 def prepare_for_reply_pieces (eddymsg_obj, replyinfo_obj):
330
331 for piece in eddymsg_obj.payload_pieces:
332 if piece.piece_type == "text":
333 # don't quote the plaintext part.
334 pass
335
336 elif piece.piece_type == "message":
337 if piece.gpg_data == None:
338 replyinfo_obj.failed_decrypt = True
339 else:
340 replyinfo_obj.success_decrypt = True
341
342 if replyinfo_obj.target_key != None:
343 continue
344 if piece.gpg_data.sigs != []:
345 replyinfo_obj.target_key = piece.gpg_data.sigs[0]
346 replyinfo_obj.msg_to_quote = flatten_payloads(piece.gpg_data.plainobj)
347
348 # to catch public keys in encrypted blocks
349 prepare_for_reply(piece.gpg_data.plainobj, replyinfo_obj)
350
351 elif piece.piece_type == "pubkey":
352 if piece.gpg_data == None or piece.gpg_data.keys == []:
353 replyinfo_obj.no_public_key = True
354 else:
355 replyinfo_obj.public_key_received = True
356
357 if replyinfo_obj.fallback_target_key == None:
358 replyinfo_obj.fallback_target_key = piece.gpg_data.keys[0]
359
360 elif (piece.piece_type == "clearsign") \
361 or (piece.piece_type == "detachedsig"):
362 if piece.gpg_data == None or piece.gpg_data.sigs == []:
363 replyinfo_obj.sig_failure = True
364 else:
365 replyinfo_obj.sig_success = True
366
367 if replyinfo_obj.fallback_target_key == None:
368 replyinfo_obj.fallback_target_key = piece.gpg_data.sigs[0]
369
370
371
372 def flatten_decrypted_payloads (eddymsg_obj, get_signed_part):
373
374 flat_string = ""
375
376 if eddymsg_obj == None:
377 return ""
378
379 # recurse on multi-part mime
380 if eddymsg_obj.multipart == True:
381 for sub in eddymsg_obj.subparts:
382 flat_string += flatten_decrypted_payloads (sub)
383
384 return flat_string
385
386 for piece in eddymsg_obj.payload_pieces:
387 if piece.piece_type == "text":
388 flat_string += piece.string
389
390 if (get_signed_part):
391 # don't include nested encryption
392 elif (piece.piece_type == "message") \
393 and (piece.gpg_data != None) \
394 and (piece.gpg_data.decrypted == False):
395 flat_string += flatten_decrypted_payloads(piece.gpg_data.plainobj, get_signed_part)
396
397 elif ((piece.piece_type == "clearsign") \
398 or (piece.piece_type == "detachedsig")) \
399 and (piece.gpg_data != None):
400 # FIXME: the key used to sign this message needs to be the one that is used for the encrypted reply.
401 flat_string += flatten_decrypted_payloads (piece.gpg_data.plainobj, get_signed_part)
402
403 return flat_string
404
405
406 def get_key_from_fp (replyinfo_obj, gpgme_ctx):
407
408 if replyinfo_obj.target_key == None:
409 replyinfo_obj.target_key = replyinfo_obj.fallback_target_key
410
411 if replyinfo_obj.target_key != None:
412 try:
413 encrypt_to_key = gpgme_ctx.get_key(replyinfo_obj.target_key)
414 return encrypt_to_key
415
416 except:
417 pass
418
419 # no available key to use
420 replyinfo_obj.target_key = None
421 replyinfo_obj.fallback_target_key = None
422
423 replyinfo_obj.no_public_key = True
424 replyinfo_obj.public_key_received = False
425
426 return None
427
428
429 def write_reply (replyinfo_obj):
430
431 reply_plain = ""
432
433 if replyinfo_obj.success_decrypt == True:
434 reply_plain += replyinfo_obj.replies['success_decrypt']
435
436 if replyinfo_obj.no_public_key == False:
437 quoted_text = email_quote_text(replyinfo_obj.msg_to_quote)
438 reply_plain += quoted_text
439
440 elif replyinfo_obj.failed_decrypt == True:
441 reply_plain += replyinfo_obj.replies['failed_decrypt']
442
443
444 if replyinfo_obj.sig_success == True:
445 reply_plain += "\n\n"
446 reply_plain += replyinfo_obj.replies['sig_success']
447
448 elif replyinfo_obj.sig_failure == True:
449 reply_plain += "\n\n"
450 reply_plain += replyinfo_obj.replies['sig_failure']
451
452
453 if replyinfo_obj.public_key_received == True:
454 reply_plain += "\n\n"
455 reply_plain += replyinfo_obj.replies['public_key_received']
456
457 elif replyinfo_obj.no_public_key == True:
458 reply_plain += "\n\n"
459 reply_plain += replyinfo_obj.replies['no_public_key']
460
461
462 reply_plain += "\n\n"
463 reply_plain += replyinfo_obj.replies['signature']
464
465 return reply_plain
466
467
468 def add_gpg_key (key_block, gpgme_ctx):
469
470 fp = io.BytesIO(key_block.encode('ascii'))
471
472 result = gpgme_ctx.import_(fp)
473 imports = result.imports
474
475 key_fingerprints = []
476
477 if imports != []:
478 for import_ in imports:
479 fingerprint = import_[0]
480 key_fingerprints += [fingerprint]
481
482 debug("added gpg key: " + fingerprint)
483
484 return key_fingerprints
485
486
487 def verify_clear_signature (sig_block, gpgme_ctx):
488
489 # FIXME: this might require the un-decoded bytes
490 # or the correct re-encoding with the carset of the mime part.
491 msg_fp = io.BytesIO(sig_block.encode('utf-8'))
492 ptxt_fp = io.BytesIO()
493
494 result = gpgme_ctx.verify(msg_fp, None, ptxt_fp)
495
496 # FIXME: this might require using the charset of the mime part.
497 plaintext = ptxt_fp.getvalue().decode('utf-8')
498
499 sig_fingerprints = []
500 for res_ in result:
501 sig_fingerprints += [res_.fpr]
502
503 return plaintext, sig_fingerprints
504
505
506 def verify_detached_signature (detached_sig, plaintext_bytes, gpgme_ctx):
507
508 detached_sig_fp = io.BytesIO(detached_sig.encode('ascii'))
509 plaintext_fp = io.BytesIO(plaintext_bytes)
510 ptxt_fp = io.BytesIO()
511
512 result = gpgme_ctx.verify(detached_sig_fp, plaintext_fp, None)
513
514 sig_fingerprints = []
515 for res_ in result:
516 sig_fingerprints += [res_.fpr]
517
518 return sig_fingerprints
519
520
521 def decrypt_block (msg_block, gpgme_ctx):
522
523 block_b = io.BytesIO(msg_block.encode('ascii'))
524 plain_b = io.BytesIO()
525
526 try:
527 sigs = gpgme_ctx.decrypt_verify(block_b, plain_b)
528 except:
529 return ("",[])
530
531 plaintext = plain_b.getvalue().decode('utf-8')
532
533 fingerprints = []
534 for sig in sigs:
535 fingerprints += [sig.fpr]
536 return (plaintext, fingerprints)
537
538
539 def choose_reply_encryption_key (gpgme_ctx, fingerprints):
540
541 reply_key = None
542 for fp in fingerprints:
543 try:
544 key = gpgme_ctx.get_key(fp)
545
546 if (key.can_encrypt == True):
547 reply_key = key
548 break
549 except:
550 continue
551
552
553 return reply_key
554
555
556 def email_to_from_subject (email_text):
557
558 email_struct = email.parser.Parser().parsestr(email_text)
559
560 email_to = email_struct['To']
561 email_from = email_struct['From']
562 email_subject = email_struct['Subject']
563
564 return email_to, email_from, email_subject
565
566
567 def import_lang(email_to):
568
569 if email_to != None:
570 for lang in langs:
571 if "edward-" + lang in email_to:
572 lang = "lang." + re.sub('-', '_', lang)
573 language = importlib.import_module(lang)
574
575 return language
576
577 return importlib.import_module("lang.en")
578
579
580 def generate_encrypted_mime (plaintext, email_from, email_subject, encrypt_to_key,
581 gpgme_ctx):
582
583 # quoted printable encoding lets most ascii characters look normal
584 # before the decrypted mime message is decoded.
585 char_set = email.charset.Charset("utf-8")
586 char_set.body_encoding = email.charset.QP
587
588 # MIMEText doesn't allow setting the text encoding
589 # so we use MIMENonMultipart.
590 plaintext_mime = MIMENonMultipart('text', 'plain')
591 plaintext_mime.set_payload(plaintext, charset=char_set)
592
593 if (encrypt_to_key != None):
594
595 encrypted_text = encrypt_sign_message(plaintext_mime.as_string(),
596 encrypt_to_key,
597 gpgme_ctx)
598
599 control_mime = MIMEApplication("Version: 1",
600 _subtype='pgp-encrypted',
601 _encoder=email.encoders.encode_7or8bit)
602 control_mime['Content-Description'] = 'PGP/MIME version identification'
603 control_mime.set_charset('us-ascii')
604
605 encoded_mime = MIMEApplication(encrypted_text,
606 _subtype='octet-stream; name="encrypted.asc"',
607 _encoder=email.encoders.encode_7or8bit)
608 encoded_mime['Content-Description'] = 'OpenPGP encrypted message'
609 encoded_mime['Content-Disposition'] = 'inline; filename="encrypted.asc"'
610 encoded_mime.set_charset('us-ascii')
611
612 message_mime = MIMEMultipart(_subtype="encrypted", protocol="application/pgp-encrypted")
613 message_mime.attach(control_mime)
614 message_mime.attach(encoded_mime)
615 message_mime['Content-Disposition'] = 'inline'
616
617 else:
618 message_mime = plaintext_mime
619
620 message_mime['To'] = email_from
621 message_mime['Subject'] = email_subject
622
623 reply = message_mime.as_string()
624
625 return reply
626
627
628 def email_quote_text (text):
629
630 quoted_message = re.sub(r'^', r'> ', text, flags=re.MULTILINE)
631
632 return quoted_message
633
634
635 def encrypt_sign_message (plaintext, encrypt_to_key, gpgme_ctx):
636
637 plaintext_bytes = io.BytesIO(plaintext.encode('ascii'))
638 encrypted_bytes = io.BytesIO()
639
640 gpgme_ctx.encrypt_sign([encrypt_to_key], gpgme.ENCRYPT_ALWAYS_TRUST,
641 plaintext_bytes, encrypted_bytes)
642
643 encrypted_txt = encrypted_bytes.getvalue().decode('ascii')
644 return encrypted_txt
645
646
647 def error (error_msg):
648
649 sys.stderr.write(progname + ": " + str(error_msg) + "\n")
650
651
652 def debug (debug_msg):
653
654 if edward_config.debug == True:
655 error(debug_msg)
656
657
658 def handle_args ():
659 if __name__ == "__main__":
660
661 global progname
662 progname = sys.argv[0]
663
664 if len(sys.argv) > 1:
665 print(progname + ": error, this program doesn't " \
666 "need any arguments.", file=sys.stderr)
667 exit(1)
668
669
670 main()
671