don't crash if a byte string can't be decoded
[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
40 import email.parser
41 import email.message
42 import email.encoders
43
44 from email.mime.multipart import MIMEMultipart
45 from email.mime.application import MIMEApplication
46 from email.mime.nonmultipart import MIMENonMultipart
47
48 import edward_config
49
50 match_types = [('encrypted',
51 '-----BEGIN PGP MESSAGE-----.*?-----END PGP MESSAGE-----'),
52 ('pubkey',
53 '-----BEGIN PGP PUBLIC KEY BLOCK-----.*?-----END PGP PUBLIC KEY BLOCK-----'),
54 ('detachedsig',
55 '-----END PGP SIGNATURE-----.*?-----BEGIN PGP SIGNATURE-----'),
56 ('clearsign',
57 '-----BEGIN PGP SIGNED MESSAGE-----.*?-----BEGIN PGP SIGNATURE-----.*?-----END PGP SIGNATURE-----')]
58
59
60 class EddyMsg (object):
61 def __init__(self):
62 self.multipart = False
63 self.subparts = []
64
65 self.charset = None
66 self.payload_bytes = None
67 self.payload_pieces = []
68
69 self.filename = None
70 self.content_type = None
71 self.description_list = None
72
73
74 class PayloadPiece (object):
75 def __init__(self):
76 self.piece_type = None
77 self.string = None
78 self.gpg_data = None
79
80
81 def main ():
82
83 handle_args()
84
85 gpgme_ctx = get_gpg_context(edward_config.gnupghome,
86 edward_config.sign_with_key)
87
88 email_text = sys.stdin.read()
89 result = parse_pgp_mime(email_text)
90
91 email_from, email_subject = email_from_subject(email_text)
92
93 # plaintext, fingerprints = email_decode_flatten(email_text, gpgme_ctx, False)
94 # encrypt_to_key = choose_reply_encryption_key(gpgme_ctx, fingerprints)
95 #
96 # reply_message = generate_reply(plaintext, email_from, \
97 # email_subject, encrypt_to_key,
98 # gpgme_ctx)
99
100 print(flatten_eddy(result))
101
102
103 def get_gpg_context (gnupghome, sign_with_key_fp):
104
105 os.environ['GNUPGHOME'] = gnupghome
106
107 gpgme_ctx = gpgme.Context()
108 gpgme_ctx.armor = True
109
110 try:
111 sign_with_key = gpgme_ctx.get_key(sign_with_key_fp)
112 except:
113 error("unable to load signing key. is the gnupghome "
114 + "and signing key properly set in the edward_config.py?")
115 exit(1)
116
117 gpgme_ctx.signers = [sign_with_key]
118
119 return gpgme_ctx
120
121
122 def parse_pgp_mime (email_text):
123
124 email_struct = email.parser.Parser().parsestr(email_text)
125
126 eddy_obj = parse_mime(email_struct)
127 eddy_obj = split_payloads(eddy_obj)
128
129 return eddy_obj
130
131
132 def parse_mime(msg_struct):
133
134 eddy_obj = EddyMsg()
135
136 if msg_struct.is_multipart() == True:
137 payloads = msg_struct.get_payload()
138
139 eddy_obj.multipart = True
140 eddy_obj.subparts = map(parse_mime, payloads)
141 else:
142 eddy_obj = get_subpart_data(msg_struct)
143
144 return eddy_obj
145
146
147 def split_payloads (eddy_obj):
148
149 if eddy_obj.multipart == True:
150 eddy_obj.subparts = map(split_payloads, eddy_obj.subparts)
151
152 else:
153 for (match_type, pattern) in match_types:
154
155 new_pieces_list = []
156 for payload_piece in eddy_obj.payload_pieces:
157 new_pieces_list += scan_and_split(payload_piece,
158 match_type, pattern)
159 eddy_obj.payload_pieces = new_pieces_list
160
161 return eddy_obj
162
163
164 def scan_and_split (payload_piece, match_type, pattern):
165
166 flags = re.DOTALL | re.MULTILINE
167 matches = re.search("(?P<beginning>.*?)(?P<match>" + pattern +
168 ")(?P<rest>.*)", payload_piece.string, flags=flags)
169
170 if matches == None:
171 pieces = [payload_piece]
172
173 else:
174
175 beginning = PayloadPiece()
176 beginning.string = matches.group('beginning')
177 beginning.piece_type = payload_piece.piece_type
178
179 match = PayloadPiece()
180 match.string = matches.group('match')
181 match.piece_type = match_type
182
183 rest = PayloadPiece()
184 rest.string = matches.group('rest')
185 rest.piece_type = payload_piece.piece_type
186
187 more_pieces = scan_and_split(rest, match_type, pattern)
188
189 if more_pieces == None:
190 pieces = [beginning, match, rest]
191 else:
192 pieces = [beginning, match] + more_pieces
193
194 return pieces
195
196
197 def get_subpart_data (part):
198
199 obj = EddyMsg()
200
201 obj.charset = part.get_content_charset()
202 obj.payload_bytes = part.get_payload(decode=True)
203
204 obj.filename = part.get_filename()
205 obj.content_type = part.get_content_type()
206 obj.description_list = part['content-description']
207
208 # your guess is as good as a-myy-ee-ine...
209 if obj.charset == None:
210 obj.charset = 'utf-8'
211
212 if obj.payload_bytes != None:
213 try:
214 payload = PayloadPiece()
215 payload.string = obj.payload_bytes.decode(obj.charset)
216 payload.piece_type = 'text'
217
218 obj.payload_pieces = [payload]
219 except UnicodeDecodeError:
220 pass
221
222 return obj
223
224
225 def flatten_eddy (eddy_obj):
226
227 if eddy_obj.multipart == True:
228 string = "\n".join(map(flatten_eddy, eddy_obj.subparts))
229 else:
230 string = flatten_payload_piece(eddy_obj.payload_pieces)
231
232 return string
233
234
235 def flatten_payload_piece (payload_pieces):
236
237 string = ""
238 for piece in payload_pieces:
239 string += piece.string
240
241 return string
242
243
244 def email_from_subject (email_text):
245
246 email_struct = email.parser.Parser().parsestr(email_text)
247
248 email_from = email_struct['From']
249 email_subject = email_struct['Subject']
250
251 return email_from, email_subject
252
253
254 def add_gpg_keys (text, gpgme_ctx):
255
256 key_blocks = scan_and_grab(text,
257 '-----BEGIN PGP PUBLIC KEY BLOCK-----',
258 '-----END PGP PUBLIC KEY BLOCK-----')
259
260 fingerprints = []
261 for key_block in key_blocks:
262 fp = io.BytesIO(key_block.encode('ascii'))
263
264 result = gpgme_ctx.import_(fp)
265 imports = result.imports
266
267 if imports != []:
268 fingerprint = imports[0][0]
269 fingerprints += [fingerprint]
270
271 debug("added gpg key: " + fingerprint)
272
273 return fingerprints
274
275
276 def decrypt_text (gpg_text, gpgme_ctx):
277
278 body = ""
279 fingerprints = []
280
281 msg_blocks = scan_and_grab(gpg_text,
282 '-----BEGIN PGP MESSAGE-----',
283 '-----END PGP MESSAGE-----')
284
285 plaintexts_and_sigs = decrypt_blocks(msg_blocks, gpgme_ctx)
286
287 for pair in plaintexts_and_sigs:
288 plaintext = pair[0]
289 sigs = pair[1]
290
291 for sig in sigs:
292 fingerprints += [sig.fpr]
293
294 # recursive for nested layers of mime and/or gpg
295 plaintext, more_fps = email_decode_flatten(plaintext, gpgme_ctx, True)
296
297 body += plaintext
298 fingerprints += more_fps
299
300 return body, fingerprints
301
302
303 def verify_clear_signature (text, gpgme_ctx):
304
305 sig_blocks = scan_and_grab(text,
306 '-----BEGIN PGP SIGNED MESSAGE-----',
307 '-----END PGP SIGNATURE-----')
308
309 fingerprints = []
310 plaintext = ""
311
312 for sig_block in sig_blocks:
313 msg_fp = io.BytesIO(sig_block.encode('utf-8'))
314 ptxt_fp = io.BytesIO()
315
316 result = gpgme_ctx.verify(msg_fp, None, ptxt_fp)
317
318 plaintext += ptxt_fp.getvalue().decode('utf-8')
319 fingerprint = result[0].fpr
320
321 fingerprints += [fingerprint]
322
323 return plaintext, fingerprints
324
325
326 def scan_and_grab (text, start_text, end_text):
327
328 matches = re.search('(' + start_text + '.*' + end_text + ')',
329 text, flags=re.DOTALL)
330
331 if matches != None:
332 match_tuple = matches.groups()
333 else:
334 match_tuple = ()
335
336 return match_tuple
337
338
339 def decrypt_blocks (msg_blocks, gpgme_ctx):
340
341 return [decrypt_block(block, gpgme_ctx) for block in msg_blocks]
342
343
344 def decrypt_block (msg_block, gpgme_ctx):
345
346 block_b = io.BytesIO(msg_block.encode('ascii'))
347 plain_b = io.BytesIO()
348
349 try:
350 sigs = gpgme_ctx.decrypt_verify(block_b, plain_b)
351 except:
352 return ("",[])
353
354 plaintext = plain_b.getvalue().decode('utf-8')
355 return (plaintext, sigs)
356
357
358 def choose_reply_encryption_key (gpgme_ctx, fingerprints):
359
360 reply_key = None
361 for fp in fingerprints:
362 try:
363 key = gpgme_ctx.get_key(fp)
364
365 if (key.can_encrypt == True):
366 reply_key = key
367 break
368 except:
369 continue
370
371
372 return reply_key
373
374
375 def generate_reply (plaintext, email_from, email_subject, encrypt_to_key,
376 gpgme_ctx):
377
378
379 reply = "To: " + email_from + "\n"
380 reply += "Subject: " + email_subject + "\n"
381
382 if (encrypt_to_key != None):
383 plaintext_reply = "thanks for the message!\n\n\n"
384 plaintext_reply += email_quote_text(plaintext)
385
386 # quoted printable encoding lets most ascii characters look normal
387 # before the decrypted mime message is decoded.
388 char_set = email.charset.Charset("utf-8")
389 char_set.body_encoding = email.charset.QP
390
391 # MIMEText doesn't allow setting the text encoding
392 # so we use MIMENonMultipart.
393 plaintext_mime = MIMENonMultipart('text', 'plain')
394 plaintext_mime.set_payload(plaintext_reply, charset=char_set)
395
396 encrypted_text = encrypt_sign_message(plaintext_mime.as_string(),
397 encrypt_to_key,
398 gpgme_ctx)
399
400 control_mime = MIMEApplication("Version: 1",
401 _subtype='pgp-encrypted',
402 _encoder=email.encoders.encode_7or8bit)
403 control_mime['Content-Description'] = 'PGP/MIME version identification'
404 control_mime.set_charset('us-ascii')
405
406 encoded_mime = MIMEApplication(encrypted_text,
407 _subtype='octet-stream; name="encrypted.asc"',
408 _encoder=email.encoders.encode_7or8bit)
409 encoded_mime['Content-Description'] = 'OpenPGP encrypted message'
410 encoded_mime['Content-Disposition'] = 'inline; filename="encrypted.asc"'
411 encoded_mime.set_charset('us-ascii')
412
413 message_mime = MIMEMultipart(_subtype="encrypted", protocol="application/pgp-encrypted")
414 message_mime.attach(control_mime)
415 message_mime.attach(encoded_mime)
416 message_mime['Content-Disposition'] = 'inline'
417
418 reply += message_mime.as_string()
419
420 else:
421 reply += "\n"
422 reply += "Sorry, i couldn't find your key.\n"
423 reply += "I'll need that to encrypt a message to you."
424
425 return reply
426
427
428 def email_quote_text (text):
429
430 quoted_message = re.sub(r'^', r'> ', text, flags=re.MULTILINE)
431
432 return quoted_message
433
434
435 def encrypt_sign_message (plaintext, encrypt_to_key, gpgme_ctx):
436
437 plaintext_bytes = io.BytesIO(plaintext.encode('ascii'))
438 encrypted_bytes = io.BytesIO()
439
440 gpgme_ctx.encrypt_sign([encrypt_to_key], gpgme.ENCRYPT_ALWAYS_TRUST,
441 plaintext_bytes, encrypted_bytes)
442
443 encrypted_txt = encrypted_bytes.getvalue().decode('ascii')
444 return encrypted_txt
445
446
447 def error (error_msg):
448
449 sys.stderr.write(progname + ": " + error_msg + "\n")
450
451
452 def debug (debug_msg):
453
454 if edward_config.debug == True:
455 error(debug_msg)
456
457
458 def handle_args ():
459 if __name__ == "__main__":
460
461 global progname
462 progname = sys.argv[0]
463
464 if len(sys.argv) > 1:
465 print(progname + ": error, this program doesn't " \
466 "need any arguments.", file=sys.stderr)
467 exit(1)
468
469
470 main()
471