diff --git a/changelog.d/19346.removal b/changelog.d/19346.removal new file mode 100644 index 0000000000..f11c5c48d4 --- /dev/null +++ b/changelog.d/19346.removal @@ -0,0 +1 @@ +MSC2697 (Dehydrated devices) has been removed, as the MSC is closed. Developers should migrate to MSC3814. \ No newline at end of file diff --git a/docs/upgrade.md b/docs/upgrade.md index 7eb50d1cf1..1630c6ab40 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -125,6 +125,14 @@ Ubuntu 25.04 Plucky Puffin [is end-of-life as of 17 Jan 2026](https://endoflife.date/ubuntu). This release drops support for Ubuntu 25.04, and in its place adds support for Ubuntu 25.10 Questing Quokka. +## Removal of MSC2697 (Legacy) Dehydrated devices + +The endpoints for +[MSC2697](https://github.com/matrix-org/matrix-spec-proposals/pull/2697) have now +been removed, since the MSC is closed. Developers who rely on this feature should +migrate to [MSC3814](https://github.com/matrix-org/matrix-spec-proposals/pull/3814) +which introduces support for a newer version of dehydrated devices. + # Upgrading to v1.144.0 ## Worker support for unstable MSC4140 `/restart` endpoint diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py index dc5e096791..0150b71621 100644 --- a/synapse/config/experimental.py +++ b/synapse/config/experimental.py @@ -378,27 +378,9 @@ class ExperimentalConfig(Config): # MSC3026 (busy presence state) self.msc3026_enabled: bool = experimental.get("msc3026_enabled", False) - # MSC2697 (device dehydration) - # Enabled by default since this option was added after adding the feature. - # It is not recommended that both MSC2697 and MSC3814 both be enabled at - # once. - self.msc2697_enabled: bool = experimental.get("msc2697_enabled", True) - # MSC3814 (dehydrated devices with SSSS) - # This is an alternative method to achieve the same goals as MSC2697. - # It is not recommended that both MSC2697 and MSC3814 both be enabled at - # once. self.msc3814_enabled: bool = experimental.get("msc3814_enabled", False) - if self.msc2697_enabled and self.msc3814_enabled: - raise ConfigError( - "MSC2697 and MSC3814 should not both be enabled.", - ( - "experimental_features", - "msc3814_enabled", - ), - ) - # MSC3244 (room version capabilities) self.msc3244_enabled: bool = experimental.get("msc3244_enabled", True) diff --git a/synapse/handlers/device.py b/synapse/handlers/device.py index 1b7de57ab9..4552667176 100644 --- a/synapse/handlers/device.py +++ b/synapse/handlers/device.py @@ -441,8 +441,8 @@ class DeviceHandler: user_id: str, device_id: str | None, device_data: JsonDict, - initial_device_display_name: str | None = None, - keys_for_device: JsonDict | None = None, + initial_device_display_name: str | None, + keys_for_device: JsonDict, ) -> str: """Store a dehydrated device for a user, optionally storing the keys associated with it as well. If the user had a previous dehydrated device, it is removed. @@ -473,46 +473,6 @@ class DeviceHandler: return device_id - async def rehydrate_device( - self, user_id: str, access_token: str, device_id: str - ) -> dict: - """Process a rehydration request from the user. - - Args: - user_id: the user who is rehydrating the device - access_token: the access token used for the request - device_id: the ID of the device that will be rehydrated - Returns: - a dict containing {"success": True} - """ - success = await self.store.remove_dehydrated_device(user_id, device_id) - - if not success: - raise errors.NotFoundError() - - # If the dehydrated device was successfully deleted (the device ID - # matched the stored dehydrated device), then modify the access - # token and refresh token to use the dehydrated device's ID and - # copy the old device display name to the dehydrated device, - # and destroy the old device ID - old_device_id = await self.store.set_device_for_access_token( - access_token, device_id - ) - await self.store.set_device_for_refresh_token(user_id, old_device_id, device_id) - old_device = await self.store.get_device(user_id, old_device_id) - if old_device is None: - raise errors.NotFoundError() - await self.store.update_device(user_id, device_id, old_device["display_name"]) - # can't call self.delete_device because that will clobber the - # access token so call the storage layer directly - await self.store.delete_devices(user_id, [old_device_id]) - - # tell everyone that the old device is gone and that the dehydrated - # device has a new display name - await self.notify_device_update(user_id, [old_device_id, device_id]) - - return {"success": True} - async def delete_dehydrated_device(self, user_id: str, device_id: str) -> None: """ Delete a stored dehydrated device. diff --git a/synapse/rest/client/devices.py b/synapse/rest/client/devices.py index 636e4b6031..b39ca6a483 100644 --- a/synapse/rest/client/devices.py +++ b/synapse/rest/client/devices.py @@ -253,131 +253,6 @@ class DehydratedDeviceDataModel(RequestBodyModel): algorithm: StrictStr -class DehydratedDeviceServlet(RestServlet): - """Retrieve or store a dehydrated device. - - Implements MSC2697. - - GET /org.matrix.msc2697.v2/dehydrated_device - - HTTP/1.1 200 OK - Content-Type: application/json - - { - "device_id": "dehydrated_device_id", - "device_data": { - "algorithm": "org.matrix.msc2697.v1.dehydration.v1.olm", - "account": "dehydrated_device" - } - } - - PUT /org.matrix.msc2697.v2/dehydrated_device - Content-Type: application/json - - { - "device_data": { - "algorithm": "org.matrix.msc2697.v1.dehydration.v1.olm", - "account": "dehydrated_device" - } - } - - HTTP/1.1 200 OK - Content-Type: application/json - - { - "device_id": "dehydrated_device_id" - } - - """ - - PATTERNS = client_patterns( - "/org.matrix.msc2697.v2/dehydrated_device$", - releases=(), - ) - - def __init__(self, hs: "HomeServer"): - super().__init__() - self.hs = hs - self.auth = hs.get_auth() - handler = hs.get_device_handler() - self.device_handler = handler - - async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]: - requester = await self.auth.get_user_by_req(request) - dehydrated_device = await self.device_handler.get_dehydrated_device( - requester.user.to_string() - ) - if dehydrated_device is not None: - (device_id, device_data) = dehydrated_device - result = {"device_id": device_id, "device_data": device_data} - return 200, result - else: - raise errors.NotFoundError("No dehydrated device available") - - class PutBody(RequestBodyModel): - device_data: DehydratedDeviceDataModel - initial_device_display_name: StrictStr | None = None - - async def on_PUT(self, request: SynapseRequest) -> tuple[int, JsonDict]: - submission = parse_and_validate_json_object_from_request(request, self.PutBody) - requester = await self.auth.get_user_by_req(request) - - device_id = await self.device_handler.store_dehydrated_device( - requester.user.to_string(), - None, - submission.device_data.dict(), - submission.initial_device_display_name, - ) - return 200, {"device_id": device_id} - - -class ClaimDehydratedDeviceServlet(RestServlet): - """Claim a dehydrated device. - - POST /org.matrix.msc2697.v2/dehydrated_device/claim - Content-Type: application/json - - { - "device_id": "dehydrated_device_id" - } - - HTTP/1.1 200 OK - Content-Type: application/json - - { - "success": true, - } - - """ - - PATTERNS = client_patterns( - "/org.matrix.msc2697.v2/dehydrated_device/claim", releases=() - ) - - def __init__(self, hs: "HomeServer"): - super().__init__() - self.hs = hs - self.auth = hs.get_auth() - handler = hs.get_device_handler() - self.device_handler = handler - - class PostBody(RequestBodyModel): - device_id: StrictStr - - async def on_POST(self, request: SynapseRequest) -> tuple[int, JsonDict]: - requester = await self.auth.get_user_by_req(request) - - submission = parse_and_validate_json_object_from_request(request, self.PostBody) - - result = await self.device_handler.rehydrate_device( - requester.user.to_string(), - self.auth.get_access_token_from_request(request), - submission.device_id, - ) - - return 200, result - - class DehydratedDeviceEventsServlet(RestServlet): PATTERNS = client_patterns( "/org.matrix.msc3814.v1/dehydrated_device/(?P[^/]*)/events$", @@ -579,9 +454,6 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: DevicesRestServlet(hs).register(http_server) DeviceRestServlet(hs).register(http_server) - if hs.config.experimental.msc2697_enabled: - DehydratedDeviceServlet(hs).register(http_server) - ClaimDehydratedDeviceServlet(hs).register(http_server) if hs.config.experimental.msc3814_enabled: DehydratedDeviceV2Servlet(hs).register(http_server) DehydratedDeviceEventsServlet(hs).register(http_server) diff --git a/synapse/rest/client/keys.py b/synapse/rest/client/keys.py index 502c5d495a..89b68331f2 100644 --- a/synapse/rest/client/keys.py +++ b/synapse/rest/client/keys.py @@ -44,7 +44,7 @@ from synapse.http.servlet import ( validate_json_object, ) from synapse.http.site import SynapseRequest -from synapse.logging.opentracing import log_kv, set_tag +from synapse.logging.opentracing import set_tag from synapse.rest.client._base import client_patterns, interactive_auth_handler from synapse.types import JsonDict, StreamToken from synapse.types.rest import RequestBodyModel @@ -105,7 +105,7 @@ class KeyUploadServlet(RestServlet): """ - PATTERNS = client_patterns("/keys/upload(/(?P[^/]+))?$") + PATTERNS = client_patterns("/keys/upload$") CATEGORY = "Encryption requests" def __init__(self, hs: "HomeServer"): @@ -220,9 +220,7 @@ class KeyUploadServlet(RestServlet): ) return v - async def on_POST( - self, request: SynapseRequest, device_id: str | None - ) -> tuple[int, JsonDict]: + async def on_POST(self, request: SynapseRequest) -> tuple[int, JsonDict]: requester = await self.auth.get_user_by_req(request, allow_guest=True) user_id = requester.user.to_string() @@ -236,31 +234,7 @@ class KeyUploadServlet(RestServlet): body = parse_json_object_from_request(request) validate_json_object(body, self.KeyUploadRequestBody) - if device_id is not None: - # Providing the device_id should only be done for setting keys - # for dehydrated devices; however, we allow it for any device for - # compatibility with older clients. - if requester.device_id is not None and device_id != requester.device_id: - dehydrated_device = await self.device_handler.get_dehydrated_device( - user_id - ) - if dehydrated_device is not None and device_id != dehydrated_device[0]: - set_tag("error", True) - log_kv( - { - "message": "Client uploading keys for a different device", - "logged_in_id": requester.device_id, - "key_being_uploaded": device_id, - } - ) - logger.warning( - "Client uploading keys for a different device " - "(logged in as %s, uploading for %s)", - requester.device_id, - device_id, - ) - else: - device_id = requester.device_id + device_id = requester.device_id if device_id is None: raise SynapseError( diff --git a/synapse/storage/databases/main/devices.py b/synapse/storage/databases/main/devices.py index cbad40faf7..e9ecf46411 100644 --- a/synapse/storage/databases/main/devices.py +++ b/synapse/storage/databases/main/devices.py @@ -1482,33 +1482,29 @@ class DeviceWorkerStore(RoomMemberWorkerStore, EndToEndKeyWorkerStore): device_id: str, device_data: str, time: int, - keys: JsonDict | None = None, + keys: JsonDict, ) -> str | None: - # TODO: make keys non-optional once support for msc2697 is dropped - if keys: - device_keys = keys.get("device_keys", None) - if device_keys: - self._set_e2e_device_keys_txn( - txn, user_id, device_id, time, device_keys - ) + device_keys = keys.get("device_keys", None) + if device_keys: + self._set_e2e_device_keys_txn(txn, user_id, device_id, time, device_keys) - one_time_keys = keys.get("one_time_keys", None) - if one_time_keys: - key_list = [] - for key_id, key_obj in one_time_keys.items(): - algorithm, key_id = key_id.split(":") - key_list.append( - ( - algorithm, - key_id, - encode_canonical_json(key_obj).decode("ascii"), - ) + one_time_keys = keys.get("one_time_keys", None) + if one_time_keys: + key_list = [] + for key_id, key_obj in one_time_keys.items(): + algorithm, key_id = key_id.split(":") + key_list.append( + ( + algorithm, + key_id, + encode_canonical_json(key_obj).decode("ascii"), ) - self._add_e2e_one_time_keys_txn(txn, user_id, device_id, time, key_list) + ) + self._add_e2e_one_time_keys_txn(txn, user_id, device_id, time, key_list) - fallback_keys = keys.get("fallback_keys", None) - if fallback_keys: - self._set_e2e_fallback_keys_txn(txn, user_id, device_id, fallback_keys) + fallback_keys = keys.get("fallback_keys", None) + if fallback_keys: + self._set_e2e_fallback_keys_txn(txn, user_id, device_id, fallback_keys) old_device_id = self.db_pool.simple_select_one_onecol_txn( txn, @@ -1532,7 +1528,7 @@ class DeviceWorkerStore(RoomMemberWorkerStore, EndToEndKeyWorkerStore): device_id: str, device_data: JsonDict, time_now: int, - keys: dict | None = None, + keys: dict, ) -> str | None: """Store a dehydrated device for a user. diff --git a/synapse/storage/databases/main/registration.py b/synapse/storage/databases/main/registration.py index 9a9c0fffc7..7b0de92435 100644 --- a/synapse/storage/databases/main/registration.py +++ b/synapse/storage/databases/main/registration.py @@ -2092,58 +2092,6 @@ class RegistrationWorkerStore(StatsStore, CacheInvalidationWorkerStore): "replace_refresh_token", _replace_refresh_token_txn ) - async def set_device_for_refresh_token( - self, user_id: str, old_device_id: str, device_id: str - ) -> None: - """Moves refresh tokens from old device to current device - - Args: - user_id: The user of the devices. - old_device_id: The old device. - device_id: The new device ID. - Returns: - None - """ - - await self.db_pool.simple_update( - "refresh_tokens", - keyvalues={"user_id": user_id, "device_id": old_device_id}, - updatevalues={"device_id": device_id}, - desc="set_device_for_refresh_token", - ) - - def _set_device_for_access_token_txn( - self, txn: LoggingTransaction, token: str, device_id: str - ) -> str: - old_device_id = self.db_pool.simple_select_one_onecol_txn( - txn, "access_tokens", {"token": token}, "device_id" - ) - - self.db_pool.simple_update_txn( - txn, "access_tokens", {"token": token}, {"device_id": device_id} - ) - - self._invalidate_cache_and_stream(txn, self.get_user_by_access_token, (token,)) - - return old_device_id - - async def set_device_for_access_token(self, token: str, device_id: str) -> str: - """Sets the device ID associated with an access token. - - Args: - token: The access token to modify. - device_id: The new device ID. - Returns: - The old device ID associated with the access token. - """ - - return await self.db_pool.runInteraction( - "set_device_for_access_token", - self._set_device_for_access_token_txn, - token, - device_id, - ) - async def add_login_token_to_user( self, user_id: str, diff --git a/tests/handlers/test_device.py b/tests/handlers/test_device.py index 183234b8a0..f99e3cd4a2 100644 --- a/tests/handlers/test_device.py +++ b/tests/handlers/test_device.py @@ -497,90 +497,7 @@ class DehydrationTestCase(unittest.HomeserverTestCase): self.store = hs.get_datastores().main return hs - def test_dehydrate_and_rehydrate_device(self) -> None: - user_id = "@boris:dehydration" - - self.get_success(self.store.register_user(user_id, "foobar")) - - # First check if we can store and fetch a dehydrated device - stored_dehydrated_device_id = self.get_success( - self.handler.store_dehydrated_device( - user_id=user_id, - device_id=None, - device_data={"device_data": {"foo": "bar"}}, - initial_device_display_name="dehydrated device", - ) - ) - - result = self.get_success(self.handler.get_dehydrated_device(user_id=user_id)) - assert result is not None - retrieved_device_id, device_data = result - - self.assertEqual(retrieved_device_id, stored_dehydrated_device_id) - self.assertEqual(device_data, {"device_data": {"foo": "bar"}}) - - # Create a new login for the user and dehydrated the device - device_id, access_token, _expiration_time, refresh_token = self.get_success( - self.registration.register_device( - user_id=user_id, - device_id=None, - initial_display_name="new device", - should_issue_refresh_token=True, - ) - ) - - # Trying to claim a nonexistent device should throw an error - self.get_failure( - self.handler.rehydrate_device( - user_id=user_id, - access_token=access_token, - device_id="not the right device ID", - ), - NotFoundError, - ) - - # dehydrating the right devices should succeed and change our device ID - # to the dehydrated device's ID - res = self.get_success( - self.handler.rehydrate_device( - user_id=user_id, - access_token=access_token, - device_id=retrieved_device_id, - ) - ) - - self.assertEqual(res, {"success": True}) - - # make sure that our device ID has changed - user_info = self.get_success(self.auth.get_user_by_access_token(access_token)) - - self.assertEqual(user_info.device_id, retrieved_device_id) - - # make sure the user device has the refresh token - assert refresh_token is not None - self.get_success( - self.auth_handler.refresh_token(refresh_token, 5 * 60 * 1000, 5 * 60 * 1000) - ) - - # make sure the device has the display name that was set from the login - res = self.get_success(self.handler.get_device(user_id, retrieved_device_id)) - - self.assertEqual(res["display_name"], "new device") - - # make sure that the device ID that we were initially assigned no longer exists - self.get_failure( - self.handler.get_device(user_id, device_id), - NotFoundError, - ) - - # make sure that there's no device available for dehydrating now - ret = self.get_success(self.handler.get_dehydrated_device(user_id=user_id)) - - self.assertIsNone(ret) - - @unittest.override_config( - {"experimental_features": {"msc2697_enabled": False, "msc3814_enabled": True}} - ) + @unittest.override_config({"experimental_features": {"msc3814_enabled": True}}) def test_dehydrate_v2_and_fetch_events(self) -> None: user_id = "@boris:server" @@ -593,6 +510,7 @@ class DehydrationTestCase(unittest.HomeserverTestCase): device_id=None, device_data={"device_data": {"foo": "bar"}}, initial_device_display_name="dehydrated device", + keys_for_device={}, ) ) diff --git a/tests/rest/admin/test_device.py b/tests/rest/admin/test_device.py index d85d169476..54918de3c8 100644 --- a/tests/rest/admin/test_device.py +++ b/tests/rest/admin/test_device.py @@ -388,9 +388,7 @@ class DevicesRestTestCase(unittest.HomeserverTestCase): self.assertEqual(0, channel.json_body["total"]) self.assertEqual(0, len(channel.json_body["devices"])) - @unittest.override_config( - {"experimental_features": {"msc2697_enabled": False, "msc3814_enabled": True}} - ) + @unittest.override_config({"experimental_features": {"msc3814_enabled": True}}) def test_get_devices(self) -> None: """ Tests that a normal lookup for devices is successfully diff --git a/tests/rest/client/test_devices.py b/tests/rest/client/test_devices.py index 93dff77d80..2cf293a962 100644 --- a/tests/rest/client/test_devices.py +++ b/tests/rest/client/test_devices.py @@ -18,8 +18,6 @@ # [This file includes modifications made by New Vector Limited] # # -from http import HTTPStatus - from twisted.internet.defer import ensureDeferred from twisted.internet.testing import MemoryReactor @@ -85,48 +83,7 @@ class DehydratedDeviceTestCase(unittest.HomeserverTestCase): self.registration = hs.get_registration_handler() self.message_handler = hs.get_device_message_handler() - def test_PUT(self) -> None: - """Sanity-check that we can PUT a dehydrated device. - - Detects https://github.com/matrix-org/synapse/issues/14334. - """ - alice = self.register_user("alice", "correcthorse") - token = self.login(alice, "correcthorse") - - # Have alice update their device list - channel = self.make_request( - "PUT", - "_matrix/client/unstable/org.matrix.msc2697.v2/dehydrated_device", - { - "device_data": { - "algorithm": "org.matrix.msc2697.v1.dehydration.v1.olm", - "account": "dehydrated_device", - }, - "device_keys": { - "user_id": "@alice:test", - "device_id": "device1", - "valid_until_ts": "80", - "algorithms": [ - "m.olm.curve25519-aes-sha2", - ], - "keys": { - ":": "", - }, - "signatures": { - "": {":": ""} - }, - }, - }, - access_token=token, - shorthand=False, - ) - self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body) - device_id = channel.json_body.get("device_id") - self.assertIsInstance(device_id, str) - - @unittest.override_config( - {"experimental_features": {"msc2697_enabled": False, "msc3814_enabled": True}} - ) + @unittest.override_config({"experimental_features": {"msc3814_enabled": True}}) def test_dehydrate_msc3814(self) -> None: user = self.register_user("mikey", "pass") token = self.login(user, "pass", device_id="device1") @@ -320,9 +277,7 @@ class DehydratedDeviceTestCase(unittest.HomeserverTestCase): ) self.assertEqual(channel.code, 401) - @unittest.override_config( - {"experimental_features": {"msc2697_enabled": False, "msc3814_enabled": True}} - ) + @unittest.override_config({"experimental_features": {"msc3814_enabled": True}}) def test_msc3814_dehydrated_device_delete_works(self) -> None: user = self.register_user("mikey", "pass") token = self.login(user, "pass", device_id="device1")