changeset 4347:54df67d5646c

component email gateway: implement Gateway Relayed Encryption: MIME and OpenPGP are used. rel 455
author Goffi <goffi@goffi.org>
date Mon, 13 Jan 2025 01:23:22 +0100
parents 62746042e6d9
children 35d41de5b2aa
files libervia/backend/plugins/plugin_comp_email_gateway/__init__.py
diffstat 1 files changed, 188 insertions(+), 41 deletions(-) [+]
line wrap: on
line diff
--- a/libervia/backend/plugins/plugin_comp_email_gateway/__init__.py	Mon Jan 13 01:23:22 2025 +0100
+++ b/libervia/backend/plugins/plugin_comp_email_gateway/__init__.py	Mon Jan 13 01:23:22 2025 +0100
@@ -16,8 +16,12 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+import base64
+from email import encoders
 from email.header import decode_header
 from email.message import EmailMessage
+from email.mime.application import MIMEApplication
+from email.mime.multipart import MIMEMultipart
 from email.mime.text import MIMEText
 from email.utils import formataddr, getaddresses, parseaddr
 from functools import partial
@@ -26,7 +30,7 @@
 import re
 import shutil
 import tempfile
-from typing import TYPE_CHECKING, NamedTuple, cast
+from typing import NamedTuple, TYPE_CHECKING, cast
 
 from pydantic import BaseModel
 from twisted.internet import defer, reactor
@@ -52,6 +56,9 @@
 from libervia.backend.plugins.plugin_comp_email_gateway.pubsub_service import (
     EmailGWPubsubService,
 )
+from libervia.backend.plugins.plugin_exp_gre import GRE, GetDataHandler
+from libervia.backend.plugins.plugin_sec_gre_encrypted_openpgp import NS_GRE_OPENPGP
+from libervia.backend.plugins.plugin_sec_gre_formatter_mime import NS_GRE_MIME
 from libervia.backend.plugins.plugin_xep_0033 import (
     AddressType,
     AddressesData,
@@ -60,6 +67,7 @@
 from libervia.backend.plugins.plugin_xep_0077 import XEP_0077
 from libervia.backend.plugins.plugin_xep_0106 import XEP_0106
 from libervia.backend.plugins.plugin_xep_0131 import HeadersData, Urgency, XEP_0131
+from libervia.backend.plugins.plugin_xep_0373 import binary_to_ascii_armor
 from libervia.backend.plugins.plugin_xep_0498 import XEP_0498
 from libervia.backend.tools.utils import aio
 
@@ -81,7 +89,9 @@
     C.PI_MODES: [C.PLUG_MODE_COMPONENT],
     C.PI_TYPE: C.PLUG_TYPE_ENTRY_POINT,
     C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0033", "XEP-0077", "XEP-0106", "XEP-0498"],
+    C.PI_DEPENDENCIES: [
+        "XEP-0033", "XEP-0077", "XEP-0106", "XEP-0498", "GRE", "GRE-MIME", "GRE-OpenPGP"
+    ],
     C.PI_RECOMMENDATIONS: [],
     C.PI_MAIN: "EmailGatewayComponent",
     C.PI_HANDLER: C.BOOL_TRUE,
@@ -109,8 +119,10 @@
     headers: HeadersData | None = None
 
 
-class EmailGatewayComponent:
+class EmailGatewayComponent(GetDataHandler):
     IMPORT_NAME = IMPORT_NAME
+    gre_formatters = [NS_GRE_MIME]
+    gre_encrypters = [NS_GRE_OPENPGP]
     verbose = 0
 
     def __init__(self, host: "LiberviaBackend") -> None:
@@ -125,6 +137,7 @@
         self._e = cast(XEP_0106, host.plugins["XEP-0106"])
         self._shim = cast(XEP_0131, host.plugins["XEP-0131"])
         self._pfs = cast(XEP_0498, host.plugins["XEP-0498"])
+        self._gre = cast(GRE, host.plugins["GRE"])
         # TODO: For the moment, all credentials are kept in cache; we should only keep the
         #   X latest.
         self.users_data: dict[jid.JID, UserData] = {}
@@ -184,6 +197,7 @@
     async def profile_connecting(self, client: SatXMPPEntity) -> None:
         assert isinstance(client, SatXMPPComponent)
         self.client = client
+        self._gre.register_get_data_handler(client, self)
         if not self.initalized:
             await self._init()
             self.initalized = True
@@ -239,33 +253,72 @@
                     f'Invalid "to" JID, can\'t send message: {message_elt.toXml()}.'
                 )
 
