Skip to content

Commit de05a64

Browse files
Sliding Sync: Add E2EE extension (MSC3884) (#17454)
Spec: [MSC3884](matrix-org/matrix-spec-proposals#3884) Based on [MSC3575](matrix-org/matrix-spec-proposals#3575): Sliding Sync
1 parent d221512 commit de05a64

9 files changed

Lines changed: 1023 additions & 34 deletions

File tree

changelog.d/17454.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add E2EE extension support to experimental [MSC3575](https://github.com/matrix-org/matrix-spec-proposals/pull/3575) Sliding Sync `/sync` endpoint.

synapse/handlers/device.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
)
4040
from synapse.storage.databases.main.client_ips import DeviceLastConnectionInfo
4141
from synapse.types import (
42+
DeviceListUpdates,
4243
JsonDict,
4344
JsonMapping,
4445
ScheduledTask,
@@ -214,7 +215,7 @@ async def get_device_changes_in_shared_rooms(
214215
@cancellable
215216
async def get_user_ids_changed(
216217
self, user_id: str, from_token: StreamToken
217-
) -> JsonDict:
218+
) -> DeviceListUpdates:
218219
"""Get list of users that have had the devices updated, or have newly
219220
joined a room, that `user_id` may be interested in.
220221
"""
@@ -341,11 +342,19 @@ async def get_user_ids_changed(
341342
possibly_joined = set()
342343
possibly_left = set()
343344

344-
result = {"changed": list(possibly_joined), "left": list(possibly_left)}
345+
device_list_updates = DeviceListUpdates(
346+
changed=possibly_joined,
347+
left=possibly_left,
348+
)
345349

346-
log_kv(result)
350+
log_kv(
351+
{
352+
"changed": device_list_updates.changed,
353+
"left": device_list_updates.left,
354+
}
355+
)
347356

348-
return result
357+
return device_list_updates
349358

350359
async def on_federation_query_user_devices(self, user_id: str) -> JsonDict:
351360
if not self.hs.is_mine(UserID.from_string(user_id)):

synapse/handlers/sliding_sync.py

Lines changed: 92 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,18 @@
1919
#
2020
import logging
2121
from itertools import chain
22-
from typing import TYPE_CHECKING, Any, Dict, Final, List, Mapping, Optional, Set, Tuple
22+
from typing import (
23+
TYPE_CHECKING,
24+
Any,
25+
Dict,
26+
Final,
27+
List,
28+
Mapping,
29+
Optional,
30+
Sequence,
31+
Set,
32+
Tuple,
33+
)
2334

2435
import attr
2536
from immutabledict import immutabledict
@@ -33,6 +44,7 @@
3344
from synapse.storage.databases.main.stream import CurrentStateDeltaMembership
3445
from synapse.storage.roommember import MemberSummary
3546
from synapse.types import (
47+
DeviceListUpdates,
3648
JsonDict,
3749
PersistedEventPosition,
3850
Requester,
@@ -343,6 +355,7 @@ def __init__(self, hs: "HomeServer"):
343355
self.notifier = hs.get_notifier()
344356
self.event_sources = hs.get_event_sources()
345357
self.relations_handler = hs.get_relations_handler()
358+
self.device_handler = hs.get_device_handler()
346359
self.rooms_to_exclude_globally = hs.config.server.rooms_to_exclude_from_sync
347360

348361
async def wait_for_sync_for_user(
@@ -371,10 +384,6 @@ async def wait_for_sync_for_user(
371384
# auth_blocking will occur)
372385
await self.auth_blocking.check_auth_blocking(requester=requester)
373386

374-
# TODO: If the To-Device extension is enabled and we have a `from_token`, delete
375-
# any to-device messages before that token (since we now know that the device
376-
# has received them). (see sync v2 for how to do this)
377-
378387
# If we're working with a user-provided token, we need to make sure to wait for
379388
# this worker to catch up with the token so we don't skip past any incoming
380389
# events or future events if the user is nefariously, manually modifying the
@@ -617,7 +626,9 @@ async def handle_room(room_id: str) -> None:
617626
await concurrently_execute(handle_room, relevant_room_map, 10)
618627

619628
extensions = await self.get_extensions_response(
620-
sync_config=sync_config, to_token=to_token
629+
sync_config=sync_config,
630+
from_token=from_token,
631+
to_token=to_token,
621632
)
622633

623634
return SlidingSyncResult(
@@ -1776,48 +1787,64 @@ async def get_extensions_response(
17761787
self,
17771788
sync_config: SlidingSyncConfig,
17781789
to_token: StreamToken,
1790+
from_token: Optional[StreamToken],
17791791
) -> SlidingSyncResult.Extensions:
17801792
"""Handle extension requests.
17811793
17821794
Args:
17831795
sync_config: Sync configuration
17841796
to_token: The point in the stream to sync up to.
1797+
from_token: The point in the stream to sync from.
17851798
"""
17861799

17871800
if sync_config.extensions is None:
17881801
return SlidingSyncResult.Extensions()
17891802

17901803
to_device_response = None
1791-
if sync_config.extensions.to_device:
1792-
to_device_response = await self.get_to_device_extensions_response(
1804+
if sync_config.extensions.to_device is not None:
1805+
to_device_response = await self.get_to_device_extension_response(
17931806
sync_config=sync_config,
17941807
to_device_request=sync_config.extensions.to_device,
17951808
to_token=to_token,
17961809
)
17971810

1798-
return SlidingSyncResult.Extensions(to_device=to_device_response)
1811+
e2ee_response = None
1812+
if sync_config.extensions.e2ee is not None:
1813+
e2ee_response = await self.get_e2ee_extension_response(
1814+
sync_config=sync_config,
1815+
e2ee_request=sync_config.extensions.e2ee,
1816+
to_token=to_token,
1817+
from_token=from_token,
1818+
)
17991819

1800-
async def get_to_device_extensions_response(
1820+
return SlidingSyncResult.Extensions(
1821+
to_device=to_device_response,
1822+
e2ee=e2ee_response,
1823+
)
1824+
1825+
async def get_to_device_extension_response(
18011826
self,
18021827
sync_config: SlidingSyncConfig,
18031828
to_device_request: SlidingSyncConfig.Extensions.ToDeviceExtension,
18041829
to_token: StreamToken,
1805-
) -> SlidingSyncResult.Extensions.ToDeviceExtension:
1830+
) -> Optional[SlidingSyncResult.Extensions.ToDeviceExtension]:
18061831
"""Handle to-device extension (MSC3885)
18071832
18081833
Args:
18091834
sync_config: Sync configuration
18101835
to_device_request: The to-device extension from the request
18111836
to_token: The point in the stream to sync up to.
18121837
"""
1813-
18141838
user_id = sync_config.user.to_string()
18151839
device_id = sync_config.device_id
18161840

1841+
# Skip if the extension is not enabled
1842+
if not to_device_request.enabled:
1843+
return None
1844+
18171845
# Check that this request has a valid device ID (not all requests have
1818-
# to belong to a device, and so device_id is None), and that the
1819-
# extension is enabled.
1820-
if device_id is None or not to_device_request.enabled:
1846+
# to belong to a device, and so device_id is None)
1847+
if device_id is None:
18211848
return SlidingSyncResult.Extensions.ToDeviceExtension(
18221849
next_batch=f"{to_token.to_device_key}",
18231850
events=[],
@@ -1868,3 +1895,53 @@ async def get_to_device_extensions_response(
18681895
next_batch=f"{stream_id}",
18691896
events=messages,
18701897
)
1898+
1899+
async def get_e2ee_extension_response(
1900+
self,
1901+
sync_config: SlidingSyncConfig,
1902+
e2ee_request: SlidingSyncConfig.Extensions.E2eeExtension,
1903+
to_token: StreamToken,
1904+
from_token: Optional[StreamToken],
1905+
) -> Optional[SlidingSyncResult.Extensions.E2eeExtension]:
1906+
"""Handle E2EE device extension (MSC3884)
1907+
1908+
Args:
1909+
sync_config: Sync configuration
1910+
e2ee_request: The e2ee extension from the request
1911+
to_token: The point in the stream to sync up to.
1912+
from_token: The point in the stream to sync from.
1913+
"""
1914+
user_id = sync_config.user.to_string()
1915+
device_id = sync_config.device_id
1916+
1917+
# Skip if the extension is not enabled
1918+
if not e2ee_request.enabled:
1919+
return None
1920+
1921+
device_list_updates: Optional[DeviceListUpdates] = None
1922+
if from_token is not None:
1923+
# TODO: This should take into account the `from_token` and `to_token`
1924+
device_list_updates = await self.device_handler.get_user_ids_changed(
1925+
user_id=user_id,
1926+
from_token=from_token,
1927+
)
1928+
1929+
device_one_time_keys_count: Mapping[str, int] = {}
1930+
device_unused_fallback_key_types: Sequence[str] = []
1931+
if device_id:
1932+
# TODO: We should have a way to let clients differentiate between the states of:
1933+
# * no change in OTK count since the provided since token
1934+
# * the server has zero OTKs left for this device
1935+
# Spec issue: https://github.com/matrix-org/matrix-doc/issues/3298
1936+
device_one_time_keys_count = await self.store.count_e2e_one_time_keys(
1937+
user_id, device_id
1938+
)
1939+
device_unused_fallback_key_types = (
1940+
await self.store.get_e2e_unused_fallback_key_types(user_id, device_id)
1941+
)
1942+
1943+
return SlidingSyncResult.Extensions.E2eeExtension(
1944+
device_list_updates=device_list_updates,
1945+
device_one_time_keys_count=device_one_time_keys_count,
1946+
device_unused_fallback_key_types=device_unused_fallback_key_types,
1947+
)

synapse/rest/client/keys.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,9 +256,15 @@ async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
256256

257257
user_id = requester.user.to_string()
258258

259-
results = await self.device_handler.get_user_ids_changed(user_id, from_token)
259+
device_list_updates = await self.device_handler.get_user_ids_changed(
260+
user_id, from_token
261+
)
262+
263+
response: JsonDict = {}
264+
response["changed"] = list(device_list_updates.changed)
265+
response["left"] = list(device_list_updates.left)
260266

261-
return 200, results
267+
return 200, response
262268

263269

264270
class OneTimeKeyServlet(RestServlet):

synapse/rest/client/sync.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,15 +1081,41 @@ async def encode_rooms(
10811081
async def encode_extensions(
10821082
self, requester: Requester, extensions: SlidingSyncResult.Extensions
10831083
) -> JsonDict:
1084-
result = {}
1084+
serialized_extensions: JsonDict = {}
10851085

10861086
if extensions.to_device is not None:
1087-
result["to_device"] = {
1087+
serialized_extensions["to_device"] = {
10881088
"next_batch": extensions.to_device.next_batch,
10891089
"events": extensions.to_device.events,
10901090
}
10911091

1092-
return result
1092+
if extensions.e2ee is not None:
1093+
serialized_extensions["e2ee"] = {
1094+
# We always include this because
1095+
# https://github.com/vector-im/element-android/issues/3725. The spec
1096+
# isn't terribly clear on when this can be omitted and how a client
1097+
# would tell the difference between "no keys present" and "nothing
1098+
# changed" in terms of whole field absent / individual key type entry
1099+
# absent Corresponding synapse issue:
1100+
# https://github.com/matrix-org/synapse/issues/10456
1101+
"device_one_time_keys_count": extensions.e2ee.device_one_time_keys_count,
1102+
# https://github.com/matrix-org/matrix-doc/blob/54255851f642f84a4f1aaf7bc063eebe3d76752b/proposals/2732-olm-fallback-keys.md
1103+
# states that this field should always be included, as long as the
1104+
# server supports the feature.
1105+
"device_unused_fallback_key_types": extensions.e2ee.device_unused_fallback_key_types,
1106+
}
1107+
1108+
if extensions.e2ee.device_list_updates is not None:
1109+
serialized_extensions["e2ee"]["device_lists"] = {}
1110+
1111+
serialized_extensions["e2ee"]["device_lists"]["changed"] = list(
1112+
extensions.e2ee.device_list_updates.changed
1113+
)
1114+
serialized_extensions["e2ee"]["device_lists"]["left"] = list(
1115+
extensions.e2ee.device_list_updates.left
1116+
)
1117+
1118+
return serialized_extensions
10931119

10941120

10951121
def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:

synapse/types/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,11 +1219,12 @@ class ReadReceipt:
12191219
@attr.s(slots=True, frozen=True, auto_attribs=True)
12201220
class DeviceListUpdates:
12211221
"""
1222-
An object containing a diff of information regarding other users' device lists, intended for
1223-
a recipient to carry out device list tracking.
1222+
An object containing a diff of information regarding other users' device lists,
1223+
intended for a recipient to carry out device list tracking.
12241224
12251225
Attributes:
1226-
changed: A set of users whose device lists have changed recently.
1226+
changed: A set of users who have updated their device identity or
1227+
cross-signing keys, or who now share an encrypted room with.
12271228
left: A set of users who the recipient no longer needs to track the device lists of.
12281229
Typically when those users no longer share any end-to-end encryption enabled rooms.
12291230
"""

synapse/types/handlers/__init__.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
#
1919
#
2020
from enum import Enum
21-
from typing import TYPE_CHECKING, Dict, Final, List, Optional, Sequence, Tuple
21+
from typing import TYPE_CHECKING, Dict, Final, List, Mapping, Optional, Sequence, Tuple
2222

2323
import attr
2424
from typing_extensions import TypedDict
@@ -31,7 +31,7 @@
3131
from pydantic import Extra
3232

3333
from synapse.events import EventBase
34-
from synapse.types import JsonDict, JsonMapping, StreamToken, UserID
34+
from synapse.types import DeviceListUpdates, JsonDict, JsonMapping, StreamToken, UserID
3535
from synapse.types.rest.client import SlidingSyncBody
3636

3737
if TYPE_CHECKING:
@@ -264,6 +264,7 @@ class Extensions:
264264
265265
Attributes:
266266
to_device: The to-device extension (MSC3885)
267+
e2ee: The E2EE device extension (MSC3884)
267268
"""
268269

269270
@attr.s(slots=True, frozen=True, auto_attribs=True)
@@ -282,10 +283,51 @@ class ToDeviceExtension:
282283
def __bool__(self) -> bool:
283284
return bool(self.events)
284285

286+
@attr.s(slots=True, frozen=True, auto_attribs=True)
287+
class E2eeExtension:
288+
"""The E2EE device extension (MSC3884)
289+
290+
Attributes:
291+
device_list_updates: List of user_ids whose devices have changed or left (only
292+
present on incremental syncs).
293+
device_one_time_keys_count: Map from key algorithm to the number of
294+
unclaimed one-time keys currently held on the server for this device. If
295+
an algorithm is unlisted, the count for that algorithm is assumed to be
296+
zero. If this entire parameter is missing, the count for all algorithms
297+
is assumed to be zero.
298+
device_unused_fallback_key_types: List of unused fallback key algorithms
299+
for this device.
300+
"""
301+
302+
# Only present on incremental syncs
303+
device_list_updates: Optional[DeviceListUpdates]
304+
device_one_time_keys_count: Mapping[str, int]
305+
device_unused_fallback_key_types: Sequence[str]
306+
307+
def __bool__(self) -> bool:
308+
# Note that "signed_curve25519" is always returned in key count responses
309+
# regardless of whether we uploaded any keys for it. This is necessary until
310+
# https://github.com/matrix-org/matrix-doc/issues/3298 is fixed.
311+
#
312+
# Also related:
313+
# https://github.com/element-hq/element-android/issues/3725 and
314+
# https://github.com/matrix-org/synapse/issues/10456
315+
default_otk = self.device_one_time_keys_count.get("signed_curve25519")
316+
more_than_default_otk = len(self.device_one_time_keys_count) > 1 or (
317+
default_otk is not None and default_otk > 0
318+
)
319+
320+
return bool(
321+
more_than_default_otk
322+
or self.device_list_updates
323+
or self.device_unused_fallback_key_types
324+
)
325+
285326
to_device: Optional[ToDeviceExtension] = None
327+
e2ee: Optional[E2eeExtension] = None
286328

287329
def __bool__(self) -> bool:
288-
return bool(self.to_device)
330+
return bool(self.to_device or self.e2ee)
289331

290332
next_pos: StreamToken
291333
lists: Dict[str, SlidingWindowList]

0 commit comments

Comments
 (0)