Files
synapse/tests/rest/client/test_versions.py
T
Kegan DougalandClaude Fable 5.1 5161187a50 B1: Define the MSC4430 room version, the MemberKey type and the signing helpers
Rust: `RoomVersion.msc4430_member_keys` and `MSC4430V12`
("org.matrix.msc4430.12" = `MSC4242V12` + the flag); redaction of
`m.room.member` keeps `content.domain` and drops `content.localpart` in such
rooms, so the reference hash, the event ID and the member key signature cover
the claimed domain but not the claimed localpart;
`membership_field::{DOMAIN,LOCALPART}`; `org.matrix.msc4430` in `/versions`;
`.pyi` updated. The version is defined but *not* registered in
`KNOWN_ROOM_VERSIONS`, not even when the flag is on.

Python: `MemberKey` in `synapse.types` next to the other identifier types, with
the grammar `^@[A-Za-z0-9_-]{43}$` (anything with a `:` is a user ID),
`is_member_key` (syntactic, regex only, for hot paths) and
`MemberKey.from_string` (strict: rejects non-canonical base64, so each public
key has exactly one member key string). `MemberKeys` constants
(`SIGNATURE_ENTITY = "sender"`, key ID `ed25519:1`) and
`EventContentFields.MSC4430_{LOCALPART,DOMAIN}`. `supports_msc4430_member_keys`
is a `TypeIs[MSC4430Event]` modelled on `supports_msc4242_state_dag`;
`MSC4430Event` subclasses `MSC4242Event`, since every member key room is a state
DAG room, so one predicate narrows to both.

Signing: `add_hashes_and_signatures_from_signers(room_version, event_dict,
signers, *, server_name)` hashes once and then applies `(entity, key)` signers
in order; `compute_member_key_room_signature(room_version, event_dict, signer,
*, server_name)` adds one signature to an already hashed event (the authoriser's
signature on `/send_join`, our co-signature on an inbound `/invite`) without
rehashing a PDU we have not checked. The three signer kinds are: the sender's
member key under `sender`; our own server name co-signing linkage information;
the authorising member key of a restricted join under the member key string
itself. Together these are the one choke point for signing in member key rooms,
and they refuse with `MemberKeySigningRefusedError` (500) any signer the MSC
does not allow: a `sender` signature by a key other than the one `sender` names,
a key ID other than `ed25519:1`, a server co-signature under any name but ours
or on anything but an `m.room.member` whose `content.domain` is us, or an
authorising signature on anything but a join that names that key in
`join_authorised_via_users_server`. A bug in choosing signers therefore cannot
make us sign as a key we do not hold or vouch for linkage information that is
not ours. An empty signer list hashes only (`/make_join` templates).

Loudness: `get_domain_from_id` and `get_localpart_from_id` raise
`MemberKeyHasNoDomainError` (a `SynapseError`, 500: it is a server bug) when
given a member key, on the no-colon branch only, so user IDs and aliases are
untouched. Because the HTTP layer logs every `SynapseError` at INFO without a
traceback, and the error may be caught before it gets there, both MSC4430 errors
log themselves at ERROR with the stack of the raise site: the point of them is
to name the unadapted code path. `hs.is_mine_id` is left alone: it is called on
raw client strings (state keys, sliding-sync `required_state`,
`/users/{id}/report`, module API) and on remote-controlled room content
(`/upgrade` moving a canonical alias), where "False if malformed" is relied upon
and a member-key-shaped string in a legacy room is just malformed.
`hs.is_mine_identity(room_id, identity)` is the sanctioned question for room
identities; it answers for user IDs and raises `MemberKeyHasNoDomainError` for
member keys. The Prometheus counter
`synapse_msc4430_unsupported_path_total{server_name,path}` and
`record_unsupported_path(server_name, path)` (counts always, warns once per path
per process) are for branches that deliberately skip member keys.

