component email gateway: implement Gateway Relayed Encryption:
MIME and OpenPGP are used. rel 455
This commit is contained in:
parent
ea6ecb3c91
commit
906ade1894
1 changed files with 189 additions and 42 deletions
|
|
@ -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):
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue