Add initial Matrix end-to-bridge encryption support

This commit is contained in:
Tulir Asokan
2020-03-28 22:01:23 +02:00
parent 0a94e60e22
commit c79d442158
11 changed files with 218 additions and 24 deletions
@@ -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")
+2
View File
@@ -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")
+2 -1
View File
@@ -15,7 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
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)
+112
View File
@@ -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 <https://www.gnu.org/licenses/>.
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]),
),
)
+11
View File
@@ -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:
+27 -3
View File
@@ -13,14 +13,14 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
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:
+13 -7
View File
@@ -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"]
+1 -1
View File
@@ -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
+19 -11
View File
@@ -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: <a href='{url}'>{body}</a>"
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):
+3
View File
@@ -18,3 +18,6 @@ prometheus_client>=0.6,<0.8
#/postgres
psycopg2-binary>=2,<3
#/e2be
matrix-nio[e2e]>=0.9,<0.10
+1 -1
View File
@@ -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