summaryrefslogtreecommitdiff
path: root/alot
diff options
context:
space:
mode:
authorPatrick Totzke <patricktotzke@gmail.com>2012-05-17 15:47:23 +0100
committerPatrick Totzke <patricktotzke@gmail.com>2012-05-17 15:47:23 +0100
commit08c2ee1d88e69aa5df27b74d1cebd3c0c8be68f6 (patch)
tree2dde7d7db9146918242f2c289c64e587706b3f09 /alot
parent138f13c682f6a63da67cced1f0c267b5f6f349b8 (diff)
parent56b0b8a0f310403d4664649ab9b69d3cd765dbe5 (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.py6
-rw-r--r--alot/buffers.py8
-rw-r--r--alot/commands/envelope.py66
-rw-r--r--alot/commands/globals.py14
-rw-r--r--alot/crypto.py140
-rw-r--r--alot/db/envelope.py81
-rw-r--r--alot/db/errors.py1
-rw-r--r--alot/defaults/alot.rc.spec6
-rw-r--r--alot/defaults/config.stub1
-rw-r--r--alot/errors.py3
-rw-r--r--alot/settings/__init__.py7
-rw-r--r--alot/settings/checks.py15
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)