From 533d5e0a7abe3d13ed2490194b0b0d52ec8aedaf Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Tue, 30 Sep 2025 17:10:32 +0100 Subject: [PATCH 1/4] Remove unstable prefixes for MSC2732 This MSC was accepted in 2022. We shouldn't need to continue supporting the unstable field names. --- synapse/handlers/e2e_keys.py | 4 +--- synapse/rest/client/sync.py | 3 --- tests/handlers/test_e2e_keys.py | 23 ----------------------- 3 files changed, 1 insertion(+), 29 deletions(-) diff --git a/synapse/handlers/e2e_keys.py b/synapse/handlers/e2e_keys.py index b9abad2188..a257f72a54 100644 --- a/synapse/handlers/e2e_keys.py +++ b/synapse/handlers/e2e_keys.py @@ -873,9 +873,7 @@ class E2eKeysHandler: log_kv( {"message": "Did not update one_time_keys", "reason": "no keys given"} ) - fallback_keys = keys.get("fallback_keys") or keys.get( - "org.matrix.msc2732.fallback_keys" - ) + fallback_keys = keys.get("fallback_keys") if fallback_keys and isinstance(fallback_keys, dict): log_kv( { diff --git a/synapse/rest/client/sync.py b/synapse/rest/client/sync.py index 5e0596d1bc..c7117e6608 100644 --- a/synapse/rest/client/sync.py +++ b/synapse/rest/client/sync.py @@ -363,9 +363,6 @@ class SyncRestServlet(RestServlet): # https://github.com/matrix-org/matrix-doc/blob/54255851f642f84a4f1aaf7bc063eebe3d76752b/proposals/2732-olm-fallback-keys.md # states that this field should always be included, as long as the server supports the feature. - response["org.matrix.msc2732.device_unused_fallback_key_types"] = ( - sync_result.device_unused_fallback_key_types - ) response["device_unused_fallback_key_types"] = ( sync_result.device_unused_fallback_key_types ) diff --git a/tests/handlers/test_e2e_keys.py b/tests/handlers/test_e2e_keys.py index fda485d413..5fcbcc41e5 100644 --- a/tests/handlers/test_e2e_keys.py +++ b/tests/handlers/test_e2e_keys.py @@ -410,7 +410,6 @@ class E2eKeysHandlerTestCase(unittest.HomeserverTestCase): device_id = "xyz" fallback_key = {"alg1:k1": "fallback_key1"} fallback_key2 = {"alg1:k2": "fallback_key2"} - fallback_key3 = {"alg1:k2": "fallback_key3"} otk = {"alg1:k2": "key2"} # we shouldn't have any unused fallback keys yet @@ -531,28 +530,6 @@ class E2eKeysHandlerTestCase(unittest.HomeserverTestCase): {"failures": {}, "one_time_keys": {local_user: {device_id: fallback_key2}}}, ) - # using the unstable prefix should also set the fallback key - self.get_success( - self.handler.upload_keys_for_user( - local_user, - device_id, - {"org.matrix.msc2732.fallback_keys": fallback_key3}, - ) - ) - - claim_res = self.get_success( - self.handler.claim_one_time_keys( - {local_user: {device_id: {"alg1": 1}}}, - self.requester, - timeout=None, - always_include_fallback_keys=False, - ) - ) - self.assertEqual( - claim_res, - {"failures": {}, "one_time_keys": {local_user: {device_id: fallback_key3}}}, - ) - def test_fallback_key_bulk(self) -> None: """Like test_fallback_key, but claims multiple keys in one handler call.""" alice = f"@alice:{self.hs.hostname}" From dde1e012a43d83ff9806ae517d89f3dac2815cbb Mon Sep 17 00:00:00 2001 From: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com> Date: Tue, 7 Oct 2025 11:15:35 +0100 Subject: [PATCH 2/4] Remove unstable prefixes for MSC2732: Olm fallback keys (#18996) Co-authored-by: Eric Eastwood --- changelog.d/18996.removal | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/18996.removal diff --git a/changelog.d/18996.removal b/changelog.d/18996.removal new file mode 100644 index 0000000000..fa06fcc929 --- /dev/null +++ b/changelog.d/18996.removal @@ -0,0 +1 @@ +Drop support for unstable field names from the long-accepted [MSC2732](https://github.com/matrix-org/matrix-spec-proposals/pull/2732) (Olm fallback keys) proposal. \ No newline at end of file From 7069636c2d6d1ef2022287addf3ed8b919ef2740 Mon Sep 17 00:00:00 2001 From: Till <2353100+S7evinK@users.noreply.github.com> Date: Tue, 7 Oct 2025 12:27:53 +0200 Subject: [PATCH 3/4] Validate the body of requests to `/keys/upload` (#17097) Co-authored-by: Andrew Morgan Co-authored-by: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com> Co-authored-by: Eric Eastwood --- changelog.d/17097.misc | 1 + synapse/handlers/e2e_keys.py | 16 ++-- synapse/rest/client/keys.py | 150 ++++++++++++++++++++++++++++++++- tests/rest/client/test_keys.py | 121 ++++++++++++++++++++++++++ 4 files changed, 280 insertions(+), 8 deletions(-) create mode 100644 changelog.d/17097.misc diff --git a/changelog.d/17097.misc b/changelog.d/17097.misc new file mode 100644 index 0000000000..42792e5f38 --- /dev/null +++ b/changelog.d/17097.misc @@ -0,0 +1 @@ +Extend validation of uploaded device keys. \ No newline at end of file diff --git a/synapse/handlers/e2e_keys.py b/synapse/handlers/e2e_keys.py index a257f72a54..48a60dbd7b 100644 --- a/synapse/handlers/e2e_keys.py +++ b/synapse/handlers/e2e_keys.py @@ -57,7 +57,6 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) - ONE_TIME_KEY_UPLOAD = "one_time_key_upload_lock" @@ -848,14 +847,22 @@ class E2eKeysHandler: """ time_now = self.clock.time_msec() - # TODO: Validate the JSON to make sure it has the right keys. device_keys = keys.get("device_keys", None) if device_keys: + log_kv( + { + "message": "Updating device_keys for user.", + "user_id": user_id, + "device_id": device_id, + } + ) await self.upload_device_keys_for_user( user_id=user_id, device_id=device_id, keys={"device_keys": device_keys}, ) + else: + log_kv({"message": "Did not update device_keys", "reason": "not a dict"}) one_time_keys = keys.get("one_time_keys", None) if one_time_keys: @@ -873,8 +880,9 @@ class E2eKeysHandler: log_kv( {"message": "Did not update one_time_keys", "reason": "no keys given"} ) + fallback_keys = keys.get("fallback_keys") - if fallback_keys and isinstance(fallback_keys, dict): + if fallback_keys: log_kv( { "message": "Updating fallback_keys for device.", @@ -883,8 +891,6 @@ class E2eKeysHandler: } ) await self.store.set_e2e_fallback_keys(user_id, device_id, fallback_keys) - elif fallback_keys: - log_kv({"message": "Did not update fallback_keys", "reason": "not a dict"}) else: log_kv( {"message": "Did not update fallback_keys", "reason": "no keys given"} diff --git a/synapse/rest/client/keys.py b/synapse/rest/client/keys.py index 9f39889c75..017941bfc4 100644 --- a/synapse/rest/client/keys.py +++ b/synapse/rest/client/keys.py @@ -23,10 +23,19 @@ import logging import re from collections import Counter -from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple +from http import HTTPStatus +from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Tuple, Union +from typing_extensions import Self + +from synapse._pydantic_compat import ( + StrictBool, + StrictStr, + validator, +) from synapse.api.auth.mas import MasDelegatedAuth from synapse.api.errors import ( + Codes, InteractiveAuthIncompleteError, InvalidAPICallError, SynapseError, @@ -37,11 +46,13 @@ from synapse.http.servlet import ( parse_integer, parse_json_object_from_request, parse_string, + validate_json_object, ) from synapse.http.site import SynapseRequest from synapse.logging.opentracing import log_kv, 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 from synapse.util.cancellation import cancellable if TYPE_CHECKING: @@ -59,7 +70,6 @@ class KeyUploadServlet(RestServlet): "device_keys": { "user_id": "", "device_id": "", - "valid_until_ts": , "algorithms": [ "m.olm.curve25519-aes-sha2", ] @@ -111,12 +121,123 @@ class KeyUploadServlet(RestServlet): self._clock = hs.get_clock() self._store = hs.get_datastores().main + class KeyUploadRequestBody(RequestBodyModel): + """ + The body of a `POST /_matrix/client/v3/keys/upload` request. + + Based on https://spec.matrix.org/v1.16/client-server-api/#post_matrixclientv3keysupload. + """ + + class DeviceKeys(RequestBodyModel): + algorithms: List[StrictStr] + """The encryption algorithms supported by this device.""" + + device_id: StrictStr + """The ID of the device these keys belong to. Must match the device ID used when logging in.""" + + keys: Mapping[StrictStr, StrictStr] + """ + Public identity keys. The names of the properties should be in the + format `:`. The keys themselves should be encoded as + specified by the key algorithm. + """ + + signatures: Mapping[StrictStr, Mapping[StrictStr, StrictStr]] + """Signatures for the device key object. A map from user ID, to a map from ":" to the signature.""" + + user_id: StrictStr + """The ID of the user the device belongs to. Must match the user ID used when logging in.""" + + class KeyObject(RequestBodyModel): + key: StrictStr + """The key, encoded using unpadded base64.""" + + fallback: Optional[StrictBool] = False + """Whether this is a fallback key. Only used when handling fallback keys.""" + + signatures: Mapping[StrictStr, Mapping[StrictStr, StrictStr]] + """Signature for the device. Mapped from user ID to another map of key signing identifier to the signature itself. + + See the following for more detail: https://spec.matrix.org/v1.16/appendices/#signing-details + """ + + device_keys: Optional[DeviceKeys] = None + """Identity keys for the device. May be absent if no new identity keys are required.""" + + fallback_keys: Optional[Mapping[StrictStr, Union[StrictStr, KeyObject]]] + """ + The public key which should be used if the device's one-time keys are + exhausted. The fallback key is not deleted once used, but should be + replaced when additional one-time keys are being uploaded. The server + will notify the client of the fallback key being used through `/sync`. + + There can only be at most one key per algorithm uploaded, and the server + will only persist one key per algorithm. + + When uploading a signed key, an additional fallback: true key should be + included to denote that the key is a fallback key. + + May be absent if a new fallback key is not required. + """ + + @validator("fallback_keys", pre=True) + def validate_fallback_keys(cls: Self, v: Any) -> Any: + if v is None: + return v + if not isinstance(v, dict): + raise TypeError("fallback_keys must be a mapping") + + for k in v.keys(): + if not len(k.split(":")) == 2: + raise SynapseError( + code=HTTPStatus.BAD_REQUEST, + errcode=Codes.BAD_JSON, + msg=f"Invalid fallback_keys key {k!r}. " + 'Expected ":".', + ) + return v + + one_time_keys: Optional[Mapping[StrictStr, Union[StrictStr, KeyObject]]] = None + """ + One-time public keys for "pre-key" messages. The names of the properties + should be in the format `:`. + + The format of the key is determined by the key algorithm, see: + https://spec.matrix.org/v1.16/client-server-api/#key-algorithms. + """ + + @validator("one_time_keys", pre=True) + def validate_one_time_keys(cls: Self, v: Any) -> Any: + if v is None: + return v + if not isinstance(v, dict): + raise TypeError("one_time_keys must be a mapping") + + for k, _ in v.items(): + if not len(k.split(":")) == 2: + raise SynapseError( + code=HTTPStatus.BAD_REQUEST, + errcode=Codes.BAD_JSON, + msg=f"Invalid one_time_keys key {k!r}. " + 'Expected ":".', + ) + return v + async def on_POST( self, request: SynapseRequest, device_id: Optional[str] ) -> Tuple[int, JsonDict]: requester = await self.auth.get_user_by_req(request, allow_guest=True) user_id = requester.user.to_string() + + # Parse the request body. Validate separately, as the handler expects a + # plain dict, rather than any parsed object. + # + # Note: It would be nice to work with a parsed object, but the handler + # needs to encode portions of the request body as canonical JSON before + # storing the result in the DB. There's little point in converted to a + # parsed object and then back to a dict. 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 @@ -149,8 +270,31 @@ class KeyUploadServlet(RestServlet): 400, "To upload keys, you must pass device_id when authenticating" ) + if "device_keys" in body: + # Validate the provided `user_id` and `device_id` fields in + # `device_keys` match that of the requesting user. We can't do + # this directly in the pydantic model as we don't have access + # to the requester yet. + # + # TODO: We could use ValidationInfo when we switch to Pydantic v2. + # https://docs.pydantic.dev/latest/concepts/validators/#validation-info + if body["device_keys"]["user_id"] != user_id: + raise SynapseError( + code=HTTPStatus.BAD_REQUEST, + errcode=Codes.BAD_JSON, + msg="Provided `user_id` in `device_keys` does not match that of the authenticated user", + ) + if body["device_keys"]["device_id"] != device_id: + raise SynapseError( + code=HTTPStatus.BAD_REQUEST, + errcode=Codes.BAD_JSON, + msg="Provided `device_id` in `device_keys` does not match that of the authenticated user device", + ) + result = await self.e2e_keys_handler.upload_keys_for_user( - user_id=user_id, device_id=device_id, keys=body + user_id=user_id, + device_id=device_id, + keys=body, ) return 200, result diff --git a/tests/rest/client/test_keys.py b/tests/rest/client/test_keys.py index d9a210b616..ef3aef5dc8 100644 --- a/tests/rest/client/test_keys.py +++ b/tests/rest/client/test_keys.py @@ -40,6 +40,127 @@ from tests.unittest import override_config from tests.utils import HAS_AUTHLIB +class KeyUploadTestCase(unittest.HomeserverTestCase): + servlets = [ + keys.register_servlets, + admin.register_servlets_for_client_rest_resource, + login.register_servlets, + ] + + def test_upload_keys_fails_on_invalid_structure(self) -> None: + """Check that we validate the structure of keys upon upload. + + Regression test for https://github.com/element-hq/synapse/pull/17097 + """ + self.register_user("alice", "wonderland") + alice_token = self.login("alice", "wonderland") + + channel = self.make_request( + "POST", + "/_matrix/client/v3/keys/upload", + { + # Error: device_keys must be a dict + "device_keys": ["some", "stuff", "weewoo"] + }, + alice_token, + ) + self.assertEqual(channel.code, HTTPStatus.BAD_REQUEST, channel.result) + self.assertEqual( + channel.json_body["errcode"], + Codes.BAD_JSON, + channel.result, + ) + + channel = self.make_request( + "POST", + "/_matrix/client/v3/keys/upload", + { + # Error: properties of fallback_keys must be in the form `:` + "fallback_keys": {"invalid_key": "signature_base64"} + }, + alice_token, + ) + self.assertEqual(channel.code, HTTPStatus.BAD_REQUEST, channel.result) + self.assertEqual( + channel.json_body["errcode"], + Codes.BAD_JSON, + channel.result, + ) + + channel = self.make_request( + "POST", + "/_matrix/client/v3/keys/upload", + { + # Same as above, but for one_time_keys + "one_time_keys": {"invalid_key": "signature_base64"} + }, + alice_token, + ) + self.assertEqual(channel.code, HTTPStatus.BAD_REQUEST, channel.result) + self.assertEqual( + channel.json_body["errcode"], + Codes.BAD_JSON, + channel.result, + ) + + def test_upload_keys_fails_on_invalid_user_id_or_device_id(self) -> None: + """ + Validate that the requesting user is uploading their own keys and nobody + else's. + """ + device_id = "DEVICE_ID" + alice_user_id = self.register_user("alice", "wonderland") + alice_token = self.login("alice", "wonderland", device_id=device_id) + + channel = self.make_request( + "POST", + "/_matrix/client/v3/keys/upload", + { + "device_keys": { + # Included `user_id` does not match requesting user. + "user_id": "@unknown_user:test", + "device_id": device_id, + "algorithms": ["m.olm.curve25519-aes-sha2"], + "keys": { + f"ed25519:{device_id}": "publickey", + }, + "signatures": {}, + } + }, + alice_token, + ) + self.assertEqual(channel.code, HTTPStatus.BAD_REQUEST, channel.result) + self.assertEqual( + channel.json_body["errcode"], + Codes.BAD_JSON, + channel.result, + ) + + channel = self.make_request( + "POST", + "/_matrix/client/v3/keys/upload", + { + "device_keys": { + "user_id": alice_user_id, + # Included `device_id` does not match requesting user's. + "device_id": "UNKNOWN_DEVICE_ID", + "algorithms": ["m.olm.curve25519-aes-sha2"], + "keys": { + f"ed25519:{device_id}": "publickey", + }, + "signatures": {}, + } + }, + alice_token, + ) + self.assertEqual(channel.code, HTTPStatus.BAD_REQUEST, channel.result) + self.assertEqual( + channel.json_body["errcode"], + Codes.BAD_JSON, + channel.result, + ) + + class KeyQueryTestCase(unittest.HomeserverTestCase): servlets = [ keys.register_servlets, From 527e831b61bcf04106b6338b53048ec3986be7c0 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Tue, 7 Oct 2025 12:54:43 +0100 Subject: [PATCH 4/4] 1.138.3 --- CHANGES.md | 13 +++++++++++++ changelog.d/17097.misc | 1 - changelog.d/18996.removal | 1 - debian/changelog | 6 ++++++ pyproject.toml | 2 +- 5 files changed, 20 insertions(+), 3 deletions(-) delete mode 100644 changelog.d/17097.misc delete mode 100644 changelog.d/18996.removal diff --git a/CHANGES.md b/CHANGES.md index e49091205e..cf2c9b634f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,16 @@ +# Synapse 1.138.3 (2025-10-07) + +## Security Fixes + +- Fix [CVE-2025-61672](https://www.cve.org/CVERecord?id=CVE-2025-61672) / [GHSA-fh66-fcv5-jjfr](https://github.com/element-hq/synapse/security/advisories/GHSA-fh66-fcv5-jjfr). Lack of validation for device keys in Synapse before 1.139.1 allows an attacker registered on the victim homeserver to degrade federation functionality, unpredictably breaking outbound federation to other homeservers. ([\#17097](https://github.com/element-hq/synapse/issues/17097)) + +## Deprecations and Removals + +- Drop support for unstable field names from the long-accepted [MSC2732](https://github.com/matrix-org/matrix-spec-proposals/pull/2732) (Olm fallback keys) proposal. This change allows unit tests to pass following the security patch above. ([\#18996](https://github.com/element-hq/synapse/issues/18996)) + + + + # Synapse 1.138.2 (2025-09-24) ## Internal Changes diff --git a/changelog.d/17097.misc b/changelog.d/17097.misc deleted file mode 100644 index 42792e5f38..0000000000 --- a/changelog.d/17097.misc +++ /dev/null @@ -1 +0,0 @@ -Extend validation of uploaded device keys. \ No newline at end of file diff --git a/changelog.d/18996.removal b/changelog.d/18996.removal deleted file mode 100644 index fa06fcc929..0000000000 --- a/changelog.d/18996.removal +++ /dev/null @@ -1 +0,0 @@ -Drop support for unstable field names from the long-accepted [MSC2732](https://github.com/matrix-org/matrix-spec-proposals/pull/2732) (Olm fallback keys) proposal. \ No newline at end of file diff --git a/debian/changelog b/debian/changelog index ea15974d63..f7ebd148a7 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +matrix-synapse-py3 (1.138.3) stable; urgency=medium + + * New Synapse release 1.138.3. + + -- Synapse Packaging team Tue, 07 Oct 2025 12:54:18 +0100 + matrix-synapse-py3 (1.138.2) stable; urgency=medium * New Synapse release 1.138.2. diff --git a/pyproject.toml b/pyproject.toml index c548a652e9..50e8a7ed84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,7 +101,7 @@ module-name = "synapse.synapse_rust" [tool.poetry] name = "matrix-synapse" -version = "1.138.2" +version = "1.138.3" description = "Homeserver for the Matrix decentralised comms protocol" authors = ["Matrix.org Team and Contributors "] license = "AGPL-3.0-or-later"