diff options
Diffstat (limited to 'alot')
-rw-r--r-- | alot/account.py | 12 | ||||
-rw-r--r-- | alot/commands/envelope.py | 17 | ||||
-rw-r--r-- | alot/crypto.py | 97 | ||||
-rw-r--r-- | alot/db/envelope.py | 98 |
4 files changed, 209 insertions, 15 deletions
diff --git a/alot/account.py b/alot/account.py index d7273707..a480e096 100644 --- a/alot/account.py +++ b/alot/account.py @@ -5,8 +5,11 @@ import email import os import glob import shlex +from email.generator import Generator +from cStringIO import StringIO from alot.helper import call_cmd_async +import alot.crypto as crypto class SendingMailFailed(RuntimeError): @@ -163,7 +166,14 @@ class SendmailAccount(Account): logging.error(failure.value.stderr) raise SendingMailFailed(errmsg) - d = call_cmd_async(cmdlist, stdin=mail.as_string()) + # Converting inner_msg to text with as_string() mangles lines + # beginning with "From", therefore we do it the hard way. + fp = StringIO() + g = Generator(fp, mangle_from_=False) + g.flatten(mail) + mailtext = crypto.RFC3156_canonicalize(fp.getvalue()) + + d = call_cmd_async(cmdlist, stdin=mailtext) d.addCallback(cb) d.addErrback(errb) return d diff --git a/alot/commands/envelope.py b/alot/commands/envelope.py index 21331ca8..642fa6a9 100644 --- a/alot/commands/envelope.py +++ b/alot/commands/envelope.py @@ -132,7 +132,22 @@ class SendCommand(Command): # send clearme = ui.notify('sending..', timeout=-1) - mail = envelope.construct_mail() + + # We wrap around construct_mail(), which is a generator. Since + # construct_mail() returns any amount of twisted.defer objects before + # actually returning the email as last object, we need to pass these + # defer objects to the main loop and send the return value back. + g = envelope.construct_mail(ui) + last_result = None + while True: + try: + mail = g.send(last_result) + last_result = yield mail + except StopIteration: + break + + if mail is None: + return def afterwards(returnvalue): logging.debug('mail sent successfully') diff --git a/alot/crypto.py b/alot/crypto.py new file mode 100644 index 00000000..47eeb7ed --- /dev/null +++ b/alot/crypto.py @@ -0,0 +1,97 @@ +# vim:ts=4:sw=4:expandtab +import re + +import pyme.core +import pyme.constants + + +def _engine_file_name_by_protocol(engines, protocol): + for engine in engines: + if engine.protocol == protocol: + return engine.file_name + return None + + +def RFC3156_micalg_from_result(result): + """ + Converts a GPGME hash algorithm name to one conforming to RFC3156. + + GPGME returns hash algorithm names such as "SHA256", but RFC3156 says that + programs need to use names such as "pgp-sha256" instead. + + :param result: GPGME op_sign_result() return value + :rtype: str + """ + # hash_algo will be something like SHA256, but we need pgp-sha256. + hash_algo = pyme.core.hash_algo_name(result.signatures[0].hash_algo) + return 'pgp-' + hash_algo.lower() + + +def RFC3156_canonicalize(text): + """ + Canonicalizes plain text (MIME-encoded usually) according to RFC3156. + + This function works as follows (in that order): + + 1. Convert all line endings to \\\\r\\\\n (DOS line endings). + 2. Ensure the text ends with a newline (\\\\r\\\\n). + 3. Encode all occurences of "From " at the beginning of a line + to "From=20" in order to prevent other mail programs to replace + this with "> From" (to avoid MBox conflicts) and thus invalidate + the signature. + + :param text: text to canonicalize (already encoded as quoted-printable) + :rtype: str + """ + text = re.sub("\r?\n", "\r\n", text) + if not text.endswith("\r\n"): + text += "\r\n" + text = re.sub("^From ", "From=20", text, flags=re.MULTILINE) + return text + + +class CryptoContext(pyme.core.Context): + """ + This is a wrapper around pyme.core.Context which simplifies the pyme API. + """ + def __init__(self): + pyme.core.Context.__init__(self) + gpg_path = _engine_file_name_by_protocol(pyme.core.get_engine_info(), + pyme.constants.PROTOCOL_OpenPGP) + if not gpg_path: + # TODO: proper exception + raise "no GPG engine found" + + self.set_engine_info(pyme.constants.PROTOCOL_OpenPGP, gpg_path) + self.set_armor(1) + + def detached_signature_for(self, plaintext_str): + """ + Signs the given plaintext string and returns the detached signature. + + A detached signature in GPG speak is a separate blob of data containing + a signature for the specified plaintext. + + .. note:: You should use #set_passphrase_cb before calling this method + if gpg-agent is not running. + :: + + context = crypto.CryptoContext() + def gpg_passphrase_cb(hint, desc, prev_bad): + return raw_input("Passphrase for key " + hint + ":") + context.set_passphrase_cb(gpg_passphrase_cb) + result, signature = context.detached_signature_for('Hello World') + if result is None: + return + + :param plaintext_str: text to sign + :rtype: tuple of pyme.pygpgme._gpgme_op_sign_result and str + """ + plaintext_data = pyme.core.Data(plaintext_str) + signature_data = pyme.core.Data() + self.op_sign(plaintext_data, signature_data, + pyme.pygpgme.GPGME_SIG_MODE_DETACH) + result = self.op_sign_result() + signature_data.seek(0, 0) + signature = signature_data.read() + return result, signature diff --git a/alot/db/envelope.py b/alot/db/envelope.py index 6b176016..2db113af 100644 --- a/alot/db/envelope.py +++ b/alot/db/envelope.py @@ -1,14 +1,24 @@ +# vim:ts=4:sw=4:expandtab import os import email import re import email.charset as charset charset.add_charset('utf-8', charset.QP, charset.QP, 'utf-8') +from email.encoders import encode_7or8bit from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart +from email.mime.application import MIMEApplication +from email.generator import Generator +from cStringIO import StringIO +from twisted.internet import reactor, threads +import pyme.core +import pyme.constants +import pyme.errors from alot import __version__ import logging import alot.helper as helper +import alot.crypto as crypto from alot.settings import settings from attachment import Attachment @@ -128,20 +138,86 @@ class Envelope(object): if self.sent_time: self.modified_since_sent = True - def construct_mail(self): + def construct_mail(self, ui): """ compiles the information contained in this envelope into a :class:`email.Message`. """ - # build body text part - textpart = MIMEText(self.body.encode('utf-8'), 'plain', 'utf-8') + # Build body text part. To properly sign/encrypt messages later on, we + # convert the text to its canonical format (as per RFC 2015). + canonical_format = self.body.encode('utf-8') + canonical_format = canonical_format.replace('\\t', ' '*4) + textpart = MIMEText(canonical_format, 'plain', 'utf-8') + + # XXX: for now + self.sign = True # wrap it in a multipart container if necessary - if self.attachments or self.sign or self.encrypt: - msg = MIMEMultipart() - msg.attach(textpart) + if self.attachments: + inner_msg = MIMEMultipart() + inner_msg.attach(textpart) + # add attachments + for a in self.attachments: + inner_msg.attach(a.get_mime_representation()) + else: + inner_msg = textpart + + if self.sign: + context = crypto.CryptoContext() + + # We sign in a different thread so that twisted/urwid can continue + # to run. In case the passphrase callback is called, we call + # ui.prompt() blockingly from within the thread. When the thread is + # done, the defer object returned by deferToThread() will be called + # with the result of our operation. + def actuallySign(): + def gpg_passphrase_cb(hint, desc, prev_bad): + logging.info('requesting passphrase') + result = threads.blockingCallFromThread(reactor, + ui.prompt, 'Passphrase for ' + hint) + logging.info('in cb, passphrase = ' + str(result)) + return result + context.set_passphrase_cb(gpg_passphrase_cb) + + # Converting inner_msg to text with as_string() mangles lines + # beginning with "From", therefore we do it the hard way. + fp = StringIO() + g = Generator(fp, mangle_from_=False) + g.flatten(inner_msg) + plaintext = crypto.RFC3156_canonicalize(fp.getvalue()) + logging.info('signing plaintext: ' + plaintext) + + try: + return context.detached_signature_for(plaintext) + except pyme.errors.GPGMEError as e: + threads.blockingCallFromThread(reactor, + ui.notify, 'GPG Error: ' + str(e), priority='error') + return None, None + + result, signature_str = yield threads.deferToThread(actuallySign) + if result is None or len(result.signatures) != 1: + # Ensure that the last value returned by our generator is None, + # then stop the generator. + yield None + return + + micalg = crypto.RFC3156_micalg_from_result(result) + outer_msg = MIMEMultipart('signed', micalg=micalg, + protocol='application/pgp-signature') + + # wrap signature in MIMEcontainter + signature_mime = MIMEApplication(_data=signature_str, + _subtype='pgp-signature; name="signature.asc"', + _encoder=encode_7or8bit) + signature_mime['Content-Description'] = 'signature' + signature_mime.set_charset('us-ascii') + + # add signed message and signature to outer message + outer_msg.attach(inner_msg) + outer_msg.attach(signature_mime) + outer_msg['Content-Disposition'] = 'inline' else: - msg = textpart + outer_msg = inner_msg headers = self.headers.copy() # add Message-ID @@ -159,13 +235,9 @@ class Envelope(object): # copy headers from envelope to mail for k, vlist in headers.items(): for v in vlist: - msg[k] = encode_header(k, v) - - # add attachments - for a in self.attachments: - msg.attach(a.get_mime_representation()) + outer_msg[k] = encode_header(k, v) - return msg + yield outer_msg def parse_template(self, tmp, reset=False, only_body=False): """parses a template or user edited string to fills this envelope. |