Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/19956.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add experimental support for [MSC4233](https://github.com/matrix-org/matrix-spec-proposals/pull/4233): remember which server a user knocked through, so that knocks can be rescinded and denied over federation.
4 changes: 4 additions & 0 deletions synapse/config/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,10 @@ def read_config(
# MSC4222: Adding `state_after` to sync v2
self.msc4222_enabled: bool = experimental.get("msc4222_enabled", False)

# MSC4233: Remembering which server a user knocked through, so that
# knocks can be rescinded and denied over federation.
self.msc4233_enabled: bool = experimental.get("msc4233_enabled", False)

# MSC4076: Add `disable_badge_count`` to pusher configuration
self.msc4076_enabled: bool = experimental.get("msc4076_enabled", False)

Expand Down
27 changes: 18 additions & 9 deletions synapse/federation/federation_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,15 @@ class SendJoinResult:
servers_in_room: AbstractSet[str]


@attr.s(slots=True, frozen=True, auto_attribs=True)
class SendKnockResult:
# The response body from the remote server, of the form
# {"knock_room_state": [<state event dict>, ...]}.
response: JsonDict
# The server which fulfilled the knock (i.e. answered our /send_knock).
origin: str


class FederationClient(FederationBase):
def __init__(self, hs: "HomeServer"):
super().__init__(hs)
Expand Down Expand Up @@ -1437,7 +1446,9 @@ async def _do_send_leave(self, destination: str, pdu: EventBase) -> JsonDict:
# content.
return resp[1]

async def send_knock(self, destinations: list[str], pdu: EventBase) -> JsonDict:
async def send_knock(
self, destinations: list[str], pdu: EventBase
) -> "SendKnockResult":
"""Attempts to send a knock event to a given list of servers. Iterates
through the list until one attempt succeeds.

Expand All @@ -1450,20 +1461,18 @@ async def send_knock(self, destinations: list[str], pdu: EventBase) -> JsonDict:
pdu: The event to be sent.

Returns:
The remote homeserver return some state from the room. The response
dictionary is in the form:

{"knock_room_state": [<state event dict>, ...]}

The list of state events may be empty.
A SendKnockResult holding the remote homeserver's response (some
state from the room, possibly empty) and the name of the server
that fulfilled the knock.

Raises:
SynapseError: If the chosen remote server returns a 3xx/4xx code.
RuntimeError: If no servers were reachable.
"""

async def send_request(destination: str) -> JsonDict:
return await self._do_send_knock(destination, pdu)
async def send_request(destination: str) -> "SendKnockResult":
response = await self._do_send_knock(destination, pdu)
return SendKnockResult(response=response, origin=destination)

return await self._try_destination_list(
"send_knock", destinations, send_request
Expand Down
30 changes: 22 additions & 8 deletions synapse/federation/sender/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -669,26 +669,40 @@ async def handle_event(event: EventBase) -> None:
)
return

# If we've rescinded an invite then we want to tell the
# other server.
# If we've rescinded an invite, or denied a knock
# (MSC4233), then we want to tell the other server.
msc4233_enabled = self.hs.config.experimental.msc4233_enabled
if (
event.type == EventTypes.Member
and event.membership == Membership.LEAVE
and event.sender != event.state_key
and (
event.membership == Membership.LEAVE
or (msc4233_enabled and event.membership == Membership.BAN)
)
):
# We check if this leave event is rescinding an invite
# by looking if there is an invite event for the user in
# the auth events. It could otherwise be a kick or
# unban, which we don't want to send (if the user wasn't
# already in the room).
# (or denying a knock) by looking if there is an invite
# (or knock) event for the user in the auth events. It
# could otherwise be a kick or unban, which we don't
# want to send (if the user wasn't already in the
# room).
auth_events = await self.store.get_events_as_list(
event.auth_event_ids()
)
for auth_event in auth_events:
if (
auth_event.type == EventTypes.Member
and auth_event.state_key == event.state_key
and auth_event.membership == Membership.INVITE
and (
(
event.membership == Membership.LEAVE
and auth_event.membership == Membership.INVITE
)
or (
msc4233_enabled
and auth_event.membership == Membership.KNOCK
)
)
):
destinations = set(destinations)
destinations.add(get_domain_from_id(event.state_key))
Expand Down
24 changes: 23 additions & 1 deletion synapse/handlers/federation.py
Original file line number Diff line number Diff line change
Expand Up @@ -886,7 +886,18 @@ async def do_knock(

# Send the signed event back to the room, and potentially receive some
# further information about the room in the form of partial state events
knock_response = await self.federation_client.send_knock(target_hosts, event)
send_knock_result = await self.federation_client.send_knock(target_hosts, event)
knock_response = send_knock_result.response

# Remember which server fulfilled the knock, so that we can route a
# rescission of the knock through it later, and know which server to
# trust for an out-of-band retraction of the knock (MSC4233).
await self.store.store_local_knock_via_server(
user_id=knockee,
room_id=event.room_id,
via_server=send_knock_result.origin,
knock_event_id=event.event_id,
)

# Store any stripped room state events in the "unsigned" key of the event.
# This is a bit of a hack and is cribbing off of invites. Basically we
Expand Down Expand Up @@ -1162,6 +1173,17 @@ async def on_invite_request(

return event

async def do_remotely_rescind_knock(
self, target_hosts: Iterable[str], room_id: str, user_id: str, content: JsonDict
) -> tuple[EventBase, int]:
"""Rescind a knock on a remote room: the same make_leave/send_leave
dance as rejecting an invite, routed through the server(s) given
(normally the server the knock was fulfilled through, per MSC4233).
"""
return await self.do_remotely_reject_invite(
target_hosts, room_id, user_id, content
)

async def do_remotely_reject_invite(
self, target_hosts: Iterable[str], room_id: str, user_id: str, content: JsonDict
) -> tuple[EventBase, int]:
Expand Down
55 changes: 52 additions & 3 deletions synapse/handlers/federation_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,12 +256,17 @@ async def on_receive_pdu(self, origin: str, pdu: EventBase) -> None:
room_id, self.server_name
)
if not is_in_room:
# Check if this is a leave event rescinding an invite
# Check if this is a leave event rescinding an invite, or denying
# a knock (MSC4233)
msc4233_enabled = self._config.experimental.msc4233_enabled
if (
pdu.type == EventTypes.Member
and pdu.membership == Membership.LEAVE
and pdu.state_key != pdu.sender
and self._is_mine_id(pdu.state_key)
and (
pdu.membership == Membership.LEAVE
or (msc4233_enabled and pdu.membership == Membership.BAN)
)
):
(
membership,
Expand All @@ -270,7 +275,8 @@ async def on_receive_pdu(self, origin: str, pdu: EventBase) -> None:
pdu.state_key, pdu.room_id
)
if (
membership == Membership.INVITE
pdu.membership == Membership.LEAVE
and membership == Membership.INVITE
and membership_event_id
and membership_event_id
in pdu.auth_event_ids() # The invite should be in the auth events of the rescission.
Expand All @@ -292,6 +298,49 @@ async def on_receive_pdu(self, origin: str, pdu: EventBase) -> None:
context = EventContext.for_outlier(self._storage_controllers)
await self.persist_events_and_notify(room_id, [(pdu, context)])
return
elif (
msc4233_enabled
and membership == Membership.KNOCK
and membership_event_id
and membership_event_id
in pdu.auth_event_ids() # The knock should be in the auth events of the denial.
):
# A local user's knock on this remote room is being denied
# (or the user banned).
#
# We cannot fully auth the denial event, but the sender has
# demonstrated knowledge of our knock by referencing it in
# the auth events, and the event's signature (checked on
# receipt) proves it comes from the sender's server. On top
# of that, only accept the event from the server the knock
# was fulfilled through, or from the denying user's own
# server.
#
# Technically we cannot verify that the sender has the
# power level required to deny the knock, but the same
# holds for invite rescission above.
via_server = await self._store.get_local_knock_via_server(
pdu.state_key, pdu.room_id
)
if origin == via_server or origin == get_domain_from_id(pdu.sender):
# As an outlier the event will never gain a
# `replaces_state`, so reflect the replaced knock in
# `unsigned` ourselves: clients use `prev_content` to
# distinguish a denied knock from a plain kick.
knock_event = await self._store.get_event(
membership_event_id, allow_none=True
)
if knock_event is not None:
pdu.unsigned["replaces_state"] = knock_event.event_id
pdu.unsigned["prev_content"] = knock_event.content
pdu.unsigned["prev_sender"] = knock_event.sender

# Handle the denial event
pdu.internal_metadata.outlier = True
pdu.internal_metadata.out_of_band_membership = True
context = EventContext.for_outlier(self._storage_controllers)
await self.persist_events_and_notify(room_id, [(pdu, context)])
return

logger.info(
"Ignoring PDU from %s as we're not in the room",
Expand Down
37 changes: 31 additions & 6 deletions synapse/handlers/room_member.py
Original file line number Diff line number Diff line change
Expand Up @@ -2086,13 +2086,38 @@ async def remote_rescind_knock(

Implements RoomMemberHandler.remote_rescind_knock
"""
# TODO: We don't yet support rescinding knocks over federation
# as we don't know which homeserver to send it to. An obvious
# candidate is the remote homeserver we originally knocked through,
# however we don't currently store that information.

# Just rescind the knock locally
knock_event = await self.store.get_event(knock_event_id)

if self.hs.config.experimental.msc4233_enabled:
# MSC4233: route the rescission through the server the knock was
# fulfilled through, which we remembered at knock time.
via_server = await self.store.get_local_knock_via_server(
knock_event.state_key, knock_event.room_id
)
if via_server is not None:
try:
(
event,
stream_id,
) = await self.federation_handler.do_remotely_rescind_knock(
[via_server],
knock_event.room_id,
knock_event.state_key,
content,
)
return event.event_id, stream_id
except Exception as e:
# If we can't reach the remote server, fall back to
# rescinding the knock locally, as we would have done
# before MSC4233.
logger.warning(
"Failed to rescind knock on %s via %s: %s",
knock_event.room_id,
via_server,
e,
)

# Rescind the knock locally
return await self._generate_local_out_of_band_leave(
knock_event, txn_id, requester, content
)
Expand Down
15 changes: 9 additions & 6 deletions synapse/storage/databases/main/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -3129,13 +3129,15 @@ def _store_room_members_txn(
and event.internal_metadata.is_out_of_band_membership()
):
# The only sort of out-of-band-membership events we expect to see here
# are remote invites/knocks and LEAVE events corresponding to
# rejected/retracted invites and rescinded knocks.
# are remote invites/knocks, LEAVE events corresponding to
# rejected/retracted invites and rescinded knocks, and LEAVE/BAN
# events corresponding to denied knocks (MSC4233).
assert event.type == EventTypes.Member
assert event.membership in (
Membership.INVITE,
Membership.KNOCK,
Membership.LEAVE,
Membership.BAN,
)

self.db_pool.simple_upsert_txn(
Expand Down Expand Up @@ -3170,10 +3172,11 @@ def _store_room_members_txn(
"event_stream_ordering": event.internal_metadata.stream_ordering,
"event_instance_name": event.internal_metadata.instance_name,
}
if event.membership == Membership.LEAVE:
# Inherit the meta data from the remote invite/knock. When using
# sliding sync filters, this will prevent the room from
# disappearing/appearing just because you left the room.
if event.membership in (Membership.LEAVE, Membership.BAN):
# Inherit the meta data from the remote invite/knock (a BAN here is
# a denied knock, MSC4233). When using sliding sync filters, this
# will prevent the room from disappearing/appearing just because
# you left the room.
pass
elif event.membership in (Membership.INVITE, Membership.KNOCK):
extra_insert_values = (
Expand Down
41 changes: 41 additions & 0 deletions synapse/storage/databases/main/roommember.py
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,47 @@ async def get_local_current_membership_for_user_in_room(

return results

async def store_local_knock_via_server(
self, user_id: str, room_id: str, via_server: str, knock_event_id: str
) -> None:
"""Record the remote server that fulfilled a local user's knock on a
remote room, so that a later rescission can be routed through it
(MSC4233). Overwrites any previous record for the user/room pair.

Args:
user_id: The ID of the knocking (local) user.
room_id: The ID of the room that was knocked on.
via_server: The remote server that answered our /send_knock.
knock_event_id: The event ID of the knock membership event.
"""
await self.db_pool.simple_upsert(
table="local_knock_via_servers",
keyvalues={"user_id": user_id, "room_id": room_id},
values={"via_server": via_server, "knock_event_id": knock_event_id},
desc="store_local_knock_via_server",
)

async def get_local_knock_via_server(
self, user_id: str, room_id: str
) -> str | None:
"""Retrieve the remote server that fulfilled a local user's knock on a
remote room, if we have a record of one (MSC4233).

Args:
user_id: The ID of the knocking (local) user.
room_id: The ID of the room that was knocked on.

Returns:
The server name, or None if no knock route is recorded.
"""
return await self.db_pool.simple_select_one_onecol(
table="local_knock_via_servers",
keyvalues={"user_id": user_id, "room_id": room_id},
retcol="via_server",
allow_none=True,
desc="get_local_knock_via_server",
)

async def get_users_server_still_shares_room_with(
self, user_ids: Collection[str]
) -> set[str]:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
--
-- This file is licensed under the Affero General Public License (AGPL) version 3.
--
-- Copyright (C) 2026 New Vector, Ltd
--
-- 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.
--
-- See the GNU Affero General Public License for more details:
-- <https://www.gnu.org/licenses/agpl-3.0.html>.

-- Records the remote server that fulfilled a local user's knock on a remote
-- room (i.e. the server that answered our /send_knock request), so that we can
-- route a subsequent rescission of the knock through the same server, and
-- so that we know which server to trust for out-of-band retractions of the
-- knock (MSC4233).
CREATE TABLE local_knock_via_servers (
user_id TEXT NOT NULL,
room_id TEXT NOT NULL,
-- The server the knock was fulfilled through.
via_server TEXT NOT NULL,
-- The event ID of the knock membership event.
knock_event_id TEXT NOT NULL,
CONSTRAINT local_knock_via_servers_uniqueness UNIQUE (user_id, room_id)
);
Loading
Loading