diff --git a/swh/web/add_forge_now/models.py b/swh/web/add_forge_now/models.py --- a/swh/web/add_forge_now/models.py +++ b/swh/web/add_forge_now/models.py @@ -5,7 +5,17 @@ import enum +from django.core.signing import Signer from django.db import models +from django.utils.crypto import constant_time_compare + +# signal_receivers imports this module, don't expand this import or you'll get a +# circular import +from . import signal_receivers +from ..config import get_config +from ..inbound_email.signals import email_received + +email_received.connect(signal_receivers.handle_inbound_message) class RequestStatus(enum.Enum): @@ -69,3 +79,40 @@ forge_contact_comment = models.TextField( help_text="Where did you find this contact information (url, ...)", ) + + ADDRESS_SIGNER_SEP = "." + + @classmethod + def get_signer(cls): + return Signer(salt="add_forge_now_request", sep=cls.ADDRESS_SIGNER_SEP) + + def get_inbound_address(self): + """Get the email address that will be able to receive messages to be logged in + this request.""" + + base_address = get_config()["add_forge_now"]["email_address"] + + username, domain = base_address.split("@") + + extension = self.get_signer().sign(self.pk) + + return f"{username}+{extension}@{domain}" + + @classmethod + def verify_address_extension(cls, extension: str) -> int: + """Retrieve the primary key of the request for the given inbound address extension. + + We reimplement `Signer.unsign`, because the extension can be casemapped at any + point in the email chain (even though email is, theoretically, case sensitive), + so we have to compare lowercase versions of both the extension and the + signature... + + Raises ValueError if the signature couldn't be verified. + + """ + value, signature = extension.rsplit(cls.ADDRESS_SIGNER_SEP, 1) + expected_signature = cls.get_signer().signature(value) + if not constant_time_compare(signature.lower(), expected_signature.lower()): + raise ValueError(f"Invalid signature in extension {extension}") + + return int(value) diff --git a/swh/web/add_forge_now/signal_receivers.py b/swh/web/add_forge_now/signal_receivers.py new file mode 100644 --- /dev/null +++ b/swh/web/add_forge_now/signal_receivers.py @@ -0,0 +1,76 @@ +# Copyright (C) 2022 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from email.message import EmailMessage +import logging +from typing import Optional, Type + +# models imports this module, don't expand this import or you'll get a circular import +from . import models +from ..config import get_config +from ..inbound_email.signals import EmailProcessingStatus +from ..inbound_email.utils import recipient_matches + +logger = logging.getLogger(__name__) + + +def handle_inbound_message(sender: Type, **kwargs) -> EmailProcessingStatus: + """Handle inbound email messages for add forge now. + + # How do we determine that the inbound message is for us (To/Cc/Received)? + + # How do we extract the RequestHistory fields out of the email: + # - text + # - actor + # - actor_role + # - new_status (?) + + """ + message = kwargs["message"] + assert isinstance(message, EmailMessage) + + match_address = get_config()["add_forge_now"]["email_address"] + + matches = recipient_matches(message, match_address) + if not matches: + return EmailProcessingStatus.IGNORED + + request_pk: Optional[int] = None + for match in matches: + extension = match.extension + if extension is None: + logger.debug( + "Recipient address %s cannot be matched to a request, ignoring", + match.recipient.addr_spec, + ) + continue + try: + current_pk = models.Request.verify_address_extension(extension) + except ValueError: + logger.debug( + "Recipient address %s failed validation", match.recipient.addr_spec + ) + continue + + if request_pk is not None and request_pk != current_pk: + logger.debug( + "Recipient address %s inconsistent with earlier valid address with " + "pk=%s, failing", + match.recipient.addr_spec, + request_pk, + ) + return EmailProcessingStatus.FAILED + + request_pk = current_pk + + if request_pk is None: + # No matches had a valid extension, unable to process message + return EmailProcessingStatus.FAILED + + request = models.Request.objects.get(pk=request_pk) + + print(request) + + return EmailProcessingStatus.PROCESSED diff --git a/swh/web/config.py b/swh/web/config.py --- a/swh/web/config.py +++ b/swh/web/config.py @@ -127,6 +127,7 @@ "staging_server_names": ("list", SWH_WEB_STAGING_SERVER_NAMES), "instance_name": ("str", "archive-test.softwareheritage.org"), "give": ("dict", {"public_key": "", "token": ""}), + "add_forge_now": ("dict", {"email_address": "add-forge-now@example.com"}), } swhweb_config: Dict[str, Any] = {}