summaryrefslogtreecommitdiff
path: root/alot
diff options
context:
space:
mode:
Diffstat (limited to 'alot')
-rw-r--r--alot/account.py12
-rw-r--r--alot/commands/envelope.py17
-rw-r--r--alot/crypto.py97
-rw-r--r--alot/db/envelope.py98
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.