From c79d4421588ba428120600e863a92c9ccfd0d915 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 28 Mar 2020 22:01:23 +0200 Subject: [PATCH] Add initial Matrix end-to-bridge encryption support --- ...fc8a72b_add_encrypted_field_for_portals.py | 27 +++++ mautrix_telegram/config.py | 2 + mautrix_telegram/db/portal.py | 3 +- mautrix_telegram/e2ee.py | 112 ++++++++++++++++++ mautrix_telegram/example-config.yaml | 11 ++ mautrix_telegram/matrix.py | 30 ++++- mautrix_telegram/portal/base.py | 20 ++-- mautrix_telegram/portal/metadata.py | 2 +- mautrix_telegram/portal/telegram.py | 30 +++-- optional-requirements.txt | 3 + requirements.txt | 2 +- 11 files changed, 218 insertions(+), 24 deletions(-) create mode 100644 alembic/versions/24f31fc8a72b_add_encrypted_field_for_portals.py create mode 100644 mautrix_telegram/e2ee.py diff --git a/alembic/versions/24f31fc8a72b_add_encrypted_field_for_portals.py b/alembic/versions/24f31fc8a72b_add_encrypted_field_for_portals.py new file mode 100644 index 00000000..8cd1ba59 --- /dev/null +++ b/alembic/versions/24f31fc8a72b_add_encrypted_field_for_portals.py @@ -0,0 +1,27 @@ +"""Add encrypted field for portals + +Revision ID: 24f31fc8a72b +Revises: a7c04a56041b +Create Date: 2020-03-28 20:14:29.046699 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "24f31fc8a72b" +down_revision = "a7c04a56041b" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("portal") as batch_op: + batch_op.add_column(sa.Column("encrypted", sa.Boolean(), nullable=False, + server_default=sa.sql.expression.false())) + + +def downgrade(): + with op.batch_alter_table("portal") as batch_op: + batch_op.drop_column("encrypted") diff --git a/mautrix_telegram/config.py b/mautrix_telegram/config.py index a1ca0182..904f3a43 100644 --- a/mautrix_telegram/config.py +++ b/mautrix_telegram/config.py @@ -118,6 +118,8 @@ class Config(BaseBridgeConfig): copy("bridge.federate_rooms") copy("bridge.animated_sticker.target") copy("bridge.animated_sticker.args") + copy("bridge.encryption.allow") + copy("bridge.encryption.default") copy("bridge.initial_power_level_overrides.group") copy("bridge.initial_power_level_overrides.user") diff --git a/mautrix_telegram/db/portal.py b/mautrix_telegram/db/portal.py index bae8c76a..8112e9ad 100644 --- a/mautrix_telegram/db/portal.py +++ b/mautrix_telegram/db/portal.py @@ -15,7 +15,7 @@ # along with this program. If not, see . from typing import Optional -from sqlalchemy import Column, Integer, String, Boolean, Text, func +from sqlalchemy import Column, Integer, String, Boolean, Text, func, sql from mautrix.types import RoomID from mautrix.util.db import Base @@ -34,6 +34,7 @@ class Portal(Base): # Matrix portal information mxid: RoomID = Column(String, unique=True, nullable=True) + encrypted: bool = Column(Boolean, nullable=False, server_default=sql.expression.false()) config: str = Column(Text, nullable=True) diff --git a/mautrix_telegram/e2ee.py b/mautrix_telegram/e2ee.py new file mode 100644 index 00000000..cb9d6895 --- /dev/null +++ b/mautrix_telegram/e2ee.py @@ -0,0 +1,112 @@ +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2019 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from typing import Tuple, Union +import logging +import asyncio +import hashlib +import hmac + +from nio import AsyncClient, Event as NioEvent, GroupEncryptionError, LoginError + +from mautrix.appservice import AppService +from mautrix.types import (Filter, RoomFilter, EventFilter, RoomEventFilter, StateFilter, + EventType, RoomID, Serializable, JSON, MessageEvent, Event) + +from .context import Context + + +class EncryptionManager: + loop: asyncio.AbstractEventLoop + log: logging.Logger = logging.getLogger("mau.e2ee") + client: AsyncClient + az: AppService + + login_shared_secret: bytes + + sync_task: asyncio.Task + + def __init__(self, context: 'Context') -> None: + self.loop = context.loop + self.az = context.az + self.config = context.config + lss: str = self.config["bridge.login_shared_secret"] + if not lss: + raise ValueError("login_shared_secret must be set to enable encryption") + self.login_shared_secret = lss.encode("utf-8") + self.client = AsyncClient(homeserver=self.config["homeserver.address"], + user=self.az.bot_mxid, device_id="Telegram bridge", + store_path="nio_store") + + async def encrypt(self, room_id: RoomID, event_type: EventType, + content: Union[Serializable, JSON]) -> Tuple[EventType, JSON]: + serialized = content.serialize() if isinstance(content, Serializable) else content + type_str = str(event_type) + retries = 0 + while True: + try: + type_str, encrypted = self.client.encrypt(room_id, type_str, serialized) + break + except GroupEncryptionError: + if retries > 3: + self.log.error("Got GroupEncryptionError again, giving up") + raise + retries += 1 + self.log.debug("Got GroupEncryptionError, sharing group session and trying again") + await self.client.share_group_session(room_id, ignore_unverified_devices=True) + event_type = EventType.find(type_str) + try: + encrypted["m.relates_to"] = serialized["m.relates_to"] + except KeyError: + pass + return event_type, encrypted + + def decrypt(self, event: MessageEvent) -> MessageEvent: + serialized = event.serialize() + event = self.client.decrypt_event(NioEvent.parse_encrypted_event(serialized)) + try: + event.source["content"]["m.relates_to"] = serialized["content"]["m.relates_to"] + except KeyError: + pass + return Event.deserialize(event.source) + + async def start(self) -> None: + self.log.debug("Logging in with bridge bot user") + password = hmac.new(self.login_shared_secret, self.az.bot_mxid.encode("utf-8"), + hashlib.sha512).hexdigest() + resp = await self.client.login(password, device_name="Telegram bridge") + if isinstance(resp, LoginError): + raise resp + self.sync_task = self.loop.create_task(self.client.sync_forever( + timeout=30000, sync_filter=self._filter.serialize())) + self.log.info("End-to-bridge encryption support is enabled") + + def stop(self) -> None: + self.sync_task.cancel() + + @property + def _filter(self) -> Filter: + all_events = EventType.find("*") + return Filter( + account_data=EventFilter(types=[all_events]), + presence=EventFilter(not_types=[all_events]), + room=RoomFilter( + include_leave=False, + state=StateFilter(types=[EventType.ROOM_MEMBER, EventType.ROOM_ENCRYPTION]), + timeline=RoomEventFilter(types=[EventType.ROOM_MEMBER, EventType.ROOM_ENCRYPTION]), + account_data=RoomEventFilter(not_types=[all_events]), + ephemeral=RoomEventFilter(not_types=[all_events]), + ), + ) diff --git a/mautrix_telegram/example-config.yaml b/mautrix_telegram/example-config.yaml index 4ab42797..26c59793 100644 --- a/mautrix_telegram/example-config.yaml +++ b/mautrix_telegram/example-config.yaml @@ -191,6 +191,17 @@ bridge: height: 256 background: "020202" # only for gif fps: 30 # only for webm + # End-to-bridge encryption support options. These require matrix-nio to be installed with pip + # and login_shared_secret to be configured in order to get a device for the bridge bot. + # + # Additionally, https://github.com/matrix-org/synapse/pull/5758 is required if using a normal + # application service. + encryption: + # Allow encryption, work in group chat rooms with e2ee enabled + allow: false + # Default to encryption, force-enable encryption in all portals the bridge creates + # This will cause the bridge bot to be in private chats for the encryption to work properly. + default: false # Overrides for base power levels. initial_power_level_overrides: diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py index 27d6f164..3163e679 100644 --- a/mautrix_telegram/matrix.py +++ b/mautrix_telegram/matrix.py @@ -13,14 +13,14 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Dict, Set, Tuple, Union, Iterable, TYPE_CHECKING +from typing import Dict, Set, Tuple, Union, Iterable, Optional, TYPE_CHECKING from mautrix.bridge import BaseMatrixHandler from mautrix.types import (Event, EventType, RoomID, UserID, EventID, ReceiptEvent, ReceiptType, ReceiptEventContent, PresenceEvent, PresenceState, TypingEvent, MessageEvent, StateEvent, RedactionEvent, RoomNameStateEventContent, RoomAvatarStateEventContent, RoomTopicStateEventContent, - MemberStateEventContent) + MemberStateEventContent, EncryptedEvent) from mautrix.errors import MatrixError from . import user as u, portal as po, puppet as pu, commands as com @@ -37,6 +37,11 @@ except ImportError: Histogram = None EVENT_TIME = None +try: + from .e2ee import EncryptionManager +except ImportError: + EncryptionManager = None + RoomMetaStateEventContent = Union[RoomNameStateEventContent, RoomAvatarStateEventContent, RoomTopicStateEventContent] @@ -44,14 +49,26 @@ RoomMetaStateEventContent = Union[RoomNameStateEventContent, RoomAvatarStateEven class MatrixHandler(BaseMatrixHandler): bot: 'Bot' commands: 'com.CommandProcessor' + e2ee: Optional[EncryptionManager] previously_typing: Dict[RoomID, Set[UserID]] def __init__(self, context: 'Context') -> None: super(MatrixHandler, self).__init__(context.az, context.config, loop=context.loop, command_processor=com.CommandProcessor(context)) + self.e2ee = None + if self.config["bridge.encryption.allow"]: + if EncryptionManager: + self.e2ee = EncryptionManager(context) + else: + self.log.warning("Encryption enabled in config, but dependencies not installed.") self.bot = context.bot self.previously_typing = {} + async def init_as_bot(self) -> None: + await super().init_as_bot() + if self.e2ee: + await self.e2ee.start() + async def get_user(self, user_id: UserID) -> 'u.User': return await u.User.get_by_mxid(user_id).ensure_started() @@ -355,7 +372,7 @@ class MatrixHandler(BaseMatrixHandler): self.previously_typing[room_id] = now_typing def filter_matrix_event(self, evt: Event) -> bool: - if not isinstance(evt, (RedactionEvent, MessageEvent, StateEvent)): + if not isinstance(evt, (RedactionEvent, MessageEvent, StateEvent, EncryptedEvent)): return True return evt.sender and (evt.sender == self.az.bot_mxid or pu.Puppet.get_id_from_mxid(evt.sender) is not None) @@ -372,6 +389,8 @@ class MatrixHandler(BaseMatrixHandler): async def handle_event(self, evt: Event) -> None: if evt.type == EventType.ROOM_REDACTION: await self.handle_redaction(evt) + elif evt.type == EventType.ROOM_ENCRYPTED and self.e2ee: + await self.int_handle_event(self.e2ee.decrypt(evt)) async def handle_state_event(self, evt: StateEvent) -> None: if evt.type == EventType.ROOM_POWER_LEVELS: @@ -387,6 +406,11 @@ class MatrixHandler(BaseMatrixHandler): await self.handle_room_pin(evt.room_id, evt.sender, new_events, old_events) elif evt.type == EventType.ROOM_TOMBSTONE: await self.handle_room_upgrade(evt.room_id, evt.sender, evt.content.replacement_room) + elif evt.type == EventType.ROOM_ENCRYPTION: + portal = po.Portal.get_by_mxid(evt.room_id) + if portal: + portal.encrypted = True + portal.save() async def log_event_handle_duration(self, evt: Event, duration: float) -> None: if EVENT_TIME: diff --git a/mautrix_telegram/portal/base.py b/mautrix_telegram/portal/base.py index c1332b0e..1d2d6d71 100644 --- a/mautrix_telegram/portal/base.py +++ b/mautrix_telegram/portal/base.py @@ -44,6 +44,7 @@ if TYPE_CHECKING: from ..bot import Bot from ..abstract_user import AbstractUser from ..config import Config + from ..matrix import MatrixHandler from . import Portal TypeParticipant = Union[TypeChatParticipant, TypeChannelParticipant] @@ -58,6 +59,7 @@ class BasePortal(ABC): az: AppService = None bot: 'Bot' = None loop: asyncio.AbstractEventLoop = None + matrix: 'MatrixHandler' = None # Config cache filter_mode: str = None @@ -85,6 +87,7 @@ class BasePortal(ABC): about: Optional[str] photo_id: Optional[str] local_config: Dict[str, Any] + encrypted: bool deleted: bool backfilling: bool backfill_leave: Optional[Set[IntentAPI]] @@ -102,7 +105,8 @@ class BasePortal(ABC): mxid: Optional[RoomID] = None, username: Optional[str] = None, megagroup: Optional[bool] = False, title: Optional[str] = None, about: Optional[str] = None, photo_id: Optional[str] = None, - local_config: Optional[str] = None, db_instance: DBPortal = None) -> None: + local_config: Optional[str] = None, encrypted: Optional[bool] = False, + db_instance: DBPortal = None) -> None: self.mxid = mxid self.tgid = tgid self.tg_receiver = tg_receiver or tgid @@ -113,6 +117,7 @@ class BasePortal(ABC): self.about = about self.photo_id = photo_id self.local_config = json.loads(local_config or "{}") + self.encrypted = encrypted self._db_instance = db_instance self._main_intent = None self.deleted = False @@ -328,12 +333,12 @@ class BasePortal(ABC): return DBPortal(tgid=self.tgid, tg_receiver=self.tg_receiver, peer_type=self.peer_type, mxid=self.mxid, username=self.username, megagroup=self.megagroup, title=self.title, about=self.about, photo_id=self.photo_id, - config=json.dumps(self.local_config)) + config=json.dumps(self.local_config), encrypted=self.encrypted) def save(self) -> None: self.db_instance.edit(mxid=self.mxid, username=self.username, title=self.title, about=self.about, photo_id=self.photo_id, megagroup=self.megagroup, - config=json.dumps(self.local_config)) + config=json.dumps(self.local_config), encrypted=self.encrypted) def delete(self) -> None: try: @@ -352,10 +357,10 @@ class BasePortal(ABC): @classmethod def from_db(cls, db_portal: DBPortal) -> 'Portal': return cls(tgid=db_portal.tgid, tg_receiver=db_portal.tg_receiver, - peer_type=db_portal.peer_type, mxid=db_portal.mxid, - username=db_portal.username, megagroup=db_portal.megagroup, - title=db_portal.title, about=db_portal.about, photo_id=db_portal.photo_id, - local_config=db_portal.config, db_instance=db_portal) + peer_type=db_portal.peer_type, mxid=db_portal.mxid, username=db_portal.username, + megagroup=db_portal.megagroup, title=db_portal.title, about=db_portal.about, + photo_id=db_portal.photo_id, local_config=db_portal.config, + encrypted=db_portal.encrypted, db_instance=db_portal) # endregion # region Class instance lookup @@ -506,6 +511,7 @@ class BasePortal(ABC): def init(context: Context) -> None: global config BasePortal.az, config, BasePortal.loop, BasePortal.bot = context.core + BasePortal.matrix = context.mx BasePortal.max_initial_member_sync = config["bridge.max_initial_member_sync"] BasePortal.sync_channel_members = config["bridge.sync_channel_members"] BasePortal.sync_matrix_state = config["bridge.sync_matrix_state"] diff --git a/mautrix_telegram/portal/metadata.py b/mautrix_telegram/portal/metadata.py index 7c6ef837..4d4d491f 100644 --- a/mautrix_telegram/portal/metadata.py +++ b/mautrix_telegram/portal/metadata.py @@ -362,7 +362,7 @@ class PortalMetadata(BasePortal, ABC): levels.kick = overrides.get("kick", 50) levels.redact = overrides.get("redact", 50) levels.invite = overrides.get("invite", 50 if dbr.invite_users else 0) - levels.events[EventType.ROOM_ENCRYPTED] = 99 + levels.events[EventType.ROOM_ENCRYPTION] = 99 levels.events[EventType.ROOM_TOMBSTONE] = 99 levels.events[EventType.ROOM_NAME] = 50 if dbr.change_info else 0 levels.events[EventType.ROOM_AVATAR] = 50 if dbr.change_info else 0 diff --git a/mautrix_telegram/portal/telegram.py b/mautrix_telegram/portal/telegram.py index 35c992bb..61f655fd 100644 --- a/mautrix_telegram/portal/telegram.py +++ b/mautrix_telegram/portal/telegram.py @@ -38,7 +38,7 @@ from telethon.tl.types import ( from mautrix.appservice import IntentAPI from mautrix.types import (EventID, UserID, ImageInfo, ThumbnailInfo, RelatesTo, MessageType, EventType, MediaMessageEventContent, TextMessageEventContent, - LocationMessageEventContent, Format) + LocationMessageEventContent, Format, MessageEventContent) from ..types import TelegramID from ..db import Message as DBMessage, TelegramFile as DBTelegramFile @@ -71,6 +71,12 @@ class PortalTelegram(BasePortal, ABC): return f"https://t.me/c/{self.tgid}/{evt.id}" return None + async def _send_message(self, intent: IntentAPI, content: MessageEventContent, + event_type: EventType = EventType.ROOM_MESSAGE, **kwargs) -> EventID: + if self.encrypted and self.matrix.e2ee: + event_type, content = await self.matrix.e2ee.encrypt(self.mxid, event_type, content) + return await intent.send_message_event(self.mxid, event_type, content, **kwargs) + async def handle_telegram_photo(self, source: 'AbstractUser', intent: IntentAPI, evt: Message, relates_to: Dict = None) -> Optional[EventID]: loc, largest_size = self._get_largest_photo_size(evt.media.photo) @@ -85,7 +91,7 @@ class PortalTelegram(BasePortal, ABC): prefix_text="Inline image: ") content.external_url = self._get_external_url(evt) await intent.set_typing(self.mxid, is_typing=False) - return await intent.send_message(self.mxid, content, timestamp=evt.date) + return await self._send_message(intent, content, timestamp=evt.date) info = ImageInfo( height=largest_size.h, width=largest_size.w, orientation=0, mimetype=file.mime_type, size=(len(largest_size.bytes) if (isinstance(largest_size, PhotoCachedSize)) @@ -95,12 +101,12 @@ class PortalTelegram(BasePortal, ABC): content = MediaMessageEventContent(url=file.mxc, msgtype=MessageType.IMAGE, info=info, body=name, relates_to=relates_to, external_url=self._get_external_url(evt)) - result = await intent.send_message(self.mxid, content, timestamp=evt.date) + result = await self._send_message(intent, content, timestamp=evt.date) if evt.message: caption_content = await formatter.telegram_to_matrix(evt, source, self.main_intent, no_reply_fallback=True) caption_content.external_url = content.external_url - result = await intent.send_message(self.mxid, caption_content, timestamp=evt.date) + result = await self._send_message(intent, caption_content, timestamp=evt.date) return result @staticmethod @@ -168,6 +174,7 @@ class PortalTelegram(BasePortal, ABC): if document.size > config["bridge.max_document_size"] * 1000 ** 2: name = attrs.name or "" caption = f"\n{evt.message}" if evt.message else "" + # TODO encrypt return await intent.send_notice(self.mxid, f"Too large file {name}{caption}") thumb_loc, thumb_size = self._get_largest_photo_size(document) @@ -199,7 +206,7 @@ class PortalTelegram(BasePortal, ABC): "audio/": MessageType.AUDIO, "image/": MessageType.IMAGE, }.get(info.mimetype[:6], MessageType.FILE)) - return await intent.send_message_event(self.mxid, event_type, content, timestamp=evt.date) + return await self._send_message(intent, content, event_type=event_type, timestamp=evt.date) def handle_telegram_location(self, _: 'AbstractUser', intent: IntentAPI, evt: Message, relates_to: dict = None) -> Awaitable[EventID]: @@ -218,7 +225,7 @@ class PortalTelegram(BasePortal, ABC): content["format"] = str(Format.HTML) content["formatted_body"] = f"Location: {body}" - return intent.send_message(self.mxid, content, timestamp=evt.date) + return self._send_message(intent, content, timestamp=evt.date) async def handle_telegram_text(self, source: 'AbstractUser', intent: IntentAPI, is_bot: bool, evt: Message) -> EventID: @@ -228,7 +235,7 @@ class PortalTelegram(BasePortal, ABC): if is_bot and self.get_config("bot_messages_as_notices"): content.msgtype = MessageType.NOTICE await intent.set_typing(self.mxid, is_typing=False) - return await intent.send_message(self.mxid, content, timestamp=evt.date) + return await self._send_message(intent, content, timestamp=evt.date) async def handle_telegram_unsupported(self, source: 'AbstractUser', intent: IntentAPI, evt: Message, relates_to: dict = None) -> EventID: @@ -241,7 +248,7 @@ class PortalTelegram(BasePortal, ABC): content.external_url = self._get_external_url(evt) content["net.maunium.telegram.unsupported"] = True await intent.set_typing(self.mxid, is_typing=False) - return await intent.send_message(self.mxid, content, timestamp=evt.date) + return await self._send_message(intent, content, timestamp=evt.date) async def handle_telegram_poll(self, source: 'AbstractUser', intent: IntentAPI, evt: Message, relates_to: RelatesTo) -> EventID: @@ -267,7 +274,7 @@ class PortalTelegram(BasePortal, ABC): relates_to=relates_to, external_url=self._get_external_url(evt)) await intent.set_typing(self.mxid, is_typing=False) - return await intent.send_message(self.mxid, content, timestamp=evt.date) + return await self._send_message(intent, content, timestamp=evt.date) @staticmethod def _int_to_bytes(i: int) -> bytes: @@ -309,7 +316,7 @@ class PortalTelegram(BasePortal, ABC): content["net.maunium.telegram.game"] = play_id await intent.set_typing(self.mxid, is_typing=False) - return await intent.send_message(self.mxid, content, timestamp=evt.date) + return await self._send_message(intent, content, timestamp=evt.date) async def handle_telegram_edit(self, source: 'AbstractUser', sender: p.Puppet, evt: Message ) -> None: @@ -353,7 +360,7 @@ class PortalTelegram(BasePortal, ABC): intent = sender.intent_for(self) if sender else self.main_intent await intent.set_typing(self.mxid, is_typing=False) - event_id = await intent.send_message(self.mxid, content) + event_id = await self._send_message(intent, content) prev_edit_msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space, -1) or editing_msg DBMessage(mxid=event_id, mx_room=self.mxid, tg_space=tg_space, tgid=TelegramID(evt.id), @@ -522,6 +529,7 @@ class PortalTelegram(BasePortal, ABC): elif isinstance(action, MessageActionChatMigrateTo): self.peer_type = "channel" self._migrate_and_save_telegram(TelegramID(action.channel_id)) + # TODO encrypt await sender.intent_for(self).send_emote(self.mxid, "upgraded this group to a supergroup.") elif isinstance(action, MessageActionGameScore): diff --git a/optional-requirements.txt b/optional-requirements.txt index 0c404596..61f51981 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -18,3 +18,6 @@ prometheus_client>=0.6,<0.8 #/postgres psycopg2-binary>=2,<3 + +#/e2be +matrix-nio[e2e]>=0.9,<0.10 diff --git a/requirements.txt b/requirements.txt index 4941f95a..e841e270 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,6 @@ ruamel.yaml>=0.15.35,<0.17 python-magic>=0.4,<0.5 commonmark>=0.8,<0.10 aiohttp>=3,<4 -mautrix==0.5.0.beta4 +mautrix==0.5.0.beta5 telethon>=1.10,<1.12 telethon-session-sqlalchemy>=0.2.14,<0.3