-        self._shim.move_keywords_to_headers(mess_data["extra"])
-        headers = mess_data["extra"].get("headers")
-        if headers:
-            extra_kw["headers"] = headers
+        encrypted_payload = self._gre.get_encrypted_payload(message_elt)
 
         try:
-            body_lang, body = next(iter(mess_data["message"].items()))
-        except (KeyError, StopIteration):
-            log.warning(f"No body found: {mess_data}")
-            body_lang, body = "", ""
-        try:
-            subject_lang, subject = next(iter(mess_data["subject"].items()))
-        except (KeyError, StopIteration):
-            subject_lang, subject = "", None
+            if encrypted_payload is not None:
+                # We convert the base64 datat to ASCII Armor
+                encrypted_binary = base64.b64decode(encrypted_payload)
+                encrypted_payload = binary_to_ascii_armor(encrypted_binary)
 
-        if not body and not subject:
-            log.warning(f"Ignoring empty message: {mess_data}")
-            return mess_data
+                assert to_email is not None
+                subject = "This is an encrypted message."
+                outer = MIMEMultipart('encrypted', protocol="application/pgp-encrypted")
+                outer["Subject"] = subject
+                # FIXME: use credentials here.
+                outer["From"] = from_jid.userhost()
+                outer["To"] = to_email
+                outer["Content-Type"] = "multipart/encrypted; protocol=\"application/pgp-encrypted\""
+                version = MIMEApplication(
+                    "Version: 1\n",
+                    _subtype='pgp-encrypted',
+                    _encoder=encoders.encode_7or8bit
+                )
+                version["Content-Description"] = "PGP/MIME version identification"
+                encrypted_part = MIMEApplication(
+                    encrypted_payload,
+                    _subtype='octet-stream',
+                    _encoder=encoders.encode_7or8bit
+                )
+                encrypted_part["Content-Description"] = "OpenPGP encrypted message"
+                encrypted_part["Content-Type"] = "application/octet-stream; name=\"encrypted.asc\""
+                encrypted_part["Content-Disposition"] = "inline; filename=\"encrypted.asc\""
+                outer.attach(version)
+                outer.attach(encrypted_part)
+                body = outer.as_bytes()
+                await self.send_encrypted_email(
+                    from_jid=from_jid,
+                    to_email=to_email,
+                    body=body,
+                    extra=SendMailExtra(**extra_kw) if extra_kw else None,
+                )
+            else:
+                self._shim.move_keywords_to_headers(mess_data["extra"])
+                headers = mess_data["extra"].get("headers")
+                if headers:
+                    extra_kw["headers"] = headers
 
