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