Mercurial > libervia-backend
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):