mailpipe|handler: raise exceptions during PGP message verification.
authorW. Trevor King <wking@tremily.us>
Sun, 2 Sep 2012 14:07:42 +0000 (10:07 -0400)
committerW. Trevor King <wking@tremily.us>
Sun, 2 Sep 2012 14:07:46 +0000 (10:07 -0400)
This avoids the silly "return None on error" convention and allows for
more detailed handling of unsigned messages (vs. improperly signed
messages).

pygrader/handler/__init__.py
pygrader/mailpipe.py

index 3fb7b28aa8ca36963f142b2740bbff99f8da511a..70905f5605e6ab38810c4328fb26d913d05f8497 100644 (file)
@@ -25,7 +25,14 @@ class InvalidMessage (ValueError):
         return None
 
 
-class UnsignedMessage (InvalidMessage):
+class InsecureMessage (InvalidMessage):
+    def __init__(self, **kwargs):
+        if 'error' not in kwargs:
+            kwargs['error'] = 'insecure message'
+        super(InsecureMessage, self).__init__(**kwargs)
+
+
+class UnsignedMessage (InsecureMessage):
     def __init__(self, **kwargs):
         if 'error' not in kwargs:
             kwargs['error'] = 'unsigned message'
index f704cb33d758263d7f032edaefeeb5b72ffe11d6..1cd3f90d3365939fc7fb05009187d97bed71650a 100644 (file)
@@ -38,6 +38,7 @@ from .handler import InvalidMessage as _InvalidMessage
 from .handler import InvalidSubjectMessage as _InvalidSubjectMessage
 from .handler import Response as _Response
 from .handler import UnsignedMessage as _UnsignedMessage
+from .handler import InsecureMessage as _InsecureMessage
 from .handler.get import InvalidStudent as _InvalidStudent
 from .handler.get import run as _handle_get
 from .handler.submission import InvalidAssignment as _InvalidAssignment
@@ -71,6 +72,25 @@ class AmbiguousAddress (_InvalidMessage):
         self.people = people
 
 
+class WrongSignatureMessage (_InsecureMessage):
+    def __init__(self, pgp_key=None, fingerprints=None, decrypted=None,
+                 **kwargs):
+        if 'error' not in kwargs:
+            kwargs['error'] = 'not signed by the expected key'
+        super(WrongSignatureMessage, self).__init__(**kwargs)
+        self.pgp_key = pgp_key
+        self.fingerprints = fingerprints
+        self.decrypted = decrypted
+
+class UnverifiedSignatureMessage (_InsecureMessage):
+    def __init__(self, signature=None, decrypted=None, **kwargs):
+        if 'error' not in kwargs:
+            kwargs['error'] = 'unverified signature'
+        super(UnverifiedSignatureMessage, self).__init__(**kwargs)
+        self.signature = signature
+        self.decrypted = decrypted
+
+
 class SubjectlessMessage (_InvalidSubjectMessage):
     def __init__(self, **kwargs):
         if 'error' not in kwargs:
@@ -556,9 +576,11 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
         course=course, stream=stream, mailbox=mailbox, input_=input_,
         output=output, dry_run=dry_run,
         continue_after_invalid_message=continue_after_invalid_message,
+        trust_email_infrastructure=trust_email_infrastructure,
         respond=respond):
         try:
             handler = _get_handler(handlers=handlers, target=target)
+            _LOG.debug('handling {}'.format(target))
             handler(
                 basedir=basedir, course=course, message=message,
                 person=person, subject=subject,
@@ -612,7 +634,8 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
 
 
 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
-                   continue_after_invalid_message=False, respond=None,
+                   continue_after_invalid_message=False,
+                   trust_email_infrastructure=False, respond=None,
                    dry_run=False):
     if mailbox is None:
         _LOG.debug('loading message from {}'.format(stream))
