class EncryptionManager: loop: asyncio.AbstractEventLoop log: TraceLogger = logging.getLogger("mau.bridge.e2ee") client: Client crypto: OlmMachine crypto_store: CryptoStore | SyncStore crypto_db: Database | None state_store: StateStore bridge: br.Bridge az: AppService _id_prefix: str _id_suffix: str _share_session_events: dict[RoomID, asyncio.Event] def __init__( self, bridge: br.Bridge, homeserver_address: str, user_id_prefix: str, user_id_suffix: str, db_url: str, key_sharing_config: dict[str, bool] = None, ) -> None: self.loop = bridge.loop or asyncio.get_event_loop() self.bridge = bridge self.az = bridge.az self.device_name = bridge.name self._id_prefix = user_id_prefix self._id_suffix = user_id_suffix self._share_session_events = {} self.key_sharing_config = key_sharing_config or {} pickle_key = "mautrix.bridge.e2ee" self.crypto_db = Database.create( url=db_url, upgrade_table=PgCryptoStore.upgrade_table, log=logging.getLogger("mau.crypto.db"), ) self.crypto_store = PgCryptoStore("", pickle_key, self.crypto_db) self.state_store = PgCryptoStateStore(self.crypto_db, bridge.get_portal) default_http_retry_count = bridge.config.get( "homeserver.http_retry_count", None) self.client = Client( base_url=homeserver_address, mxid=self.az.bot_mxid, loop=self.loop, sync_store=self.crypto_store, log=self.log.getChild("client"), default_retry_count=default_http_retry_count, ) self.crypto = OlmMachine(self.client, self.crypto_store, self.state_store) self.client.add_event_handler(InternalEventType.SYNC_STOPPED, self._exit_on_sync_fail) self.crypto.allow_key_share = self.allow_key_share async def _exit_on_sync_fail(self, data) -> None: if data["error"]: self.log.critical("Exiting due to crypto sync error") sys.exit(32) async def allow_key_share(self, device: DeviceIdentity, request: RequestedKeyInfo) -> bool: require_verification = self.key_sharing_config.get( "require_verification", True) allow = self.key_sharing_config.get("allow", False) if not allow: self.log.debug( f"Key sharing not enabled, ignoring key request from " f"{device.user_id}/{device.device_id}") return False elif device.trust == TrustState.BLACKLISTED: raise RejectKeyShare( f"Rejecting key request from blacklisted device " f"{device.user_id}/{device.device_id}", code=RoomKeyWithheldCode.BLACKLISTED, reason="You have been blacklisted by this device", ) elif device.trust == TrustState.VERIFIED or not require_verification: portal = await self.bridge.get_portal(request.room_id) if portal is None: raise RejectKeyShare( f"Rejecting key request for {request.session_id} from " f"{device.user_id}/{device.device_id}: room is not a portal", code=RoomKeyWithheldCode.UNAVAILABLE, reason="Requested room is not a portal", ) user = await self.bridge.get_user(device.user_id) if not await user.is_in_portal(portal): raise RejectKeyShare( f"Rejecting key request for {request.session_id} from " f"{device.user_id}/{device.device_id}: user is not in portal", code=RoomKeyWithheldCode.UNAUTHORIZED, reason="You're not in that portal", ) self.log.debug( f"Accepting key request for {request.session_id} from " f"{device.user_id}/{device.device_id}") return True else: raise RejectKeyShare( f"Rejecting key request from unverified device " f"{device.user_id}/{device.device_id}", code=RoomKeyWithheldCode.UNVERIFIED, reason="You have not been verified by this device", ) def _ignore_user(self, user_id: str) -> bool: return (user_id.startswith(self._id_prefix) and user_id.endswith(self._id_suffix) and user_id != self.az.bot_mxid) async def handle_member_event(self, evt: StateEvent) -> None: if self._ignore_user(evt.state_key): # We don't want to invalidate group sessions because a ghost left or joined return await self.crypto.handle_member_event(evt) async def _share_session_lock(self, room_id: RoomID) -> bool: try: event = self._share_session_events[room_id] except KeyError: self._share_session_events[room_id] = asyncio.Event() return True else: await event.wait() return False async def encrypt( self, room_id: RoomID, event_type: EventType, content: Serializable | JSON ) -> tuple[EventType, EncryptedMegolmEventContent]: try: encrypted = await self.crypto.encrypt_megolm_event( room_id, event_type, content) except EncryptionError: self.log.debug( "Got EncryptionError, sharing group session and trying again") if await self._share_session_lock(room_id): try: users = await self.az.state_store.get_members_filtered( room_id, self._id_prefix, self._id_suffix, self.az.bot_mxid) await self.crypto.share_group_session(room_id, users) finally: self._share_session_events.pop(room_id).set() encrypted = await self.crypto.encrypt_megolm_event( room_id, event_type, content) return EventType.ROOM_ENCRYPTED, encrypted async def decrypt(self, evt: EncryptedEvent, wait_session_timeout: int = 5) -> MessageEvent: try: decrypted = await self.crypto.decrypt_megolm_event(evt) except SessionNotFound as e: if not wait_session_timeout: raise self.log.debug( f"Couldn't find session {e.session_id} trying to decrypt {evt.event_id}," f" waiting {wait_session_timeout} seconds...") got_keys = await self.crypto.wait_for_session( evt.room_id, e.sender_key, e.session_id, timeout=wait_session_timeout) if got_keys: self.log.debug(f"Got session {e.session_id} after waiting, " f"trying to decrypt {evt.event_id} again") decrypted = await self.crypto.decrypt_megolm_event(evt) else: raise self.log.trace("Decrypted event %s: %s", evt.event_id, decrypted) return decrypted async def start(self) -> None: flows = await self.client.get_login_flows() if not flows.supports_type(LoginType.APPSERVICE): self.log.critical( "Encryption enabled in config, but homeserver does not support appservice login" ) sys.exit(30) self.log.debug("Logging in with bridge bot user") if self.crypto_db: try: await self.crypto_db.start() except Exception as e: self.bridge._log_db_error(e) await self.crypto_store.open() device_id = await self.crypto_store.get_device_id() if device_id: self.log.debug(f"Found device ID in database: {device_id}") # We set the API token to the AS token here to authenticate the appservice login # It'll get overridden after the login self.client.api.token = self.az.as_token await self.client.login( login_type=LoginType.APPSERVICE, device_name=self.device_name, device_id=device_id, store_access_token=True, update_hs_url=False, ) await self.crypto.load() if not device_id: await self.crypto_store.put_device_id(self.client.device_id) self.log.debug( f"Logged in with new device ID {self.client.device_id}") _ = self.client.start(self._filter) self.log.info("End-to-bridge encryption support is enabled") async def stop(self) -> None: self.client.stop() await self.crypto_store.close() if self.crypto_db: await self.crypto_db.stop() @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(not_types=[all_events]), timeline=RoomEventFilter(not_types=[all_events]), account_data=RoomEventFilter(not_types=[all_events]), ephemeral=RoomEventFilter(not_types=[all_events]), ), )
class EncryptionManager: loop: asyncio.AbstractEventLoop log: TraceLogger = logging.getLogger("mau.bridge.e2ee") client: Client crypto: OlmMachine crypto_store: Union[CryptoStore, SyncStore] crypto_db: Optional[Database] state_store: StateStore bridge: 'Bridge' az: AppService login_shared_secret: bytes _id_prefix: str _id_suffix: str sync_task: asyncio.Future _share_session_events: Dict[RoomID, asyncio.Event] def __init__(self, bridge: 'Bridge', login_shared_secret: str, homeserver_address: str, user_id_prefix: str, user_id_suffix: str, db_url: str, key_sharing_config: Dict[str, bool] = None) -> None: self.loop = bridge.loop or asyncio.get_event_loop() self.bridge = bridge self.az = bridge.az self.device_name = bridge.name self._id_prefix = user_id_prefix self._id_suffix = user_id_suffix self.login_shared_secret = login_shared_secret.encode("utf-8") self._share_session_events = {} self.key_sharing_config = key_sharing_config or {} pickle_key = "mautrix.bridge.e2ee" if db_url.startswith("postgres://"): if not PgCryptoStore or not PgCryptoStateStore: raise RuntimeError( "Database URL is set to postgres, but asyncpg is not installed" ) self.crypto_db = Database( url=db_url, upgrade_table=PgCryptoStore.upgrade_table, log=logging.getLogger("mau.crypto.db"), loop=self.loop) self.crypto_store = PgCryptoStore("", pickle_key, self.crypto_db) self.state_store = PgCryptoStateStore(self.crypto_db, bridge.get_portal) elif db_url.startswith("pickle:///"): self.crypto_db = None self.crypto_store = PickleCryptoStore("", pickle_key, db_url[len("pickle:///"):]) self.state_store = SQLCryptoStateStore(bridge.get_portal) else: raise RuntimeError("Unsupported database scheme") self.client = Client(base_url=homeserver_address, mxid=self.az.bot_mxid, loop=self.loop, sync_store=self.crypto_store) self.crypto = OlmMachine(self.client, self.crypto_store, self.state_store) self.crypto.allow_key_share = self.allow_key_share async def allow_key_share(self, device: DeviceIdentity, request: RequestedKeyInfo) -> bool: require_verification = self.key_sharing_config.get( "require_verification", True) allow = self.key_sharing_config.get("allow", False) if not allow: self.log.debug( f"Key sharing not enabled, ignoring key request from " f"{device.user_id}/{device.device_id}") return False elif device.trust == TrustState.BLACKLISTED: raise RejectKeyShare( f"Rejecting key request from blacklisted device " f"{device.user_id}/{device.device_id}", code=RoomKeyWithheldCode.BLACKLISTED, reason="You have been blacklisted by this device") elif device.trust == TrustState.VERIFIED or not require_verification: portal = await self.bridge.get_portal(request.room_id) if portal is None: raise RejectKeyShare( f"Rejecting key request for {request.session_id} from " f"{device.user_id}/{device.device_id}: room is not a portal", code=RoomKeyWithheldCode.UNAVAILABLE, reason="Requested room is not a portal") user = await self.bridge.get_user(device.user_id) if not await user.is_in_portal(portal): raise RejectKeyShare( f"Rejecting key request for {request.session_id} from " f"{device.user_id}/{device.device_id}: user is not in portal", code=RoomKeyWithheldCode.UNAUTHORIZED, reason="You're not in that portal") self.log.debug( f"Accepting key request for {request.session_id} from " f"{device.user_id}/{device.device_id}") return True else: raise RejectKeyShare( f"Rejecting key request from unverified device " f"{device.user_id}/{device.device_id}", code=RoomKeyWithheldCode.UNVERIFIED, reason="You have not been verified by this device") def _ignore_user(self, user_id: str) -> bool: return (user_id.startswith(self._id_prefix) and user_id.endswith(self._id_suffix) and user_id != self.az.bot_mxid) async def handle_member_event(self, evt: StateEvent) -> None: if self._ignore_user(evt.state_key): # We don't want to invalidate group sessions because a ghost left or joined return await self.crypto.handle_member_event(evt) async def _share_session_lock(self, room_id: RoomID) -> bool: try: event = self._share_session_events[room_id] except KeyError: self._share_session_events[room_id] = asyncio.Event() return True else: await event.wait() return False async def encrypt( self, room_id: RoomID, event_type: EventType, content: Union[Serializable, JSON] ) -> Tuple[EventType, EncryptedMegolmEventContent]: try: encrypted = await self.crypto.encrypt_megolm_event( room_id, event_type, content) except EncryptionError: self.log.debug( "Got EncryptionError, sharing group session and trying again") if await self._share_session_lock(room_id): try: users = await self.az.state_store.get_members_filtered( room_id, self._id_prefix, self._id_suffix, self.az.bot_mxid) await self.crypto.share_group_session(room_id, users) finally: self._share_session_events.pop(room_id).set() encrypted = await self.crypto.encrypt_megolm_event( room_id, event_type, content) return EventType.ROOM_ENCRYPTED, encrypted async def decrypt(self, evt: EncryptedEvent) -> MessageEvent: decrypted = await self.crypto.decrypt_megolm_event(evt) self.log.trace("Decrypted event %s: %s", evt.event_id, decrypted) return decrypted 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() if self.crypto_db: await self.crypto_db.start() await self.crypto_store.open() device_id = await self.crypto_store.get_device_id() if device_id: self.log.debug(f"Found device ID in database: {device_id}") await self.client.login(password=password, device_name=self.device_name, device_id=device_id, store_access_token=True, update_hs_url=False) await self.crypto.load() if not device_id: await self.crypto_store.put_device_id(self.client.device_id) self.log.debug( f"Logged in with new device ID {self.client.device_id}") self.sync_task = self.client.start(self._filter) self.log.info("End-to-bridge encryption support is enabled") async def stop(self) -> None: self.sync_task.cancel() await self.crypto_store.close() if self.crypto_db: await self.crypto_db.stop() @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(not_types=[all_events]), timeline=RoomEventFilter(not_types=[all_events]), account_data=RoomEventFilter(not_types=[all_events]), ephemeral=RoomEventFilter(not_types=[all_events]), ), )