partially generalized the testing suite
[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
35 import sys
36 import gpgme
37 import re
38 import io
39 import os
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 def main ():
52
53 handle_args()
54
55 gpgme_ctx = get_gpg_context(edward_config.gnupghome,
56 edward_config.sign_with_key)
57
58 email_text = sys.stdin.read()
59 email_from, email_subject = email_from_subject(email_text)
60
61 plaintext, keys = email_decode_flatten(email_text, gpgme_ctx, False)
62 encrypt_to_key = choose_reply_encryption_key(keys)
63
64 reply_message = generate_reply(plaintext, email_from, \
65 email_subject, encrypt_to_key,
66 gpgme_ctx)
67
68 print(reply_message)
69
70
71 def get_gpg_context (gnupghome, sign_with_key_fp):
72
73 os.environ['GNUPGHOME'] = gnupghome
74
75 gpgme_ctx = gpgme.Context()
76 gpgme_ctx.armor = True
77
78 try:
79 sign_with_key = gpgme_ctx.get_key(sign_with_key_fp)
80 except:
81 error("unable to load signing key. is the gnupghome "
82 + "and signing key properly set in the edward_config.py?")
83 exit(1)
84
85 gpgme_ctx.signers = [sign_with_key]
86
87 return gpgme_ctx
88
89
90 def email_decode_flatten (email_text, gpgme_ctx, from_decryption):
91
92 body = ""
93 keys = []
94
95 email_struct = email.parser.Parser().parsestr(email_text)
96
97 for subpart in email_struct.walk():
98
99 payload, description, filename, content_type \
100 = get_email_subpart_info(subpart)
101
102 if payload == "":
103 continue
104
105 if content_type == "multipart":
106 continue
107
108 if content_type == "application/pgp-encrypted":
109 if ((description == "PGP/MIME version identification")
110 and (payload.strip() != "Version: 1")):
111 debug("Warning: unknown " + description
112 + ": " + payload.strip())
113 # ignore the version number
114 continue
115
116 if (filename == "encrypted.asc") or (content_type == "pgp/mime"):
117 plaintext, more_keys = decrypt_text(payload, gpgme_ctx)
118
119 body += plaintext
120 keys += more_keys
121
122 elif content_type == "application/pgp-keys":
123 keys += add_gpg_keys(payload, gpgme_ctx)
124
125 elif content_type == "text/plain":
126 if from_decryption == True:
127 body += payload + "\n"
128
129 keys += add_gpg_keys(payload, gpgme_ctx)
130
131 else:
132 plaintext, more_keys = decrypt_text(payload, gpgme_ctx)
133
134 body += plaintext
135 keys += more_keys
136
137 keys += add_gpg_keys(payload, gpgme_ctx)
138
139 return body, keys
140
141
142 def email_from_subject (email_text):
143
144 email_struct = email.parser.Parser().parsestr(email_text)
145
146 email_from = email_struct['From']
147 email_subject = email_struct['Subject']
148
149 return email_from, email_subject
150
151
152 def get_email_subpart_info (part):
153
154 charset = part.get_content_charset()
155 payload_bytes = part.get_payload(decode=True)
156
157 filename = part.get_filename()
158 content_type = part.get_content_type()
159 description_list = part.get_params(header='content-description')
160
161 if charset == None:
162 charset = 'utf-8'
163
164 if payload_bytes != None:
165 payload = payload_bytes.decode(charset)
166 else:
167 payload = ""
168
169 if description_list != None:
170 description = description_list[0][0]
171 else:
172 description = ""
173
174 return payload, description, filename, content_type
175
176
177 def add_gpg_keys (text, gpgme_ctx):
178
179 key_blocks = scan_and_grab(text,
180 '-----BEGIN PGP PUBLIC KEY BLOCK-----',
181 '-----END PGP PUBLIC KEY BLOCK-----')
182
183 keys = []
184 for key_block in key_blocks:
185 fp = io.BytesIO(key_block.encode('ascii'))
186
187 result = gpgme_ctx.import_(fp)
188 imports = result.imports
189
190 if imports != []:
191 fingerprint = imports[0][0]
192 keys += [gpgme_ctx.get_key(fingerprint)]
193
194 debug("added gpg key: " + fingerprint)
195
196 return keys
197
198
199 def decrypt_text (gpg_text, gpgme_ctx):
200
201 body = ""
202 keys = []
203
204 msg_blocks = scan_and_grab(gpg_text,
205 '-----BEGIN PGP MESSAGE-----',
206 '-----END PGP MESSAGE-----')
207
208 plaintexts_and_sigs = decrypt_blocks(msg_blocks, gpgme_ctx)
209
210 for pair in plaintexts_and_sigs:
211 plaintext = pair[0]
212 sigs = pair[1]
213
214 for sig in sigs:
215 keys += [gpgme_ctx.get_key(sig.fpr)]
216
217 # recursive for nested layers of mime and/or gpg
218 plaintext, more_keys = email_decode_flatten(plaintext, gpgme_ctx, True)
219
220 body += plaintext
221 keys += more_keys
222
223 return body, keys
224
225
226 def scan_and_grab (text, start_text, end_text):
227
228 matches = re.search('(' + start_text + '.*' + end_text + ')',
229 text, flags=re.DOTALL)
230
231 if matches != None:
232 match_tuple = matches.groups()
233 else:
234 match_tuple = ()
235
236 return match_tuple
237
238
239 def decrypt_blocks (msg_blocks, gpgme_ctx):
240
241 return [decrypt_block(block, gpgme_ctx) for block in msg_blocks]
242
243
244 def decrypt_block (msg_block, gpgme_ctx):
245
246 block_b = io.BytesIO(msg_block.encode('ascii'))
247 plain_b = io.BytesIO()
248
249 try:
250 sigs = gpgme_ctx.decrypt_verify(block_b, plain_b)
251 except:
252 return ("",[])
253
254 plaintext = plain_b.getvalue().decode('utf-8')
255 return (plaintext, sigs)
256
257
258 def choose_reply_encryption_key (keys):
259
260 reply_key = None
261 for key in keys:
262 if (key.can_encrypt == True):
263 reply_key = key
264 break
265
266 return reply_key
267
268
269 def generate_reply (plaintext, email_from, email_subject, encrypt_to_key,
270 gpgme_ctx):
271
272
273 reply = "To: " + email_from + "\n"
274 reply += "Subject: " + email_subject + "\n"
275
276 if (encrypt_to_key != None):
277 plaintext_reply = "thanks for the message!\n\n\n"
278 plaintext_reply += email_quote_text(plaintext)
279
280 # quoted printable encoding lets most ascii characters look normal
281 # before the decrypted mime message is decoded.
282 char_set = email.charset.Charset("utf-8")
283 char_set.body_encoding = email.charset.QP
284
285 # MIMEText doesn't allow setting the text encoding
286 # so we use MIMENonMultipart.
287 plaintext_mime = MIMENonMultipart('text', 'plain')
288 plaintext_mime.set_payload(plaintext_reply, charset=char_set)
289
290 encrypted_text = encrypt_sign_message(plaintext_mime.as_string(),
291 encrypt_to_key,
292 gpgme_ctx)
293
294 control_mime = MIMEApplication("Version: 1",
295 _subtype='pgp-encrypted',
296 _encoder=email.encoders.encode_7or8bit)
297 control_mime['Content-Description'] = 'PGP/MIME version identification'
298 control_mime.set_charset('us-ascii')
299
300 encoded_mime = MIMEApplication(encrypted_text,
301 _subtype='octet-stream; name="encrypted.asc"',
302 _encoder=email.encoders.encode_7or8bit)
303 encoded_mime['Content-Description'] = 'OpenPGP encrypted message'
304 encoded_mime['Content-Disposition'] = 'inline; filename="encrypted.asc"'
305 encoded_mime.set_charset('us-ascii')
306
307 message_mime = MIMEMultipart(_subtype="encrypted", protocol="application/pgp-encrypted")
308 message_mime.attach(control_mime)
309 message_mime.attach(encoded_mime)
310 message_mime['Content-Disposition'] = 'inline'
311
312 reply += message_mime.as_string()
313
314 else:
315 reply += "\n"
316 reply += "Sorry, i couldn't find your key.\n"
317 reply += "I'll need that to encrypt a message to you."
318
319 return reply
320
321
322 def email_quote_text (text):
323
324 quoted_message = re.sub(r'^', r'> ', text, flags=re.MULTILINE)
325
326 return quoted_message
327
328
329 def encrypt_sign_message (plaintext, encrypt_to_key, gpgme_ctx):
330
331 plaintext_bytes = io.BytesIO(plaintext.encode('ascii'))
332 encrypted_bytes = io.BytesIO()
333
334 gpgme_ctx.encrypt_sign([encrypt_to_key], gpgme.ENCRYPT_ALWAYS_TRUST,
335 plaintext_bytes, encrypted_bytes)
336
337 encrypted_txt = encrypted_bytes.getvalue().decode('ascii')
338 return encrypted_txt
339
340
341 def error (error_msg):
342
343 sys.stderr.write(progname + ": " + error_msg + "\n")
344
345
346 def debug (debug_msg):
347
348 if edward_config.debug == True:
349 error(debug_msg)
350
351
352 def handle_args ():
353 if __name__ == "__main__":
354
355 global progname
356 progname = sys.argv[0]
357
358 if len(sys.argv) > 1:
359 print(progname + ": error, this program doesn't " \
360 "need any arguments.", file=sys.stderr)
361 exit(1)
362
363
364 main()
365