diff --git a/Cargo.lock b/Cargo.lock index abb3c6bed36..671eec05799 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,6 +65,12 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -374,7 +380,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "headers-core", "http", @@ -494,7 +500,7 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -1184,7 +1190,7 @@ version = "0.12.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -1458,7 +1464,7 @@ name = "synapse" version = "0.1.0" dependencies = [ "anyhow", - "base64", + "base64 0.21.7", "blake2", "bytes", "futures", diff --git a/changelog.d/18685.feature b/changelog.d/18685.feature new file mode 100644 index 00000000000..ded40f89ea5 --- /dev/null +++ b/changelog.d/18685.feature @@ -0,0 +1 @@ +Add support for MSC4291: Room IDs as hashes of create event. \ No newline at end of file diff --git a/synapse/api/room_versions.py b/synapse/api/room_versions.py index 4bde385f786..cc1d64af788 100644 --- a/synapse/api/room_versions.py +++ b/synapse/api/room_versions.py @@ -36,12 +36,14 @@ class EventFormatVersions: ROOM_V1_V2 = 1 # $id:server event id format: used for room v1 and v2 ROOM_V3 = 2 # MSC1659-style $hash event id format: used for room v3 ROOM_V4_PLUS = 3 # MSC1884-style $hash format: introduced for room v4 + ROOM_V11_PLUS_MSC4291 = 4 # MSC4291 room IDs as hashes: introduced for room MSC4291v11 KNOWN_EVENT_FORMAT_VERSIONS = { EventFormatVersions.ROOM_V1_V2, EventFormatVersions.ROOM_V3, EventFormatVersions.ROOM_V4_PLUS, + EventFormatVersions.ROOM_V11_PLUS_MSC4291, } @@ -109,6 +111,8 @@ class RoomVersion: msc3931_push_features: Tuple[str, ...] # values from PushRuleRoomFlag # MSC3757: Restricting who can overwrite a state event msc3757_enabled: bool + # MSC4291: Room IDs as hashes of the create event + msc4291_room_ids_as_hashes: bool class RoomVersions: @@ -131,6 +135,7 @@ class RoomVersions: enforce_int_power_levels=False, msc3931_push_features=(), msc3757_enabled=False, + msc4291_room_ids_as_hashes=False, ) V2 = RoomVersion( "2", @@ -151,6 +156,7 @@ class RoomVersions: enforce_int_power_levels=False, msc3931_push_features=(), msc3757_enabled=False, + msc4291_room_ids_as_hashes=False, ) V3 = RoomVersion( "3", @@ -171,6 +177,7 @@ class RoomVersions: enforce_int_power_levels=False, msc3931_push_features=(), msc3757_enabled=False, + msc4291_room_ids_as_hashes=False, ) V4 = RoomVersion( "4", @@ -191,6 +198,7 @@ class RoomVersions: enforce_int_power_levels=False, msc3931_push_features=(), msc3757_enabled=False, + msc4291_room_ids_as_hashes=False, ) V5 = RoomVersion( "5", @@ -211,6 +219,7 @@ class RoomVersions: enforce_int_power_levels=False, msc3931_push_features=(), msc3757_enabled=False, + msc4291_room_ids_as_hashes=False, ) V6 = RoomVersion( "6", @@ -231,6 +240,7 @@ class RoomVersions: enforce_int_power_levels=False, msc3931_push_features=(), msc3757_enabled=False, + msc4291_room_ids_as_hashes=False, ) V7 = RoomVersion( "7", @@ -251,6 +261,7 @@ class RoomVersions: enforce_int_power_levels=False, msc3931_push_features=(), msc3757_enabled=False, + msc4291_room_ids_as_hashes=False, ) V8 = RoomVersion( "8", @@ -271,6 +282,7 @@ class RoomVersions: enforce_int_power_levels=False, msc3931_push_features=(), msc3757_enabled=False, + msc4291_room_ids_as_hashes=False, ) V9 = RoomVersion( "9", @@ -291,6 +303,7 @@ class RoomVersions: enforce_int_power_levels=False, msc3931_push_features=(), msc3757_enabled=False, + msc4291_room_ids_as_hashes=False, ) V10 = RoomVersion( "10", @@ -311,6 +324,7 @@ class RoomVersions: enforce_int_power_levels=True, msc3931_push_features=(), msc3757_enabled=False, + msc4291_room_ids_as_hashes=False, ) MSC1767v10 = RoomVersion( # MSC1767 (Extensible Events) based on room version "10" @@ -332,6 +346,7 @@ class RoomVersions: enforce_int_power_levels=True, msc3931_push_features=(PushRuleRoomFlag.EXTENSIBLE_EVENTS,), msc3757_enabled=False, + msc4291_room_ids_as_hashes=False, ) MSC3757v10 = RoomVersion( # MSC3757 (Restricting who can overwrite a state event) based on room version "10" @@ -353,6 +368,7 @@ class RoomVersions: enforce_int_power_levels=True, msc3931_push_features=(), msc3757_enabled=True, + msc4291_room_ids_as_hashes=False, ) V11 = RoomVersion( "11", @@ -373,6 +389,7 @@ class RoomVersions: enforce_int_power_levels=True, msc3931_push_features=(), msc3757_enabled=False, + msc4291_room_ids_as_hashes=False, ) MSC3757v11 = RoomVersion( # MSC3757 (Restricting who can overwrite a state event) based on room version "11" @@ -394,6 +411,28 @@ class RoomVersions: enforce_int_power_levels=True, msc3931_push_features=(), msc3757_enabled=True, + msc4291_room_ids_as_hashes=False, + ) + MSC4291v11 = RoomVersion( + "org.matrix.msc4291.11", + RoomDisposition.UNSTABLE, + EventFormatVersions.ROOM_V11_PLUS_MSC4291, + StateResolutionVersions.V2, + enforce_key_validity=True, + special_case_aliases_auth=False, + strict_canonicaljson=True, + limit_notifications_power_levels=True, + implicit_room_creator=True, # Used by MSC3820 + updated_redaction_rules=True, # Used by MSC3820 + restricted_join_rule=True, + restricted_join_rule_fix=True, + knock_join_rule=True, + msc3389_relation_redactions=False, + knock_restricted_join_rule=True, + enforce_int_power_levels=True, + msc3931_push_features=(), + msc3757_enabled=False, + msc4291_room_ids_as_hashes=True, ) @@ -413,6 +452,7 @@ class RoomVersions: RoomVersions.V11, RoomVersions.MSC3757v10, RoomVersions.MSC3757v11, + RoomVersions.MSC4291v11, ) } diff --git a/synapse/crypto/event_signing.py b/synapse/crypto/event_signing.py index b85df1ce422..c36398cec08 100644 --- a/synapse/crypto/event_signing.py +++ b/synapse/crypto/event_signing.py @@ -101,6 +101,9 @@ def compute_content_hash( event_dict.pop("outlier", None) event_dict.pop("destinations", None) + # N.B. no need to pop the room_id from create events in MSC4291 rooms + # as they shouldn't have one. + event_json_bytes = encode_canonical_json(event_dict) hashed = hash_algorithm(event_json_bytes) diff --git a/synapse/event_auth.py b/synapse/event_auth.py index 35d02c8294e..4a8b7c40c74 100644 --- a/synapse/event_auth.py +++ b/synapse/event_auth.py @@ -261,7 +261,8 @@ async def check_state_independent_auth_rules( f"Event {event.event_id} has unexpected auth_event for {k}: {auth_event_id}", ) - # We also need to check that the auth event itself is not rejected. + # 2.3 ... If there are entries which were themselves rejected under the checks performed on receipt + # of a PDU, reject. if auth_event.rejected_reason: raise AuthError( 403, @@ -271,7 +272,7 @@ async def check_state_independent_auth_rules( auth_dict[k] = auth_event_id - # 3. If event does not have a m.room.create in its auth_events, reject. + # 2.4. If event does not have a m.room.create in its auth_events, reject. creation_event = auth_dict.get((EventTypes.Create, ""), None) if not creation_event: raise AuthError(403, "No create event in auth events") @@ -311,13 +312,14 @@ def check_state_dependent_auth_rules( # Later code relies on there being a create event e.g _can_federate, _is_membership_change_allowed # so produce a more intelligible error if we don't have one. - if auth_dict.get(CREATE_KEY) is None: + create_event = auth_dict.get(CREATE_KEY) + if create_event is None: raise AuthError( 403, f"Event {event.event_id} is missing a create event in auth_events." ) # additional check for m.federate - creating_domain = get_domain_from_id(event.room_id) + creating_domain = get_domain_from_id(create_event.sender) originating_domain = get_domain_from_id(event.sender) if creating_domain != originating_domain: if not _can_federate(event, auth_dict): @@ -470,12 +472,20 @@ def _check_create(event: "EventBase") -> None: if event.prev_event_ids(): raise AuthError(403, "Create event has prev events") - # 1.2 If the domain of the room_id does not match the domain of the sender, - # reject. - sender_domain = get_domain_from_id(event.sender) - room_id_domain = get_domain_from_id(event.room_id) - if room_id_domain != sender_domain: - raise AuthError(403, "Creation event's room_id domain does not match sender's") + if event.room_version.msc4291_room_ids_as_hashes: + # 1.2 If the create event has a room_id, reject + if "room_id" in event: + raise AuthError(403, "Create event has a room_id") + else: + # 1.2 If the domain of the room_id does not match the domain of the sender, + # reject. + if not event.room_version.msc4291_room_ids_as_hashes: + sender_domain = get_domain_from_id(event.sender) + room_id_domain = get_domain_from_id(event.room_id) + if room_id_domain != sender_domain: + raise AuthError( + 403, "Creation event's room_id domain does not match sender's" + ) # 1.3 If content.room_version is present and is not a recognised version, reject room_version_prop = event.content.get("room_version", "1") @@ -533,7 +543,13 @@ def _is_membership_change_allowed( target_user_id = event.state_key - creating_domain = get_domain_from_id(event.room_id) + # We need the create event in order to check if we can federate or not. + # If it's missing, yell loudly. Previously we only did this inside the + # _can_federate check. + create_event = auth_events.get((EventTypes.Create, "")) + if not create_event: + raise AuthError(403, "Create event missing from auth_events") + creating_domain = get_domain_from_id(create_event.sender) target_domain = get_domain_from_id(target_user_id) if creating_domain != target_domain: if not _can_federate(event, auth_events): diff --git a/synapse/events/__init__.py b/synapse/events/__init__.py index c77d569e2e7..014dec28669 100644 --- a/synapse/events/__init__.py +++ b/synapse/events/__init__.py @@ -44,7 +44,10 @@ from synapse.api.constants import EventTypes, RelationTypes from synapse.api.room_versions import EventFormatVersions, RoomVersion, RoomVersions from synapse.synapse_rust.events import EventInternalMetadata -from synapse.types import JsonDict, StrCollection +from synapse.types import ( + JsonDict, + StrCollection, +) from synapse.util.caches import intern_dict from synapse.util.frozenutils import freeze @@ -209,7 +212,6 @@ def __init__( content: DictProperty[JsonDict] = DictProperty("content") hashes: DictProperty[Dict[str, str]] = DictProperty("hashes") origin_server_ts: DictProperty[int] = DictProperty("origin_server_ts") - room_id: DictProperty[str] = DictProperty("room_id") sender: DictProperty[str] = DictProperty("sender") # TODO state_key should be Optional[str]. This is generally asserted in Synapse # by calling is_state() first (which ensures it is not None), but it is hard (not possible?) @@ -224,6 +226,10 @@ def __init__( def event_id(self) -> str: raise NotImplementedError() + @property + def room_id(self) -> str: + raise NotImplementedError() + @property def membership(self) -> str: return self.content["membership"] @@ -386,6 +392,10 @@ def __init__( def event_id(self) -> str: return self._event_id + @property + def room_id(self) -> str: + return self._dict["room_id"] + class FrozenEventV2(EventBase): format_version = EventFormatVersions.ROOM_V3 # All events of this type are V2 @@ -443,6 +453,10 @@ def event_id(self) -> str: self._event_id = "$" + encode_base64(compute_event_reference_hash(self)[1]) return self._event_id + @property + def room_id(self) -> str: + return self._dict["room_id"] + def prev_event_ids(self) -> List[str]: """Returns the list of prev event IDs. The order matches the order specified in the event, though there is no meaning to it. @@ -481,6 +495,67 @@ def event_id(self) -> str: return self._event_id +class FrozenEventV4(FrozenEventV3): + """FrozenEventV4 for MSC4291 room IDs are hashes""" + + format_version = EventFormatVersions.ROOM_V11_PLUS_MSC4291 + + """Override the room_id for m.room.create events""" + + def __init__( + self, + event_dict: JsonDict, + room_version: RoomVersion, + internal_metadata_dict: Optional[JsonDict] = None, + rejected_reason: Optional[str] = None, + ): + super().__init__( + event_dict=event_dict, + room_version=room_version, + internal_metadata_dict=internal_metadata_dict, + rejected_reason=rejected_reason, + ) + self._room_id: Optional[str] = None + + @property + def room_id(self) -> str: + # if we have calculated the room ID already, don't do it again. + if self._room_id: + return self._room_id + + is_create_event = self.type == EventTypes.Create and self.get_state_key() == "" + + # for non-create events: use the supplied value from the JSON, as per FrozenEventV3 + if not is_create_event: + self._room_id = self._dict["room_id"] + assert self._room_id is not None + return self._room_id + + # for create events: calculate the room ID + from synapse.crypto.event_signing import compute_event_reference_hash + + self._room_id = "!" + encode_base64( + compute_event_reference_hash(self)[1], urlsafe=True + ) + return self._room_id + + def auth_event_ids(self) -> StrCollection: + """Returns the list of auth event IDs. The order matches the order + specified in the event, though there is no meaning to it. + Returns: + The list of event IDs of this event's auth_events + Includes the creation event ID for convenience of all the codepaths + which expects the auth chain to include the creator ID, even though + it's explicitly not included on the wire. Excludes the create event + for the create event itself. + """ + create_event_id = "$" + self.room_id[1:] + assert create_event_id not in self._dict["auth_events"] + if self.type == EventTypes.Create and self.get_state_key() == "": + return self._dict["auth_events"] # should be [] + return self._dict["auth_events"] + [create_event_id] + + def _event_type_from_format_version( format_version: int, ) -> Type[Union[FrozenEvent, FrozenEventV2, FrozenEventV3]]: @@ -500,6 +575,8 @@ def _event_type_from_format_version( return FrozenEventV2 elif format_version == EventFormatVersions.ROOM_V4_PLUS: return FrozenEventV3 + elif format_version == EventFormatVersions.ROOM_V11_PLUS_MSC4291: + return FrozenEventV4 else: raise Exception("No event format %r" % (format_version,)) diff --git a/synapse/events/builder.py b/synapse/events/builder.py index afb04881df1..5e1913d389e 100644 --- a/synapse/events/builder.py +++ b/synapse/events/builder.py @@ -82,7 +82,8 @@ class EventBuilder: room_version: RoomVersion - room_id: str + # MSC4291 makes the room ID == the create event ID. This means the create event has no room_id. + room_id: Optional[str] type: str sender: str @@ -142,7 +143,14 @@ async def build( Returns: The signed and hashed event. """ + # Create events always have empty auth_events. + if self.type == EventTypes.Create and self.is_state() and self.state_key == "": + auth_event_ids = [] + + # Calculate auth_events for non-create events if auth_event_ids is None: + # Every non-create event must have a room ID + assert self.room_id is not None state_ids = await self._state.compute_state_after_events( self.room_id, prev_event_ids, @@ -224,12 +232,31 @@ async def build( "auth_events": auth_events, "prev_events": prev_events, "type": self.type, - "room_id": self.room_id, "sender": self.sender, "content": self.content, "unsigned": self.unsigned, "depth": depth, } + if self.room_id is not None: + event_dict["room_id"] = self.room_id + + if self.room_version.msc4291_room_ids_as_hashes: + # In MSC4291: the create event has no room ID as the create event ID /is/ the room ID. + if ( + self.type == EventTypes.Create + and self.is_state() + and self._state_key == "" + ): + assert self.room_id is None + else: + # All other events do not reference the create event in auth_events, as the room ID + # /is/ the create event. However, the rest of the code (for consistency between room + # versions) assume that the create event remains part of the auth events. c.f. event + # class which automatically adds the create event when `.auth_event_ids()` is called + assert self.room_id is not None + create_event_id = "$" + self.room_id[1:] + auth_event_ids.remove(create_event_id) + event_dict["auth_events"] = auth_event_ids if self.is_state(): event_dict["state_key"] = self._state_key @@ -285,7 +312,7 @@ def for_room_version( room_version=room_version, type=key_values["type"], state_key=key_values.get("state_key"), - room_id=key_values["room_id"], + room_id=key_values.get("room_id"), sender=key_values["sender"], content=key_values.get("content", {}), unsigned=key_values.get("unsigned", {}), diff --git a/synapse/events/utils.py b/synapse/events/utils.py index 02ffe2c95ee..1bd6e064b9d 100644 --- a/synapse/events/utils.py +++ b/synapse/events/utils.py @@ -176,9 +176,12 @@ def add_fields(*fields: str) -> None: if room_version.updated_redaction_rules: # MSC2176 rules state that create events cannot have their `content` redacted. new_content = event_dict["content"] - elif not room_version.implicit_room_creator: + if not room_version.implicit_room_creator: # Some room versions give meaning to `creator` add_fields("creator") + if room_version.msc4291_room_ids_as_hashes: + # room_id is not allowed on the create event as it's derived from the event ID + allowed_keys.remove("room_id") elif event_type == EventTypes.JoinRules: add_fields("join_rule") diff --git a/synapse/events/validator.py b/synapse/events/validator.py index 15095cc4ef9..4d9ba15829e 100644 --- a/synapse/events/validator.py +++ b/synapse/events/validator.py @@ -183,8 +183,18 @@ def validate_builder(self, event: Union[EventBase, EventBuilder]) -> None: fields an event would have """ + create_event_as_room_id = ( + event.room_version.msc4291_room_ids_as_hashes + and event.type == EventTypes.Create + and hasattr(event, "state_key") + and event.state_key == "" + ) + strings = ["room_id", "sender", "type"] + if create_event_as_room_id: + strings.remove("room_id") + if hasattr(event, "state_key"): strings.append("state_key") @@ -192,7 +202,14 @@ def validate_builder(self, event: Union[EventBase, EventBuilder]) -> None: if not isinstance(getattr(event, s), str): raise SynapseError(400, "Not '%s' a string type" % (s,)) - RoomID.from_string(event.room_id) + if not create_event_as_room_id: + assert event.room_id is not None + RoomID.from_string(event.room_id) + if event.room_version.msc4291_room_ids_as_hashes and not RoomID.is_valid( + event.room_id + ): + raise SynapseError(400, f"Invalid room ID '{event.room_id}'") + UserID.from_string(event.sender) if event.type == EventTypes.Message: diff --git a/synapse/federation/federation_base.py b/synapse/federation/federation_base.py index 8d1e156dab7..05c7809dc85 100644 --- a/synapse/federation/federation_base.py +++ b/synapse/federation/federation_base.py @@ -342,6 +342,21 @@ def event_from_pdu_json(pdu_json: JsonDict, room_version: RoomVersion) -> EventB if room_version.strict_canonicaljson: validate_canonicaljson(pdu_json) + # enforce that MSC4291 auth events don't include the create event. + # N.B. if they DO include a spurious create event, it'll fail auth checks elsewhere, so we don't + # need to do expensive DB lookups to find which event ID is the create event here. + if room_version.msc4291_room_ids_as_hashes: + room_id = pdu_json.get("room_id") + if room_id: + create_event_id = "$" + room_id[1:] + auth_events = pdu_json.get("auth_events") + if auth_events: + if create_event_id in auth_events: + raise SynapseError( + 400, + "auth_events must not contain the create event", + Codes.BAD_JSON, + ) event = make_event_from_dict(pdu_json, room_version) return event diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py index 5d6ee6996f7..8cc1bce75d0 100644 --- a/synapse/handlers/message.py +++ b/synapse/handlers/message.py @@ -678,7 +678,10 @@ async def create_event( Codes.USER_ACCOUNT_SUSPENDED, ) - if event_dict["type"] == EventTypes.Create and event_dict["state_key"] == "": + is_create_event = ( + event_dict["type"] == EventTypes.Create and event_dict["state_key"] == "" + ) + if is_create_event: room_version_id = event_dict["content"]["room_version"] maybe_room_version_obj = KNOWN_ROOM_VERSIONS.get(room_version_id) if not maybe_room_version_obj: @@ -785,6 +788,7 @@ async def _is_exempt_from_privacy_policy( """ # the only thing the user can do is join the server notices room. if builder.type == EventTypes.Member: + assert builder.room_id is not None membership = builder.content.get("membership", None) if membership == Membership.JOIN: return await self.store.is_server_notice_room(builder.room_id) @@ -1267,7 +1271,13 @@ async def create_new_client_event( % (len(prev_event_ids),) ) else: - prev_event_ids = await self.store.get_prev_events_for_room(builder.room_id) + if builder.type == EventTypes.Create: + prev_event_ids = [] + else: + assert builder.room_id is not None + prev_event_ids = await self.store.get_prev_events_for_room( + builder.room_id + ) # Do a quick sanity check here, rather than waiting until we've created the # event and then try to auth it (which fails with a somewhat confusing "No @@ -2153,6 +2163,7 @@ async def _rebuild_event_after_third_party_rules( original_event.room_version, third_party_result ) self.validator.validate_builder(builder) + assert builder.room_id is not None except SynapseError as e: raise Exception( "Third party rules module created an invalid event: " + e.msg, diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py index b063e301e69..8817b119558 100644 --- a/synapse/handlers/room.py +++ b/synapse/handlers/room.py @@ -81,6 +81,7 @@ Requester, RoomAlias, RoomID, + RoomIdWithDomain, RoomStreamToken, StateMap, StrCollection, @@ -220,8 +221,28 @@ async def upgrade_room( old_room = await self.store.get_room(old_room_id) if old_room is None: raise NotFoundError("Unknown room id %s" % (old_room_id,)) + old_room_is_public, _ = old_room - new_room_id = self._generate_room_id() + creation_event_with_context = None + if new_version.msc4291_room_ids_as_hashes: + old_room_create_event = await self.store.get_create_event_for_room( + old_room_id + ) + creation_content = self._calculate_upgraded_room_creation_content( + old_room_create_event, + tombstone_event_id=None, + new_room_version=new_version, + ) + creation_event_with_context = await self._generate_create_event_for_room_id( + requester, + creation_content, + old_room_is_public, + new_version, + ) + (create_event, _) = creation_event_with_context + new_room_id = create_event.room_id + else: + new_room_id = self._generate_room_id() # Try several times, it could fail with PartialStateConflictError # in _upgrade_room, cf comment in except block. @@ -270,6 +291,7 @@ async def upgrade_room( new_version, tombstone_event, tombstone_context, + creation_event_with_context, ) return ret @@ -293,6 +315,9 @@ async def _upgrade_room( new_version: RoomVersion, tombstone_event: EventBase, tombstone_context: synapse.events.snapshot.EventContext, + creation_event_with_context: Optional[ + Tuple[EventBase, synapse.events.snapshot.EventContext] + ] = None, ) -> str: """ Args: @@ -304,7 +329,7 @@ async def _upgrade_room( new_version: the version to upgrade the room to tombstone_event: the tombstone event to send to the old room tombstone_context: the context for the tombstone event - + creation_event_with_context: The new room's create event, for room IDs as create event IDs. Raises: ShadowBanError if the requester is shadow-banned. """ @@ -313,14 +338,16 @@ async def _upgrade_room( logger.info("Creating new room %s to replace %s", new_room_id, old_room_id) - # create the new room. may raise a `StoreError` in the exceedingly unlikely - # event of a room ID collision. - await self.store.store_room( - room_id=new_room_id, - room_creator_user_id=user_id, - is_public=old_room[0], - room_version=new_version, - ) + # We've already stored the room if we have the create event + if not creation_event_with_context: + # create the new room. may raise a `StoreError` in the exceedingly unlikely + # event of a room ID collision. + await self.store.store_room( + room_id=new_room_id, + room_creator_user_id=user_id, + is_public=old_room[0], + room_version=new_version, + ) await self.clone_existing_room( requester, @@ -328,6 +355,7 @@ async def _upgrade_room( new_room_id=new_room_id, new_room_version=new_version, tombstone_event_id=tombstone_event.event_id, + creation_event_with_context=creation_event_with_context, ) # now send the tombstone @@ -434,7 +462,6 @@ async def _update_upgraded_room_pls( ) except AuthError as e: logger.warning("Unable to update PLs in old room: %s", e) - await self.event_creation_handler.create_and_send_nonmember_event( requester, { @@ -449,6 +476,30 @@ async def _update_upgraded_room_pls( ratelimit=False, ) + def _calculate_upgraded_room_creation_content( + self, + old_room_create_event: EventBase, + tombstone_event_id: Optional[str], + new_room_version: RoomVersion, + ) -> JsonDict: + creation_content: JsonDict = { + "room_version": new_room_version.identifier, + "predecessor": { + "room_id": old_room_create_event.room_id, + }, + } + if tombstone_event_id is not None: + creation_content["predecessor"]["event_id"] = tombstone_event_id + # Check if old room was non-federatable + if not old_room_create_event.content.get(EventContentFields.FEDERATE, True): + # If so, mark the new room as non-federatable as well + creation_content[EventContentFields.FEDERATE] = False + # Copy the room type as per MSC3818. + room_type = old_room_create_event.content.get(EventContentFields.ROOM_TYPE) + if room_type is not None: + creation_content[EventContentFields.ROOM_TYPE] = room_type + return creation_content + async def clone_existing_room( self, requester: Requester, @@ -456,6 +507,9 @@ async def clone_existing_room( new_room_id: str, new_room_version: RoomVersion, tombstone_event_id: str, + creation_event_with_context: Optional[ + Tuple[EventBase, synapse.events.snapshot.EventContext] + ] = None, ) -> None: """Populate a new room based on an old room @@ -466,24 +520,23 @@ async def clone_existing_room( created with _generate_room_id()) new_room_version: the new room version to use tombstone_event_id: the ID of the tombstone event in the old room. + creation_event_with_context: The create event of the new room, if the new room supports + room ID as create event ID hash. """ user_id = requester.user.to_string() - creation_content: JsonDict = { - "room_version": new_room_version.identifier, - "predecessor": {"room_id": old_room_id, "event_id": tombstone_event_id}, - } - - # Check if old room was non-federatable - # Get old room's create event old_room_create_event = await self.store.get_create_event_for_room(old_room_id) - # Check if the create event specified a non-federatable room - if not old_room_create_event.content.get(EventContentFields.FEDERATE, True): - # If so, mark the new room as non-federatable as well - creation_content[EventContentFields.FEDERATE] = False - + if creation_event_with_context: + create_event, _ = creation_event_with_context + creation_content = create_event.content + else: + creation_content = self._calculate_upgraded_room_creation_content( + old_room_create_event, + tombstone_event_id, + new_room_version, + ) initial_state = {} # Replicate relevant room events @@ -499,11 +552,8 @@ async def clone_existing_room( (EventTypes.PowerLevels, ""), ] - # Copy the room type as per MSC3818. room_type = old_room_create_event.content.get(EventContentFields.ROOM_TYPE) if room_type is not None: - creation_content[EventContentFields.ROOM_TYPE] = room_type - # If the old room was a space, copy over the rooms in the space. if room_type == RoomTypes.SPACE: types_to_copy.append((EventTypes.SpaceChild, None)) @@ -603,6 +653,7 @@ async def clone_existing_room( invite_list=[], initial_state=initial_state, creation_content=creation_content, + creation_event_with_context=creation_event_with_context, ) # Transfer membership events @@ -902,11 +953,26 @@ async def create_room( self._validate_room_config(config, visibility) - room_id = await self._generate_and_create_room_id( - creator_id=user_id, - is_public=is_public, - room_version=room_version, - ) + creation_content = config.get("creation_content", {}) + # override any attempt to set room versions via the creation_content + creation_content["room_version"] = room_version.identifier + + creation_event_with_context = None + if room_version.msc4291_room_ids_as_hashes: + creation_event_with_context = await self._generate_create_event_for_room_id( + requester, + creation_content, + is_public, + room_version, + ) + (create_event, _) = creation_event_with_context + room_id = create_event.room_id + else: + room_id = await self._generate_and_create_room_id( + creator_id=user_id, + is_public=is_public, + room_version=room_version, + ) # Check whether this visibility value is blocked by a third party module allowed_by_third_party_rules = await ( @@ -943,11 +1009,6 @@ async def create_room( for val in raw_initial_state: initial_state[(val["type"], val.get("state_key", ""))] = val["content"] - creation_content = config.get("creation_content", {}) - - # override any attempt to set room versions via the creation_content - creation_content["room_version"] = room_version.identifier - ( last_stream_id, last_sent_event_id, @@ -964,6 +1025,7 @@ async def create_room( power_level_content_override=power_level_content_override, creator_join_profile=creator_join_profile, ignore_forced_encryption=ignore_forced_encryption, + creation_event_with_context=creation_event_with_context, ) # we avoid dropping the lock between invites, as otherwise joins can @@ -1029,6 +1091,38 @@ async def create_room( return room_id, room_alias, last_stream_id + async def _generate_create_event_for_room_id( + self, + creator: Requester, + creation_content: JsonDict, + is_public: bool, + room_version: RoomVersion, + ) -> Tuple[EventBase, synapse.events.snapshot.EventContext]: + ( + creation_event, + new_unpersisted_context, + ) = await self.event_creation_handler.create_event( + creator, + { + "content": creation_content, + "sender": creator.user.to_string(), + "type": EventTypes.Create, + "state_key": "", + }, + prev_event_ids=[], + depth=1, + state_map={}, + for_batch=False, + ) + await self.store.store_room( + room_id=creation_event.room_id, + room_creator_user_id=creator.user.to_string(), + is_public=is_public, + room_version=room_version, + ) + creation_context = await new_unpersisted_context.persist(creation_event) + return (creation_event, creation_context) + async def _send_events_for_new_room( self, creator: Requester, @@ -1042,6 +1136,9 @@ async def _send_events_for_new_room( power_level_content_override: Optional[JsonDict] = None, creator_join_profile: Optional[JsonDict] = None, ignore_forced_encryption: bool = False, + creation_event_with_context: Optional[ + Tuple[EventBase, synapse.events.snapshot.EventContext] + ] = None, ) -> Tuple[int, str, int]: """Sends the initial events into a new room. Sends the room creation, membership, and power level events into the room sequentially, then creates and batches up the @@ -1078,7 +1175,10 @@ async def _send_events_for_new_room( user in this room. ignore_forced_encryption: Ignore encryption forced by `encryption_enabled_by_default_for_room_type` setting. - + creation_event_with_context: + Set in MSC4291 rooms where the create event determines the room ID. If provided, + does not create an additional create event but instead appends the remaining new + events onto the provided create event. Returns: A tuple containing the stream ID, event ID and depth of the last event sent to the room. @@ -1143,13 +1243,26 @@ async def create_event( preset_config, config = self._room_preset_config(room_config) - # MSC2175 removes the creator field from the create event. - if not room_version.implicit_room_creator: - creation_content["creator"] = creator_id - creation_event, unpersisted_creation_context = await create_event( - EventTypes.Create, creation_content, False - ) - creation_context = await unpersisted_creation_context.persist(creation_event) + if creation_event_with_context is None: + # MSC2175 removes the creator field from the create event. + if not room_version.implicit_room_creator: + creation_content["creator"] = creator_id + creation_event, unpersisted_creation_context = await create_event( + EventTypes.Create, creation_content, False + ) + creation_context = await unpersisted_creation_context.persist( + creation_event + ) + else: + (creation_event, creation_context) = creation_event_with_context + # we had to do the above already in order to have a room ID, so just updates local vars + # and continue. + depth = 2 + prev_event = [creation_event.event_id] + state_map[(creation_event.type, creation_event.state_key)] = ( + creation_event.event_id + ) + logger.debug("Sending %s in new room", EventTypes.Member) ev = await self.event_creation_handler.handle_new_client_event( requester=creator, @@ -1409,7 +1522,7 @@ def _generate_room_id(self) -> str: A random room ID of the form "!opaque_id:domain". """ random_string = stringutils.random_string(18) - return RoomID(random_string, self.hs.hostname).to_string() + return RoomIdWithDomain(random_string, self.hs.hostname).to_string() async def _generate_and_create_room_id( self, diff --git a/synapse/handlers/room_member.py b/synapse/handlers/room_member.py index cf9db7b0189..9defa5cf0c9 100644 --- a/synapse/handlers/room_member.py +++ b/synapse/handlers/room_member.py @@ -1173,9 +1173,8 @@ async def update_membership_locked( elif effective_membership_state == Membership.KNOCK: if not is_host_in_room: - # The knock needs to be sent over federation instead - remote_room_hosts.append(get_domain_from_id(room_id)) - + # we used to add the domain of the room ID to remote_room_hosts. + # This is not safe in MSC4291 rooms which do not have a domain. content["membership"] = Membership.KNOCK try: diff --git a/synapse/types/__init__.py b/synapse/types/__init__.py index 5549f3c9f8d..b4a8c540740 100644 --- a/synapse/types/__init__.py +++ b/synapse/types/__init__.py @@ -353,12 +353,78 @@ class RoomAlias(DomainSpecificString): @attr.s(slots=True, frozen=True, repr=False) -class RoomID(DomainSpecificString): - """Structure representing a room id.""" +class RoomIdWithDomain(DomainSpecificString): + """Structure representing a room ID with a domain suffix.""" SIGIL = "!" +# the set of urlsafe base64 characters, no padding. +ROOM_ID_PATTERN_DOMAINLESS = re.compile(r"^[A-Za-z0-9\-_]{43}$") + + +@attr.define(slots=True, frozen=True, repr=False) +class RoomID: + """Structure representing a room id without a domain. + There are two forms of room IDs: + - "!localpart:domain" used in most room versions prior to MSC4291. + - "!event_id_base_64" used in room versions post MSC4291. + This class will accept any room ID which meets either of these two criteria. + """ + + SIGIL = "!" + id: str + room_id_with_domain: Optional[RoomIdWithDomain] + + @classmethod + def is_valid(cls: Type["RoomID"], s: str) -> bool: + if ":" in s: + return RoomIdWithDomain.is_valid(s) + try: + cls.from_string(s) + return True + except Exception: + return False + + def get_domain(self) -> Optional[str]: + if not self.room_id_with_domain: + return None + return self.room_id_with_domain.domain + + def to_string(self) -> str: + if self.room_id_with_domain: + return self.room_id_with_domain.to_string() + return self.id + + __repr__ = to_string + + @classmethod + def from_string(cls: Type["RoomID"], s: str) -> "RoomID": + # sigil check + if len(s) < 1 or s[0] != cls.SIGIL: + raise SynapseError( + 400, + "Expected %s string to start with '%s'" % (cls.__name__, cls.SIGIL), + Codes.INVALID_PARAM, + ) + + room_id_with_domain: Optional[RoomIdWithDomain] = None + if ":" in s: + room_id_with_domain = RoomIdWithDomain.from_string(s) + else: + # MSC4291 room IDs must be valid urlsafe unpadded base64 + val = s[1:] + if not ROOM_ID_PATTERN_DOMAINLESS.match(val): + raise SynapseError( + 400, + "Expected %s string to be valid urlsafe unpadded base64 '%s'" + % (cls.__name__, val), + Codes.INVALID_PARAM, + ) + + return cls(id=s, room_id_with_domain=room_id_with_domain) + + @attr.s(slots=True, frozen=True, repr=False) class EventID(DomainSpecificString): """Structure representing an event id.""" diff --git a/tests/storage/test_redaction.py b/tests/storage/test_redaction.py index 91be2ccb3ee..130674a229a 100644 --- a/tests/storage/test_redaction.py +++ b/tests/storage/test_redaction.py @@ -263,6 +263,7 @@ async def build( @property def room_id(self) -> str: + assert self._base_builder.room_id is not None return self._base_builder.room_id @property diff --git a/tests/types/test_init.py b/tests/types/test_init.py new file mode 100644 index 00000000000..b7a5b93ce98 --- /dev/null +++ b/tests/types/test_init.py @@ -0,0 +1,51 @@ +from synapse.api.errors import SynapseError +from synapse.types import RoomID + +from tests.unittest import TestCase + + +class RoomIDTestCase(TestCase): + def test_can_create_msc4291_room_ids(self) -> None: + valid_msc4291_room_id = "!31hneApxJ_1o-63DmFrpeqnkFfWppnzWso1JvH3ogLM" + room_id = RoomID.from_string(valid_msc4291_room_id) + self.assertEquals(RoomID.is_valid(valid_msc4291_room_id), True) + self.assertEquals( + room_id.to_string(), + valid_msc4291_room_id, + ) + self.assertEquals(room_id.id, "!31hneApxJ_1o-63DmFrpeqnkFfWppnzWso1JvH3ogLM") + self.assertEquals(room_id.get_domain(), None) + + def test_cannot_create_invalid_msc4291_room_ids(self) -> None: + invalid_room_ids = [ + "!wronglength", + "!31hneApxJ_1o-63DmFrpeqnNOTurlsafeBASE64/gLM", + "!", + "! ", + ] + for bad_room_id in invalid_room_ids: + with self.assertRaises(SynapseError): + RoomID.from_string(bad_room_id) + if not RoomID.is_valid(bad_room_id): + raise SynapseError(400, "invalid") + + def test_cannot_create_invalid_legacy_room_ids(self) -> None: + invalid_room_ids = [ + "!something:invalid$_chars.com", + ] + for bad_room_id in invalid_room_ids: + with self.assertRaises(SynapseError): + RoomID.from_string(bad_room_id) + if not RoomID.is_valid(bad_room_id): + raise SynapseError(400, "invalid") + + def test_can_create_valid_legacy_room_ids(self) -> None: + valid_room_ids = [ + "!foo:example.com", + "!foo:example.com:8448", + "!💩💩💩:example.com", + ] + for room_id_str in valid_room_ids: + room_id = RoomID.from_string(room_id_str) + self.assertEquals(RoomID.is_valid(room_id_str), True) + self.assertIsNotNone(room_id.get_domain())