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