Skip to content

Commit 42bbff8

Browse files
S7evinKanoadragon453MadLittleMods
authored
Validate the body of requests to /keys/upload (#17097)
Co-authored-by: Andrew Morgan <[email protected]> Co-authored-by: Andrew Morgan <[email protected]> Co-authored-by: Eric Eastwood <[email protected]>
1 parent 5465c68 commit 42bbff8

File tree

4 files changed

+280
-8
lines changed

4 files changed

+280
-8
lines changed

changelog.d/17097.misc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Extend validation of uploaded device keys.

synapse/handlers/e2e_keys.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@
5757

5858
logger = logging.getLogger(__name__)
5959

60-
6160
ONE_TIME_KEY_UPLOAD = "one_time_key_upload_lock"
6261

6362

@@ -847,14 +846,22 @@ async def upload_keys_for_user(
847846
"""
848847
time_now = self.clock.time_msec()
849848

850-
# TODO: Validate the JSON to make sure it has the right keys.
851849
device_keys = keys.get("device_keys", None)
852850
if device_keys:
851+
log_kv(
852+
{
853+
"message": "Updating device_keys for user.",
854+
"user_id": user_id,
855+
"device_id": device_id,
856+
}
857+
)
853858
await self.upload_device_keys_for_user(
854859
user_id=user_id,
855860
device_id=device_id,
856861
keys={"device_keys": device_keys},
857862
)
863+
else:
864+
log_kv({"message": "Did not update device_keys", "reason": "not a dict"})
858865

859866
one_time_keys = keys.get("one_time_keys", None)
860867
if one_time_keys:
@@ -872,8 +879,9 @@ async def upload_keys_for_user(
872879
log_kv(
873880
{"message": "Did not update one_time_keys", "reason": "no keys given"}
874881
)
882+
875883
fallback_keys = keys.get("fallback_keys")
876-
if fallback_keys and isinstance(fallback_keys, dict):
884+
if fallback_keys:
877885
log_kv(
878886
{
879887
"message": "Updating fallback_keys for device.",
@@ -882,8 +890,6 @@ async def upload_keys_for_user(
882890
}
883891
)
884892
await self.store.set_e2e_fallback_keys(user_id, device_id, fallback_keys)
885-
elif fallback_keys:
886-
log_kv({"message": "Did not update fallback_keys", "reason": "not a dict"})
887893
else:
888894
log_kv(
889895
{"message": "Did not update fallback_keys", "reason": "no keys given"}

synapse/rest/client/keys.py

Lines changed: 147 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,19 @@
2323
import logging
2424
import re
2525
from collections import Counter
26-
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple
26+
from http import HTTPStatus
27+
from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Tuple, Union
2728

29+
from typing_extensions import Self
30+
31+
from synapse._pydantic_compat import (
32+
StrictBool,
33+
StrictStr,
34+
validator,
35+
)
2836
from synapse.api.auth.mas import MasDelegatedAuth
2937
from synapse.api.errors import (
38+
Codes,
3039
InteractiveAuthIncompleteError,
3140
InvalidAPICallError,
3241
SynapseError,
@@ -37,11 +46,13 @@
3746
parse_integer,
3847
parse_json_object_from_request,
3948
parse_string,
49+
validate_json_object,
4050
)
4151
from synapse.http.site import SynapseRequest
4252
from synapse.logging.opentracing import log_kv, set_tag
4353
from synapse.rest.client._base import client_patterns, interactive_auth_handler
4454
from synapse.types import JsonDict, StreamToken
55+
from synapse.types.rest import RequestBodyModel
4556
from synapse.util.cancellation import cancellable
4657

4758
if TYPE_CHECKING:
@@ -59,7 +70,6 @@ class KeyUploadServlet(RestServlet):
5970
"device_keys": {
6071
"user_id": "<user_id>",
6172
"device_id": "<device_id>",
62-
"valid_until_ts": <millisecond_timestamp>,
6373
"algorithms": [
6474
"m.olm.curve25519-aes-sha2",
6575
]
@@ -111,12 +121,123 @@ def __init__(self, hs: "HomeServer"):
111121
self._clock = hs.get_clock()
112122
self._store = hs.get_datastores().main
113123

124+
class KeyUploadRequestBody(RequestBodyModel):
125+
"""
126+
The body of a `POST /_matrix/client/v3/keys/upload` request.
127+
128+
Based on https://spec.matrix.org/v1.16/client-server-api/#post_matrixclientv3keysupload.
129+
"""
130+
131+
class DeviceKeys(RequestBodyModel):
132+
algorithms: List[StrictStr]
133+
"""The encryption algorithms supported by this device."""
134+
135+
device_id: StrictStr
136+
"""The ID of the device these keys belong to. Must match the device ID used when logging in."""
137+
138+
keys: Mapping[StrictStr, StrictStr]
139+
"""
140+
Public identity keys. The names of the properties should be in the
141+
format `<algorithm>:<device_id>`. The keys themselves should be encoded as
142+
specified by the key algorithm.
143+
"""
144+
145+
signatures: Mapping[StrictStr, Mapping[StrictStr, StrictStr]]
146+
"""Signatures for the device key object. A map from user ID, to a map from "<algorithm>:<device_id>" to the signature."""
147+
148+
user_id: StrictStr
149+
"""The ID of the user the device belongs to. Must match the user ID used when logging in."""
150+
151+
class KeyObject(RequestBodyModel):
152+
key: StrictStr
153+
"""The key, encoded using unpadded base64."""
154+
155+
fallback: Optional[StrictBool] = False
156+
"""Whether this is a fallback key. Only used when handling fallback keys."""
157+
158+
signatures: Mapping[StrictStr, Mapping[StrictStr, StrictStr]]
159+
"""Signature for the device. Mapped from user ID to another map of key signing identifier to the signature itself.
160+
161+
See the following for more detail: https://spec.matrix.org/v1.16/appendices/#signing-details
162+
"""
163+
164+
device_keys: Optional[DeviceKeys] = None
165+
"""Identity keys for the device. May be absent if no new identity keys are required."""
166+
167+
fallback_keys: Optional[Mapping[StrictStr, Union[StrictStr, KeyObject]]]
168+
"""
169+
The public key which should be used if the device's one-time keys are
170+
exhausted. The fallback key is not deleted once used, but should be
171+
replaced when additional one-time keys are being uploaded. The server
172+
will notify the client of the fallback key being used through `/sync`.
173+
174+
There can only be at most one key per algorithm uploaded, and the server
175+
will only persist one key per algorithm.
176+
177+
When uploading a signed key, an additional fallback: true key should be
178+
included to denote that the key is a fallback key.
179+
180+
May be absent if a new fallback key is not required.
181+
"""
182+
183+
@validator("fallback_keys", pre=True)
184+
def validate_fallback_keys(cls: Self, v: Any) -> Any:
185+
if v is None:
186+
return v
187+
if not isinstance(v, dict):
188+
raise TypeError("fallback_keys must be a mapping")
189+
190+
for k in v.keys():
191+
if not len(k.split(":")) == 2:
192+
raise SynapseError(
193+
code=HTTPStatus.BAD_REQUEST,
194+
errcode=Codes.BAD_JSON,
195+
msg=f"Invalid fallback_keys key {k!r}. "
196+
'Expected "<algorithm>:<device_id>".',
197+
)
198+
return v
199+
200+
one_time_keys: Optional[Mapping[StrictStr, Union[StrictStr, KeyObject]]] = None
201+
"""
202+
One-time public keys for "pre-key" messages. The names of the properties
203+
should be in the format `<algorithm>:<key_id>`.
204+
205+
The format of the key is determined by the key algorithm, see:
206+
https://spec.matrix.org/v1.16/client-server-api/#key-algorithms.
207+
"""
208+
209+
@validator("one_time_keys", pre=True)
210+
def validate_one_time_keys(cls: Self, v: Any) -> Any:
211+
if v is None:
212+
return v
213+
if not isinstance(v, dict):
214+
raise TypeError("one_time_keys must be a mapping")
215+
216+
for k, _ in v.items():
217+
if not len(k.split(":")) == 2:
218+
raise SynapseError(
219+
code=HTTPStatus.BAD_REQUEST,
220+
errcode=Codes.BAD_JSON,
221+
msg=f"Invalid one_time_keys key {k!r}. "
222+
'Expected "<algorithm>:<device_id>".',
223+
)
224+
return v
225+
114226
async def on_POST(
115227
self, request: SynapseRequest, device_id: Optional[str]
116228
) -> Tuple[int, JsonDict]:
117229
requester = await self.auth.get_user_by_req(request, allow_guest=True)
118230
user_id = requester.user.to_string()
231+
232+
# Parse the request body. Validate separately, as the handler expects a
233+
# plain dict, rather than any parsed object.
234+
#
235+
# Note: It would be nice to work with a parsed object, but the handler
236+
# needs to encode portions of the request body as canonical JSON before
237+
# storing the result in the DB. There's little point in converted to a
238+
# parsed object and then back to a dict.
119239
body = parse_json_object_from_request(request)
240+
validate_json_object(body, self.KeyUploadRequestBody)
120241

121242
if device_id is not None:
122243
# Providing the device_id should only be done for setting keys
@@ -149,8 +270,31 @@ async def on_POST(
149270
400, "To upload keys, you must pass device_id when authenticating"
150271
)
151272

273+
if "device_keys" in body:
274+
# Validate the provided `user_id` and `device_id` fields in
275+
# `device_keys` match that of the requesting user. We can't do
276+
# this directly in the pydantic model as we don't have access
277+
# to the requester yet.
278+
#
279+
# TODO: We could use ValidationInfo when we switch to Pydantic v2.
280+
# https://docs.pydantic.dev/latest/concepts/validators/#validation-info
281+
if body["device_keys"]["user_id"] != user_id:
282+
raise SynapseError(
283+
code=HTTPStatus.BAD_REQUEST,
284+
errcode=Codes.BAD_JSON,
285+
msg="Provided `user_id` in `device_keys` does not match that of the authenticated user",
286+
)
287+
if body["device_keys"]["device_id"] != device_id:
288+
raise SynapseError(
289+
code=HTTPStatus.BAD_REQUEST,
290+
errcode=Codes.BAD_JSON,
291+
msg="Provided `device_id` in `device_keys` does not match that of the authenticated user device",
292+
)
293+
152294
result = await self.e2e_keys_handler.upload_keys_for_user(
153-
user_id=user_id, device_id=device_id, keys=body
295+
user_id=user_id,
296+
device_id=device_id,
297+
keys=body,
154298
)
155299

156300
return 200, result

tests/rest/client/test_keys.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,127 @@
4040
from tests.utils import HAS_AUTHLIB
4141

4242

43+
class KeyUploadTestCase(unittest.HomeserverTestCase):
44+
servlets = [
45+
keys.register_servlets,
46+
admin.register_servlets_for_client_rest_resource,
47+
login.register_servlets,
48+
]
49+
50+
def test_upload_keys_fails_on_invalid_structure(self) -> None:
51+
"""Check that we validate the structure of keys upon upload.
52+
53+
Regression test for https://github.com/element-hq/synapse/pull/17097
54+
"""
55+
self.register_user("alice", "wonderland")
56+
alice_token = self.login("alice", "wonderland")
57+
58+
channel = self.make_request(
59+
"POST",
60+
"/_matrix/client/v3/keys/upload",
61+
{
62+
# Error: device_keys must be a dict
63+
"device_keys": ["some", "stuff", "weewoo"]
64+
},
65+
alice_token,
66+
)
67+
self.assertEqual(channel.code, HTTPStatus.BAD_REQUEST, channel.result)
68+
self.assertEqual(
69+
channel.json_body["errcode"],
70+
Codes.BAD_JSON,
71+
channel.result,
72+
)
73+
74+
channel = self.make_request(
75+
"POST",
76+
"/_matrix/client/v3/keys/upload",
77+
{
78+
# Error: properties of fallback_keys must be in the form `<algorithm>:<device_id>`
79+
"fallback_keys": {"invalid_key": "signature_base64"}
80+
},
81+
alice_token,
82+
)
83+
self.assertEqual(channel.code, HTTPStatus.BAD_REQUEST, channel.result)
84+
self.assertEqual(
85+
channel.json_body["errcode"],
86+
Codes.BAD_JSON,
87+
channel.result,
88+
)
89+
90+
channel = self.make_request(
91+
"POST",
92+
"/_matrix/client/v3/keys/upload",
93+
{
94+
# Same as above, but for one_time_keys
95+
"one_time_keys": {"invalid_key": "signature_base64"}
96+
},
97+
alice_token,
98+
)
99+
self.assertEqual(channel.code, HTTPStatus.BAD_REQUEST, channel.result)
100+
self.assertEqual(
101+
channel.json_body["errcode"],
102+
Codes.BAD_JSON,
103+
channel.result,
104+
)
105+
106+
def test_upload_keys_fails_on_invalid_user_id_or_device_id(self) -> None:
107+
"""
108+
Validate that the requesting user is uploading their own keys and nobody
109+
else's.
110+
"""
111+
device_id = "DEVICE_ID"
112+
alice_user_id = self.register_user("alice", "wonderland")
113+
alice_token = self.login("alice", "wonderland", device_id=device_id)
114+
115+
channel = self.make_request(
116+
"POST",
117+
"/_matrix/client/v3/keys/upload",
118+
{
119+
"device_keys": {
120+
# Included `user_id` does not match requesting user.
121+
"user_id": "@unknown_user:test",
122+
"device_id": device_id,
123+
"algorithms": ["m.olm.curve25519-aes-sha2"],
124+
"keys": {
125+
f"ed25519:{device_id}": "publickey",
126+
},
127+
"signatures": {},
128+
}
129+
},
130+
alice_token,
131+
)
132+
self.assertEqual(channel.code, HTTPStatus.BAD_REQUEST, channel.result)
133+
self.assertEqual(
134+
channel.json_body["errcode"],
135+
Codes.BAD_JSON,
136+
channel.result,
137+
)
138+
139+
channel = self.make_request(
140+
"POST",
141+
"/_matrix/client/v3/keys/upload",
142+
{
143+
"device_keys": {
144+
"user_id": alice_user_id,
145+
# Included `device_id` does not match requesting user's.
146+
"device_id": "UNKNOWN_DEVICE_ID",
147+
"algorithms": ["m.olm.curve25519-aes-sha2"],
148+
"keys": {
149+
f"ed25519:{device_id}": "publickey",
150+
},
151+
"signatures": {},
152+
}
153+
},
154+
alice_token,
155+
)
156+
self.assertEqual(channel.code, HTTPStatus.BAD_REQUEST, channel.result)
157+
self.assertEqual(
158+
channel.json_body["errcode"],
159+
Codes.BAD_JSON,
160+
channel.result,
161+
)
162+
163+
43164
class KeyQueryTestCase(unittest.HomeserverTestCase):
44165
servlets = [
45166
keys.register_servlets,

0 commit comments

Comments
 (0)