From bb890ed723a8ccc47857227e37c3024875f189db Mon Sep 17 00:00:00 2001 From: Matthew Date: Thu, 25 Jun 2026 20:00:19 +0100 Subject: [PATCH] Don't delete the dehydrated device when MAS syncs the device list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modern MAS device-sync endpoint (_synapse/mas/sync_devices) reconciled the user's device list against the set of devices MAS knows about, but the dehydrated device (MSC3814) has no MAS session and so was never in that set. It was therefore treated as an extra device and deleted on every sync — in particular when a user logged out their last device — which broke offline key delivery via the dehydrated device. Exclude the dehydrated device from the reconciliation, mirroring the admin devices API (which flags it) and MAS's own legacy sync path (which filters it out). --- changelog.d/19999.bugfix | 1 + synapse/rest/synapse/mas/devices.py | 10 ++++++ tests/rest/synapse/mas/test_devices.py | 42 ++++++++++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 changelog.d/19999.bugfix diff --git a/changelog.d/19999.bugfix b/changelog.d/19999.bugfix new file mode 100644 index 0000000000..9439433274 --- /dev/null +++ b/changelog.d/19999.bugfix @@ -0,0 +1 @@ +Fix a bug where a user's dehydrated device (MSC3814) was deleted when their device list was synced from MAS (e.g. on logging out their last device), breaking offline key delivery. diff --git a/synapse/rest/synapse/mas/devices.py b/synapse/rest/synapse/mas/devices.py index 9d94a67675..b5d19d3cec 100644 --- a/synapse/rest/synapse/mas/devices.py +++ b/synapse/rest/synapse/mas/devices.py @@ -196,6 +196,16 @@ class MasSyncDevicesResource(MasBaseResource): current_devices_list = set(current_devices.keys()) target_device_list = set(body.devices) + # Exclude the dehydrated device (MSC3814): it has no MAS session, so MAS + # never lists it in the target set and the reconciliation below would + # otherwise treat it as extra and delete it. This mirrors the admin + # devices API and MAS's own legacy device-sync path, which both skip it. + dehydrated_device = await self.device_handler.get_dehydrated_device( + user_id=str(user_id) + ) + if dehydrated_device is not None: + current_devices_list.discard(dehydrated_device[0]) + to_add = target_device_list - current_devices_list to_delete = current_devices_list - target_device_list diff --git a/tests/rest/synapse/mas/test_devices.py b/tests/rest/synapse/mas/test_devices.py index 6b7596f1c6..e9035f4c0b 100644 --- a/tests/rest/synapse/mas/test_devices.py +++ b/tests/rest/synapse/mas/test_devices.py @@ -546,6 +546,48 @@ class MasSyncDevicesResource(BaseTestCase): devices = self.get_success(store.get_devices_by_user(str(self.alice_user_id))) self.assertEqual(set(devices.keys()), {"DEVICE1"}) + def test_sync_devices_preserves_dehydrated_device(self) -> None: + # Store a dehydrated device (MSC3814). It has no MAS session, so it is + # never part of the target device list, but it must survive a sync. + device_handler = self.hs.get_device_handler() + dehydrated_device_id = self.get_success( + device_handler.store_dehydrated_device( + user_id=str(self.alice_user_id), + device_id=None, + device_data={"device_data": {"foo": "bar"}}, + initial_device_display_name="dehydrated device", + keys_for_device={}, + ) + ) + + # Sync down to a single real device. The dehydrated device is not in the + # target list, but must not be deleted. + channel = self.make_request( + "POST", + "/_synapse/mas/sync_devices", + shorthand=False, + access_token=self.SHARED_SECRET, + content={ + "localpart": "alice", + "devices": ["DEVICE1"], + }, + ) + + self.assertEqual(channel.code, 200, channel.json_body) + self.assertEqual(channel.json_body, {}) + + # The real devices are reconciled, but the dehydrated device survives. + store = self.hs.get_datastores().main + devices = self.get_success(store.get_devices_by_user(str(self.alice_user_id))) + self.assertEqual(set(devices.keys()), {"DEVICE1", dehydrated_device_id}) + + # And it is still registered as the dehydrated device. + dehydrated = self.get_success( + device_handler.get_dehydrated_device(str(self.alice_user_id)) + ) + assert dehydrated is not None + self.assertEqual(dehydrated[0], dehydrated_device_id) + def test_sync_devices_add_and_delete(self) -> None: # Sync with a mix of additions and deletions channel = self.make_request(