@@ -641,7 +664,9 @@ def _load_messages(course, stream, mailbox=None, input_=None, output=None,
     messages = sorted(messages, key=_get_message_time)
     for key,msg in messages:
         try:
-            ret = _parse_message(course=course, message=msg)
+            ret = _parse_message(
+                course=course, message=msg,
+                trust_email_infrastructure=trust_email_infrastructure)
         except _InvalidMessage as error:
             error.message = msg
             _LOG.warn('invalid message {}'.format(error.message_id()))
@@ -660,7 +685,7 @@ def _load_messages(course, stream, mailbox=None, input_=None, output=None,
                 del mbox[key]
         yield ret
 
-def _parse_message(course, message):
+def _parse_message(course, message, trust_email_infrastructure=False):
     """Parse an incoming email and respond if neccessary.
 
     Return ``(msg, person, assignment, time)`` on successful parsing.
@@ -671,8 +696,14 @@ def _parse_message(course, message):
     try:
         person = _get_message_person(course=course, message=message)
         if person.pgp_key:
-            message = _get_decoded_message(
-                course=course, message=message, person=person)
+            _LOG.debug('verify message is from {}'.format(person))
+            try:
+                message = _get_verified_message(message, person.pgp_key)
+            except _UnsignedMessage as error:
+                if trust_email_infrastructure:
+                    _LOG.warn('{}'.format(error))
+                else:
+                    raise
         subject = _get_message_subject(message=message)
         target = _get_message_target(subject=subject)
     except _InvalidMessage as error:
@@ -699,12 +730,6 @@ def _get_message_person(course, message):
         raise AmbiguousAddress(message=message, address=sender, people=people)
     return people[0]
 
-def _get_decoded_message(course, message, person):
-    msg = _get_verified_message(message, person.pgp_key)
-    if msg is None:
-        raise _UnsignedMessage(message=message)
-    return msg
-
 def _get_message_subject(message):
     """
     >>> from email.header import Header
@@ -816,39 +841,44 @@ def _get_verified_message(message, pgp_key):
     >>> our_message.authenticated
     True
 
-    If it is signed, but not by the right key, we get ``None``.
+    If it is signed, but not by the right key, we get an error.
 
     >>> print(_get_verified_message(signed, pgp_key='01234567'))
-    None
+    Traceback (most recent call last):
+      ...
+    pygrader.mailpipe.WrongSignatureMessage: not signed by the expected key
 
-    If it is not signed at all, we get ``None``.
+    If it is not signed at all, we get another error.
 
     >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
-    None
+    Traceback (most recent call last):
+      ...
+    pygrader.handler.UnsignedMessage: unsigned message
     """
     mid = message['message-id']
     try:
         decrypted,verified,result = _pgp_mime.verify(message=message)
-    except (ValueError, AssertionError):
-        _LOG.warning('could not verify {} (not signed?)'.format(mid))
-        return None
+    except (ValueError, AssertionError) as error:
+        raise _UnsignedMessage(message=message) from error
     _LOG.debug(str(result, 'utf-8'))
     tree = _etree.fromstring(result.replace(b'\x00', b''))
     match = None
+    fingerprints = []
     for signature in tree.findall('.//signature'):
         for fingerprint in signature.iterchildren('fpr'):
-            if fingerprint.text.endswith(pgp_key):
-                match = signature
-                break
-    if match is None:
-        _LOG.warning('{} is not signed by the expected key'.format(mid))
-        return None
+            fingerprints.append(fingerprint)
+    matches = [f for f in fingerprints if f.text.endswith(pgp_key)]
+    if len(matches) == 0:
+        raise WrongSignatureMessage(
+            message=message, pgp_key=pgp_key, fingerprints=fingerprints,
+            decrypted=decrypted)
+    match = matches[0]
     if not verified:
         sumhex = list(signature.iterchildren('summary'))[0].get('value')
         summary = int(sumhex, 16)
         if summary != 0:
-            _LOG.warning('{} has an unverified signature'.format(mid))
-            return None
+            raise UnverifiedSignatureMessage(
+                message=message, signature=signature, decrypted=decrypted)
         # otherwise, we may have an untrusted key.  We'll count that
         # as verified here, because the caller is explicity looking
         # for signatures by this fingerprint.