-        try:
-            await self.send_email(
-                from_jid=from_jid,
-                to_email=to_email,
-                body=body,
-                subject=subject,
-                extra=SendMailExtra(**extra_kw) if extra_kw else None,
-            )
+                try:
+                    body_lang, body = next(iter(mess_data["message"].items()))
+                except (KeyError, StopIteration):
+                    log.warning(f"No body found: {mess_data}")
+                    body_lang, body = "", ""
+                try:
+                    subject_lang, subject = next(iter(mess_data["subject"].items()))
+                except (KeyError, StopIteration):
+                    subject_lang, subject = "", None
+
+                if not body and not subject:
+                    log.warning(f"Ignoring empty message: {mess_data}")
+                    return mess_data
+
+                await self.send_email(
+                    from_jid=from_jid,
+                    to_email=to_email,
+                    body=body,
+                    subject=subject,
+                    extra=SendMailExtra(**extra_kw) if extra_kw else None,
+                )
         except exceptions.UnknownEntityError:
             log.warning(f"Can't send message, user {from_jid} is not registered.")
             message_error_elt = StanzaError(
@@ -302,6 +355,83 @@
                 email_address = formataddr((f"xmpp:{address_jid}", email_address))
             return email_address
 
+    async def get_credentials(self, from_jid: jid.JID) -> Credentials:
+        """Retrieve user credentials from a bare JID.
+
+        @param from_jid: Entity to retrieve credentials from.
+        @return: Credentials.
+
+        @raise UnknownEntityError: If no credentials are found for the given JID.
+        """
+        # We need a bare jid.
+        assert self.storage is not None
+        assert not from_jid.resource
+        try:
+            user_data = self.users_data[from_jid]
+        except KeyError:
+            key = KEY_CREDENTIALS.format(from_jid=from_jid)
+            credentials = await self.storage.get(key)
+            if credentials is None:
+                raise exceptions.UnknownEntityError(
+                    f"No credentials found for {from_jid}."
+                )
+            self.users_data[from_jid] = UserData(credentials)
+        else:
+            credentials = user_data.credentials
+        return credentials
+
+    async def send_encrypted_email(
+        self,
+        from_jid: jid.JID,
+        to_email: str | None,
+        body: bytes,
+        extra: SendMailExtra | None = None,
+    ) -> None:
+        """Send an email using sender credentials.
+
+        Credentials will be retrieved from cache or database.
+
+        @param from_jid: Bare JID of the sender.
+        @param to_email: Email address of the recipient.
+        @param body: Encrypted body of the email.
+        @param extra: Extra data.
+        """
+        assert self.client is not None
+        assert isinstance(body, bytes)
+        credentials = await self.get_credentials(from_jid)
+
+        sender_domain = credentials["user_email"].split("@", 1)[-1]
+        recipients = []
+        if to_email is not None:
+            recipients.append(to_email.encode())
+        if extra is not None and extra.addresses is not None:
+            for address in extra.addresses.addresses:
+                recipient_jid = address.jid
+                if recipient_jid is None:
+                    continue
+                recipient_email = self.jid_to_email(
+                    self.client, recipient_jid, credentials
+                )
+                recipients.append(recipient_email.encode())
+
+        if not recipients:
+            raise exceptions.InternalError("No recipient found.")
+
+        await smtp.sendmail(
+            credentials["smtp_host"].encode(),
+            credentials["user_email"].encode(),
+            recipients,
+            body,
+            senderDomainName=sender_domain,
+            port=int(credentials["smtp_port"]),
+            username=credentials["smtp_username"].encode(),
+            password=credentials["smtp_password"].encode(),
+            requireAuthentication=True,
+            # TODO: only STARTTLS is supported right now, implicit TLS should be supported
+            #   too.
+            requireTransportSecurity=True,
+        )
+
     async def send_email(
         self,
         from_jid: jid.JID,
@@ -330,21 +460,26 @@
                 '"to_email" can\'t be None if there is no "to" address!'
             )
 
-        # We need a bare jid.
-        assert self.storage is not None
-        assert not from_jid.resource
-        try:
-            user_data = self.users_data[from_jid]
-        except KeyError:
-            key = KEY_CREDENTIALS.format(from_jid=from_jid)
-            credentials = await self.storage.get(key)
-            if credentials is None:
-                raise exceptions.UnknownEntityError(
-                    f"No credentials found for {from_jid}."
-                )
-            self.users_data[from_jid] = UserData(credentials)
-        else:
-            credentials = user_data.credentials
+        credentials = await self.get_credentials(from_jid)
+
+        if isinstance(body, bytes):
+            assert to_email is not None
+            sender_domain = credentials["user_email"].split("@", 1)[-1]
+            await smtp.sendmail(
+                credentials["smtp_host"].encode(),
+                credentials["user_email"].encode(),
+                [to_email.encode()],
+                body,
+                senderDomainName=sender_domain,
+                port=int(credentials["smtp_port"]),
+                username=credentials["smtp_username"].encode(),
+                password=credentials["smtp_password"].encode(),
+                requireAuthentication=True,
+                # TODO: only STARTTLS is supported right now, implicit TLS should be supported
+                #   too.
+                requireTransportSecurity=True,
+            )
+            return
 
         msg = MIMEText(body, "plain", "UTF-8")
         if subject is not None:
@@ -929,6 +1064,18 @@
             return True
 
 
+    async def on_relayed_encryption_data(
+        self,
+        client: SatXMPPEntity,
+        iq_elt: domish.Element,
+        form: data_form.Form
+    ) -> None:
+        from_jid = jid.JID(iq_elt["from"]).userhostJID()
+        credentials = await self.get_credentials(from_jid)
+        form.addField(data_form.Field(var="sender_id", value=credentials["user_email"]))
+
+
+
 @implementer(iwokkel.IDisco)
 class EmailGatewayHandler(XMPPHandler):