The identity module is `synapse/handlers/member_keys.py` (`MemberKeyHandler`,
`hs.get_member_key_handler()`). The `MemberKey` type and `is_member_key` live in
`synapse.types` because `get_domain_from_id` needs them and the module cannot
import handlers.

Config: `msc4430_enabled` implies `msc4428_enabled`; the Complement image sets
it.

None of this is reachable: no identity in any existing room can satisfy
`is_member_key`, and the room version cannot be used. Not gated, by design: the
`is_member_key`-guarded raises in `get_domain_from_id`/`get_localpart_from_id`,
which only fire for member-key-shaped strings and turn a 400 "Invalid ID" into a
500. The one remote-reachable path that was a clean 400 before,
`/make_join|leave|knock/{roomId}/{userId}` with a member-key-shaped path
parameter (the handlers compare `get_domain_from_id(user_id)` with the origin),
now validates the parameter as a user ID at the servlet (400 `M_INVALID_PARAM`),
so a malformed request cannot make the Synapse federation client fail over
across every resident on a 5xx. A PDU whose sender is member-key-shaped in a
legacy room already fails at `_check_sigs_on_pdu` and is logged at ERROR with a
traceback on `/send`; only the status code changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 09:22:38 +02:00

203 lines
8.0 KiB
Python

# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2026 Element Creations 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>.
import logging
from twisted.internet.testing import MemoryReactor
from synapse.rest import admin
from synapse.rest.client import login, versions
from synapse.server import HomeServer
from synapse.types import JsonDict
from synapse.util.clock import Clock
from tests import unittest
logger = logging.getLogger(__name__)
class VersionsTestCase(unittest.HomeserverTestCase):
"""
Test `VersionsRestServlet`
"""
servlets = [
admin.register_servlets,
login.register_servlets,
versions.register_servlets,
]
def tearDown(self) -> None:
# MemoryReactor doesn't trigger the shutdown phases, and we want the
# Tokio thread pool to be stopped
# XXX: This logic should probably get moved somewhere else
shutdown_triggers = self.reactor.triggers.get("shutdown", {})
for phase in ["before", "during", "after"]:
triggers = shutdown_triggers.get(phase, [])
for callbable, args, kwargs in triggers:
callbable(*args, **kwargs)
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.admin_user = self.register_user("admin", "pass", admin=True)
self.admin_user_tok = self.login("admin", "pass")
def test_unauthenticated(self) -> None:
channel = self.make_request(
"GET",
"/_matrix/client/versions",
)
self.assertEqual(channel.code, 200, channel.result)
self._sanity_check_versions_response(channel.json_body)
def test_authenticated(self) -> None:
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass")
channel = self.make_request(
"GET",
"/_matrix/client/versions",
access_token=user1_tok,
)
self.assertEqual(channel.code, 200, channel.result)
self._sanity_check_versions_response(channel.json_body)
def test_authenticated_with_per_user_feature(self) -> None:
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass")
user2_id = self.register_user("user2", "pass")
user2_tok = self.login(user2_id, "pass")
# Sanity check that the experimental feature should not be enabled yet
channel = self.make_request(
"GET",
"/_matrix/client/versions",
access_token=user1_tok,
)
self.assertEqual(channel.code, 200, channel.result)
self._sanity_check_versions_response(channel.json_body)
self.assertEqual(
channel.json_body["unstable_features"]["org.matrix.msc3881"],
False,
channel.json_body,
)
# Enable the feature for this specific user
self._enable_experimental_feature_for_user(
target_user_id=user1_id, features={"msc3881": True}
)
# The experimental feature should be enabled for this user
channel = self.make_request(
"GET",
"/_matrix/client/versions",
access_token=user1_tok,
)
self.assertEqual(channel.code, 200, channel.result)
self._sanity_check_versions_response(channel.json_body)
self.assertEqual(
channel.json_body["unstable_features"]["org.matrix.msc3881"],
True,
channel.json_body,
)
# But not for other users
channel = self.make_request(
"GET",
"/_matrix/client/versions",
access_token=user2_tok,
)
self.assertEqual(channel.code, 200, channel.result)
self._sanity_check_versions_response(channel.json_body)
self.assertEqual(
channel.json_body["unstable_features"]["org.matrix.msc3881"],
False,
channel.json_body,
)
def test_msc4446_false_by_default(self) -> None:
channel = self.make_request("GET", "/_matrix/client/versions")
self.assertEqual(channel.code, 200, channel.result)
self.assertFalse(channel.json_body["unstable_features"]["com.beeper.msc4446"])
@unittest.override_config({"experimental_features": {"msc4446_enabled": True}})
def test_msc4446_true_if_enabled(self) -> None:
channel = self.make_request("GET", "/_matrix/client/versions")
self.assertEqual(channel.code, 200, channel.result)
self.assertTrue(channel.json_body["unstable_features"]["com.beeper.msc4446"])
def test_msc4502_false_by_default(self) -> None:
channel = self.make_request("GET", "/_matrix/client/versions")
self.assertEqual(channel.code, 200, channel.result)
self.assertFalse(channel.json_body["unstable_features"]["io.element.msc4502"])
@unittest.override_config({"experimental_features": {"msc4502_enabled": True}})
def test_msc4502_true_if_enabled(self) -> None:
channel = self.make_request("GET", "/_matrix/client/versions")
self.assertEqual(channel.code, 200, channel.result)
self.assertTrue(channel.json_body["unstable_features"]["io.element.msc4502"])
def test_msc4428_false_by_default(self) -> None:
channel = self.make_request("GET", "/_matrix/client/versions")
self.assertEqual(channel.code, 200, channel.result)
self.assertFalse(channel.json_body["unstable_features"]["org.matrix.msc4428"])
@unittest.override_config({"experimental_features": {"msc4428_enabled": True}})
def test_msc4428_true_if_enabled(self) -> None:
channel = self.make_request("GET", "/_matrix/client/versions")
self.assertEqual(channel.code, 200, channel.result)
self.assertTrue(channel.json_body["unstable_features"]["org.matrix.msc4428"])
self.assertFalse(channel.json_body["unstable_features"]["org.matrix.msc4430"])
def test_msc4430_false_by_default(self) -> None:
channel = self.make_request("GET", "/_matrix/client/versions")
self.assertEqual(channel.code, 200, channel.result)
self.assertFalse(channel.json_body["unstable_features"]["org.matrix.msc4430"])
@unittest.override_config({"experimental_features": {"msc4430_enabled": True}})
def test_msc4430_true_if_enabled(self) -> None:
channel = self.make_request("GET", "/_matrix/client/versions")
self.assertEqual(channel.code, 200, channel.result)
self.assertTrue(channel.json_body["unstable_features"]["org.matrix.msc4430"])
# MSC4430 implies MSC4428.
self.assertTrue(channel.json_body["unstable_features"]["org.matrix.msc4428"])
def _sanity_check_versions_response(self, versions_response: JsonDict) -> None:
"""
Make sure this looks like a `/_matrix/client/versions` response
"""
self.assertIsInstance(
versions_response["versions"],
list,
f"Expected `versions` to be a list of strings but saw {versions_response}",
)
self.assertIsInstance(
versions_response["unstable_features"],
dict,
f"Expected `unstable_features` to be a dict mapping feature name to a bool but saw {versions_response}",
)
def _enable_experimental_feature_for_user(
self, *, target_user_id: str, features: dict[str, bool]
) -> None:
"""
Use the admin API to enable an experimental feature for a specific user
"""
channel = self.make_request(
"PUT",
f"/_synapse/admin/v1/experimental_features/{target_user_id}",
content={
"features": features,
},
access_token=self.admin_user_tok,
)
self.assertEqual(channel.code, 200)