component email gateway: implement Gateway Relayed Encryption:

MIME and OpenPGP are used.

rel 455
This commit is contained in:
Goffi 2025-01-13 01:23:22 +01:00
commit 906ade1894

View file

@ -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 @@ from pathlib import Path
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.models.core import MessageData
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_0033 import (
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 @@ PLUGIN_INFO = {
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 @@ class SendMailExtra(BaseModel):
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 @@ class EmailGatewayComponent:
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 @@ class EmailGatewayComponent:
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 @@ class EmailGatewayComponent:
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 @@ class EmailGatewayComponent:
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 @@ class EmailGatewayComponent:
'"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 @@ class EmailGatewayComponent:
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):