diff options
author | Patrick Totzke <patricktotzke@gmail.com> | 2012-05-17 15:47:23 +0100 |
---|---|---|
committer | Patrick Totzke <patricktotzke@gmail.com> | 2012-05-17 15:47:23 +0100 |
commit | 08c2ee1d88e69aa5df27b74d1cebd3c0c8be68f6 (patch) | |
tree | 2dde7d7db9146918242f2c289c64e587706b3f09 /alot | |
parent | 138f13c682f6a63da67cced1f0c267b5f6f349b8 (diff) | |
parent | 56b0b8a0f310403d4664649ab9b69d3cd765dbe5 (diff) |
Merge branch '0.3-feature-pyme' into staging
Conflicts:
alot/settings/__init__.py
alot/settings/checks.py
docs/source/generate_configs.py
Diffstat (limited to 'alot')
-rw-r--r-- | alot/account.py | 6 | ||||
-rw-r--r-- | alot/buffers.py | 8 | ||||
-rw-r--r-- | alot/commands/envelope.py | 66 | ||||
-rw-r--r-- | alot/commands/globals.py | 14 | ||||
-rw-r--r-- | alot/crypto.py | 140 | ||||
-rw-r--r-- | alot/db/envelope.py | 81 | ||||
-rw-r--r-- | alot/db/errors.py | 1 | ||||
-rw-r--r-- | alot/defaults/alot.rc.spec | 6 | ||||
-rw-r--r-- | alot/defaults/config.stub | 1 | ||||
-rw-r--r-- | alot/errors.py | 3 | ||||
-rw-r--r-- | alot/settings/__init__.py | 7 | ||||
-rw-r--r-- | alot/settings/checks.py | 15 |
12 files changed, 326 insertions, 22 deletions
diff --git a/alot/account.py b/alot/account.py index d7273707..b65bf3cb 100644 --- a/alot/account.py +++ b/alot/account.py @@ -7,6 +7,7 @@ import glob import shlex from alot.helper import call_cmd_async +import alot.crypto as crypto class SendingMailFailed(RuntimeError): @@ -47,7 +48,7 @@ class Account(object): gpg_key=None, signature=None, signature_filename=None, signature_as_attachment=False, sent_box=None, sent_tags=['sent'], draft_box=None, draft_tags=['draft'], - abook=None, **rest): + abook=None, sign_by_default=False, **rest): self.address = address self.aliases = aliases self.realname = realname @@ -55,6 +56,7 @@ class Account(object): self.signature = signature self.signature_filename = signature_filename self.signature_as_attachment = signature_as_attachment + self.sign_by_default = sign_by_default self.sent_box = sent_box self.sent_tags = sent_tags self.draft_box = draft_box @@ -163,7 +165,7 @@ class SendmailAccount(Account): logging.error(failure.value.stderr) raise SendingMailFailed(errmsg) - d = call_cmd_async(cmdlist, stdin=mail.as_string()) + d = call_cmd_async(cmdlist, stdin=crypto.email_as_string(mail)) d.addCallback(cb) d.addErrback(errb) return d diff --git a/alot/buffers.py b/alot/buffers.py index 1e20d85a..bb1cd156 100644 --- a/alot/buffers.py +++ b/alot/buffers.py @@ -118,6 +118,14 @@ class EnvelopeBuffer(Buffer): for value in vlist: lines.append((k, value)) + # sign/encrypt lines + if self.envelope.sign: + description = 'Yes' + sign_key = self.envelope.sign_key + if sign_key is not None and len(sign_key.subkeys) > 0: + description += ', with key ' + sign_key.subkeys[0].keyid + lines.append(('GPG sign', description)) + # add header list widget iff header values exists if lines: key_att = settings.get_theming_attribute('envelope', 'header_key') diff --git a/alot/commands/envelope.py b/alot/commands/envelope.py index c906bef4..70547075 100644 --- a/alot/commands/envelope.py +++ b/alot/commands/envelope.py @@ -1,3 +1,4 @@ +import argparse import os import re import glob @@ -8,8 +9,10 @@ from twisted.internet.defer import inlineCallbacks import datetime from alot.account import SendingMailFailed +from alot.errors import GPGProblem from alot import buffers from alot import commands +from alot import crypto from alot.commands import Command, registerCommand from alot.commands import globals from alot.helper import string_decode @@ -130,9 +133,20 @@ class SendCommand(Command): else: account = settings.get_accounts()[0] + clearme = ui.notify(u'constructing mail (GPG, attachments)\u2026', + timeout=-1) + + try: + mail = envelope.construct_mail() + except GPGProblem, e: + ui.clear_notify([clearme]) + ui.notify(e.message, priority='error') + return + + ui.clear_notify([clearme]) + # send clearme = ui.notify('sending..', timeout=-1) - mail = envelope.construct_mail() def afterwards(returnvalue): logging.debug('mail sent successfully') @@ -317,3 +331,53 @@ class ToggleHeaderCommand(Command): """toggle display of all headers""" def apply(self, ui): ui.current_buffer.toggle_all_headers() + + +@registerCommand(MODE, 'sign', forced={'action': 'sign'}, arguments=[ + (['keyid'], {'nargs':argparse.REMAINDER, 'help':'which key id to use'})], + help='mark mail to be signed before sending') +@registerCommand(MODE, 'unsign', forced={'action': 'unsign'}, + help='mark mail not to be signed before sending') +@registerCommand(MODE, 'togglesign', forced={'action': 'toggle'}, arguments=[ + (['keyid'], {'nargs':argparse.REMAINDER, 'help':'which key id to use'})], + help='toggle sign status') +class SignCommand(Command): + """toggle signing this email""" + def __init__(self, action=None, keyid=None, **kwargs): + """ + :param action: whether to sign/unsign/toggle + :type action: str + :param keyid: which key id to use + :type keyid: str + """ + self.action = action + self.keyid = keyid + Command.__init__(self, **kwargs) + + def apply(self, ui): + sign = None + key = None + envelope = ui.current_buffer.envelope + # sign status + if self.action == 'sign': + sign = True + elif self.action == 'unsign': + sign = False + elif self.action == 'toggle': + sign = not envelope.sign + envelope.sign = sign + + # try to find key if hint given as parameter + if sign: + if len(self.keyid) > 0: + keyid = str(' '.join(self.keyid)) + try: + key = crypto.CryptoContext().get_key(keyid) + except GPGProblem, e: + envelope.sign = False + ui.notify(e.message, priority='error') + return + envelope.sign_key = key + + # reload buffer + ui.current_buffer.rebuild() diff --git a/alot/commands/globals.py b/alot/commands/globals.py index b0956ddb..65609996 100644 --- a/alot/commands/globals.py +++ b/alot/commands/globals.py @@ -17,12 +17,14 @@ from alot.commands import commandfactory from alot import buffers from alot import widgets from alot import helper +from alot import crypto from alot.db.errors import DatabaseLockedError from alot.completion import ContactsCompleter from alot.completion import AccountCompleter from alot.db.envelope import Envelope from alot import commands from alot.settings import settings +from alot.errors import GPGProblem MODE = 'global' @@ -596,12 +598,16 @@ class ComposeCommand(Command): select='yes', cancel='no')) == 'no': return + # Figure out whether we should GPG sign messages by default + # and look up key if so + sender = self.envelope.get('From') + name, addr = email.Utils.parseaddr(sender) + account = settings.get_account_by_address(addr) + self.envelope.sign = account.sign_by_default + self.envelope.sign_key = account.gpg_key + # get missing To header if 'To' not in self.envelope.headers: - sender = self.envelope.get('From') - name, addr = email.Utils.parseaddr(sender) - account = settings.get_account_by_address(addr) - allbooks = not settings.get('complete_matching_abook_only') logging.debug(allbooks) if account is not None: diff --git a/alot/crypto.py b/alot/crypto.py new file mode 100644 index 00000000..4e38a472 --- /dev/null +++ b/alot/crypto.py @@ -0,0 +1,140 @@ +# vim:ts=4:sw=4:expandtab +import re + +from email.generator import Generator +from cStringIO import StringIO +import pyme.core +import pyme.constants +from alot.errors import GPGProblem + + +def email_as_string(mail): + """ + Converts the given message to a string, without mangling "From" lines + (like as_string() does). + + :param mail: email to convert to string + :rtype: str + """ + fp = StringIO() + g = Generator(fp, mangle_from_=False) + g.flatten(mail) + return RFC3156_canonicalize(fp.getvalue()) + + +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 get_key(self, keyid): + """ + Gets a key from the keyring by filtering for the specified keyid, but + only if the given keyid is specific enough (if it matches multiple + keys, an exception will be thrown). + The same happens if no key is found for the given hint. + + :param keyid: filter term for the keyring (usually a key ID) + :type keyid: bytestring + :rtype: pyme.pygpgme._gpgme_key + :raises: GPGProblem + """ + result = self.op_keylist_start(str(keyid), 0) + key = self.op_keylist_next() + if self.op_keylist_next() is not None: + raise GPGProblem(("More than one key found matching this filter." + " Please be more specific (use a key ID like 4AC8EE1D).")) + self.op_keylist_end() + if key == None: + raise GPGProblem('No key could be found for hint "%s"' % keyid) + return key + + def detached_signature_for(self, plaintext_str, key=None): + """ + 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 + :param key: gpgme_key_t object representing the key to use + :rtype: tuple of pyme.pygpgme._gpgme_op_sign_result and str + """ + if key is not None: + self.signers_clear() + self.signers_add(key) + 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 f84d305a..14859d9f 100644 --- a/alot/db/envelope.py +++ b/alot/db/envelope.py @@ -1,15 +1,23 @@ +# 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 +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 alot.errors import GPGProblem from attachment import Attachment from utils import encode_header @@ -18,7 +26,7 @@ from utils import encode_header class Envelope(object): """a message that is not yet sent and still editable""" def __init__(self, template=None, bodytext=u'', headers={}, attachments=[], - sign=False, encrypt=False): + sign=False, sign_key=None, encrypt=False): """ :param template: if not None, the envelope will be initialised by :meth:`parsing <parse_template>` this string before @@ -44,6 +52,7 @@ class Envelope(object): self.headers.update(headers) self.attachments = list(attachments) self.sign = sign + self.sign_key = sign_key self.encrypt = encrypt self.sent_time = None self.modified_since_sent = False @@ -133,15 +142,65 @@ class Envelope(object): 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') # 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: - msg = textpart + inner_msg = textpart + + if self.sign: + context = crypto.CryptoContext() + + plaintext = crypto.email_as_string(inner_msg) + logging.info('signing plaintext: ' + plaintext) + + try: + result, signature_str = context.detached_signature_for( + plaintext, self.sign_key) + if len(result.signatures) != 1: + raise GPGProblem(("Could not sign message " + "(GPGME did not return a signature)")) + except pyme.errors.GPGMEError as e: + # 11 == GPG_ERR_BAD_PASSPHRASE + if e.getcode() == 11: + # If GPG_AGENT_INFO is unset or empty, the user just does + # not have gpg-agent running (properly). + if os.environ.get('GPG_AGENT_INFO', '').strip() == '': + raise GPGProblem(("Bad passphrase and " + "GPG_AGENT_INFO not set. Please setup " + "gpg-agent.")) + else: + raise GPGProblem(("Bad passphrase. Is " + "gpg-agent running?")) + raise GPGProblem(str(e)) + + 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: + outer_msg = inner_msg headers = self.headers.copy() # add Message-ID @@ -159,13 +218,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 + return outer_msg def parse_template(self, tmp, reset=False, only_body=False): """parses a template or user edited string to fills this envelope. diff --git a/alot/db/errors.py b/alot/db/errors.py index 240e769b..eb1a56d7 100644 --- a/alot/db/errors.py +++ b/alot/db/errors.py @@ -15,3 +15,4 @@ class DatabaseLockedError(DatabaseError): class NonexistantObjectError(DatabaseError): """requested thread or message does not exist in the index""" pass + diff --git a/alot/defaults/alot.rc.spec b/alot/defaults/alot.rc.spec index 7d6715d9..dc1c3c1f 100644 --- a/alot/defaults/alot.rc.spec +++ b/alot/defaults/alot.rc.spec @@ -181,6 +181,12 @@ prompt_suffix = string(default=':') # :ref:`signature_as_attachment <signature-as-attachment>` is set to True signature_filename = string(default=None) + # Outgoing messages will be GPG signed by default if this is set to True. + sign_by_default = boolean(default=False) + + # The GPG key ID you want to use with this account. If unset, alot will + # use your default key. + gpg_key = gpg_key_hint(default=None) # address book for this account [[[abook]]] diff --git a/alot/defaults/config.stub b/alot/defaults/config.stub index 9d6f3661..94f78c4d 100644 --- a/alot/defaults/config.stub +++ b/alot/defaults/config.stub @@ -67,6 +67,7 @@ t = 'refine To' b = 'refine Bcc' c = 'refine Cc' + S = togglesign select = edit H = toggleheaders diff --git a/alot/errors.py b/alot/errors.py new file mode 100644 index 00000000..29283a45 --- /dev/null +++ b/alot/errors.py @@ -0,0 +1,3 @@ +class GPGProblem(Exception): + """GPG Error""" + pass diff --git a/alot/settings/__init__.py b/alot/settings/__init__.py index e0cd24f5..b33dca62 100644 --- a/alot/settings/__init__.py +++ b/alot/settings/__init__.py @@ -15,7 +15,9 @@ from alot.helper import pretty_datetime, string_decode from errors import ConfigError from utils import read_config -from checks import mail_container, force_list +from checks import force_list +from checks import mail_container +from checks import gpg_key from theme import Theme @@ -56,7 +58,8 @@ class SettingsManager(object): spec = os.path.join(DEFAULTSPATH, 'alot.rc.spec') newconfig = read_config(path, spec, checks={'mail_container': mail_container, - 'force_list': force_list}) + 'force_list': force_list, + 'gpg_key_hint': gpg_key}) self._config.merge(newconfig) hooks_path = os.path.expanduser(self._config.get('hooksfile')) diff --git a/alot/settings/checks.py b/alot/settings/checks.py index 4b56a284..353842de 100644 --- a/alot/settings/checks.py +++ b/alot/settings/checks.py @@ -3,6 +3,10 @@ import re from urlparse import urlparse from validate import VdtTypeError from validate import is_list +from validate import ValidateError + +from alot import crypto +from alot.errors import GPGProblem def mail_container(value): @@ -53,3 +57,14 @@ def force_list(value, min=None, max=None): if rlist == ['']: rlist = [] return rlist + + +def gpg_key(value): + """ + test if value points to a known gpg key + and return that key as :class:`pyme.pygpgme._gpgme_key`. + """ + try: + return crypto.CryptoContext().get_key(value) + except GPGProblem, e: + raise ValidateError(e.message) |