Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit 4cb44a1

Browse files
authored
Add support for MSC2697: Dehydrated devices (#8380)
This allows a user to store an offline device on the server and then restore it at a subsequent login.
1 parent 43c6228 commit 4cb44a1

File tree

9 files changed

+454
-21
lines changed

9 files changed

+454
-21
lines changed

changelog.d/8380.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for device dehydration ([MSC2697](https://github.com/matrix-org/matrix-doc/pull/2697)).

synapse/handlers/device.py

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# -*- coding: utf-8 -*-
22
# Copyright 2016 OpenMarket Ltd
33
# Copyright 2019 New Vector Ltd
4-
# Copyright 2019 The Matrix.org Foundation C.I.C.
4+
# Copyright 2019,2020 The Matrix.org Foundation C.I.C.
55
#
66
# Licensed under the Apache License, Version 2.0 (the "License");
77
# you may not use this file except in compliance with the License.
@@ -15,7 +15,7 @@
1515
# See the License for the specific language governing permissions and
1616
# limitations under the License.
1717
import logging
18-
from typing import Any, Dict, List, Optional
18+
from typing import Any, Dict, List, Optional, Tuple
1919

2020
from synapse.api import errors
2121
from synapse.api.constants import EventTypes
@@ -29,6 +29,7 @@
2929
from synapse.logging.opentracing import log_kv, set_tag, trace
3030
from synapse.metrics.background_process_metrics import run_as_background_process
3131
from synapse.types import (
32+
JsonDict,
3233
StreamToken,
3334
get_domain_from_id,
3435
get_verify_key_from_cross_signing_key,
@@ -505,6 +506,85 @@ async def user_left_room(self, user, room_id):
505506
# receive device updates. Mark this in DB.
506507
await self.store.mark_remote_user_device_list_as_unsubscribed(user_id)
507508

509+
async def store_dehydrated_device(
510+
self,
511+
user_id: str,
512+
device_data: JsonDict,
513+
initial_device_display_name: Optional[str] = None,
514+
) -> str:
515+
"""Store a dehydrated device for a user. If the user had a previous
516+
dehydrated device, it is removed.
517+
518+
Args:
519+
user_id: the user that we are storing the device for
520+
device_data: the dehydrated device information
521+
initial_device_display_name: The display name to use for the device
522+
Returns:
523+
device id of the dehydrated device
524+
"""
525+
device_id = await self.check_device_registered(
526+
user_id, None, initial_device_display_name,
527+
)
528+
old_device_id = await self.store.store_dehydrated_device(
529+
user_id, device_id, device_data
530+
)
531+
if old_device_id is not None:
532+
await self.delete_device(user_id, old_device_id)
533+
return device_id
534+
535+
async def get_dehydrated_device(
536+
self, user_id: str
537+
) -> Optional[Tuple[str, JsonDict]]:
538+
"""Retrieve the information for a dehydrated device.
539+
540+
Args:
541+
user_id: the user whose dehydrated device we are looking for
542+
Returns:
543+
a tuple whose first item is the device ID, and the second item is
544+
the dehydrated device information
545+
"""
546+
return await self.store.get_dehydrated_device(user_id)
547+
548+
async def rehydrate_device(
549+
self, user_id: str, access_token: str, device_id: str
550+
) -> dict:
551+
"""Process a rehydration request from the user.
552+
553+
Args:
554+
user_id: the user who is rehydrating the device
555+
access_token: the access token used for the request
556+
device_id: the ID of the device that will be rehydrated
557+
Returns:
558+
a dict containing {"success": True}
559+
"""
560+
success = await self.store.remove_dehydrated_device(user_id, device_id)
561+
562+
if not success:
563+
raise errors.NotFoundError()
564+
565+
# If the dehydrated device was successfully deleted (the device ID
566+
# matched the stored dehydrated device), then modify the access
567+
# token to use the dehydrated device's ID and copy the old device
568+
# display name to the dehydrated device, and destroy the old device
569+
# ID
570+
old_device_id = await self.store.set_device_for_access_token(
571+
access_token, device_id
572+
)
573+
old_device = await self.store.get_device(user_id, old_device_id)
574+
await self.store.update_device(user_id, device_id, old_device["display_name"])
575+
# can't call self.delete_device because that will clobber the
576+
# access token so call the storage layer directly
577+
await self.store.delete_device(user_id, old_device_id)
578+
await self.store.delete_e2e_keys_by_device(
579+
user_id=user_id, device_id=old_device_id
580+
)
581+
582+
# tell everyone that the old device is gone and that the dehydrated
583+
# device has a new display name
584+
await self.notify_device_update(user_id, [old_device_id, device_id])
585+
586+
return {"success": True}
587+
508588

509589
def _update_device_from_client_ips(device, client_ips):
510590
ip = client_ips.get((device["user_id"], device["device_id"]), {})

synapse/rest/client/v2_alpha/devices.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# -*- coding: utf-8 -*-
22
# Copyright 2015, 2016 OpenMarket Ltd
3+
# Copyright 2020 The Matrix.org Foundation C.I.C.
34
#
45
# Licensed under the Apache License, Version 2.0 (the "License");
56
# you may not use this file except in compliance with the License.
@@ -21,6 +22,7 @@
2122
assert_params_in_dict,
2223
parse_json_object_from_request,
2324
)
25+
from synapse.http.site import SynapseRequest
2426

2527
from ._base import client_patterns, interactive_auth_handler
2628

@@ -151,7 +153,139 @@ async def on_PUT(self, request, device_id):
151153
return 200, {}
152154

153155

156+
class DehydratedDeviceServlet(RestServlet):
157+
"""Retrieve or store a dehydrated device.
158+
159+
GET /org.matrix.msc2697.v2/dehydrated_device
160+
161+
HTTP/1.1 200 OK
162+
Content-Type: application/json
163+
164+
{
165+
"device_id": "dehydrated_device_id",
166+
"device_data": {
167+
"algorithm": "org.matrix.msc2697.v1.dehydration.v1.olm",
168+
"account": "dehydrated_device"
169+
}
170+
}
171+
172+
PUT /org.matrix.msc2697/dehydrated_device
173+
Content-Type: application/json
174+
175+
{
176+
"device_data": {
177+
"algorithm": "org.matrix.msc2697.v1.dehydration.v1.olm",
178+
"account": "dehydrated_device"
179+
}
180+
}
181+
182+
HTTP/1.1 200 OK
183+
Content-Type: application/json
184+
185+
{
186+
"device_id": "dehydrated_device_id"
187+
}
188+
189+
"""
190+
191+
PATTERNS = client_patterns("/org.matrix.msc2697.v2/dehydrated_device", releases=())
192+
193+
def __init__(self, hs):
194+
super().__init__()
195+
self.hs = hs
196+
self.auth = hs.get_auth()
197+
self.device_handler = hs.get_device_handler()
198+
199+
async def on_GET(self, request: SynapseRequest):
200+
requester = await self.auth.get_user_by_req(request)
201+
dehydrated_device = await self.device_handler.get_dehydrated_device(
202+
requester.user.to_string()
203+
)
204+
if dehydrated_device is not None:
205+
(device_id, device_data) = dehydrated_device
206+
result = {"device_id": device_id, "device_data": device_data}
207+
return (200, result)
208+
else:
209+
raise errors.NotFoundError("No dehydrated device available")
210+
211+
async def on_PUT(self, request: SynapseRequest):
212+
submission = parse_json_object_from_request(request)
213+
requester = await self.auth.get_user_by_req(request)
214+
215+
if "device_data" not in submission:
216+
raise errors.SynapseError(
217+
400, "device_data missing", errcode=errors.Codes.MISSING_PARAM,
218+
)
219+
elif not isinstance(submission["device_data"], dict):
220+
raise errors.SynapseError(
221+
400,
222+
"device_data must be an object",
223+
errcode=errors.Codes.INVALID_PARAM,
224+
)
225+
226+
device_id = await self.device_handler.store_dehydrated_device(
227+
requester.user.to_string(),
228+
submission["device_data"],
229+
submission.get("initial_device_display_name", None),
230+
)
231+
return 200, {"device_id": device_id}
232+
233+
234+
class ClaimDehydratedDeviceServlet(RestServlet):
235+
"""Claim a dehydrated device.
236+
237+
POST /org.matrix.msc2697.v2/dehydrated_device/claim
238+
Content-Type: application/json
239+
240+
{
241+
"device_id": "dehydrated_device_id"
242+
}
243+
244+
HTTP/1.1 200 OK
245+
Content-Type: application/json
246+
247+
{
248+
"success": true,
249+
}
250+
251+
"""
252+
253+
PATTERNS = client_patterns(
254+
"/org.matrix.msc2697.v2/dehydrated_device/claim", releases=()
255+
)
256+
257+
def __init__(self, hs):
258+
super().__init__()
259+
self.hs = hs
260+
self.auth = hs.get_auth()
261+
self.device_handler = hs.get_device_handler()
262+
263+
async def on_POST(self, request: SynapseRequest):
264+
requester = await self.auth.get_user_by_req(request)
265+
266+
submission = parse_json_object_from_request(request)
267+
268+
if "device_id" not in submission:
269+
raise errors.SynapseError(
270+
400, "device_id missing", errcode=errors.Codes.MISSING_PARAM,
271+
)
272+
elif not isinstance(submission["device_id"], str):
273+
raise errors.SynapseError(
274+
400, "device_id must be a string", errcode=errors.Codes.INVALID_PARAM,
275+
)
276+
277+
result = await self.device_handler.rehydrate_device(
278+
requester.user.to_string(),
279+
self.auth.get_access_token_from_request(request),
280+
submission["device_id"],
281+
)
282+
283+
return (200, result)
284+
285+
154286
def register_servlets(hs, http_server):
155287
DeleteDevicesRestServlet(hs).register(http_server)
156288
DevicesRestServlet(hs).register(http_server)
157289
DeviceRestServlet(hs).register(http_server)
290+
DehydratedDeviceServlet(hs).register(http_server)
291+
ClaimDehydratedDeviceServlet(hs).register(http_server)

synapse/rest/client/v2_alpha/keys.py

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# -*- coding: utf-8 -*-
22
# Copyright 2015, 2016 OpenMarket Ltd
33
# Copyright 2019 New Vector Ltd
4+
# Copyright 2020 The Matrix.org Foundation C.I.C.
45
#
56
# Licensed under the Apache License, Version 2.0 (the "License");
67
# you may not use this file except in compliance with the License.
@@ -67,6 +68,7 @@ def __init__(self, hs):
6768
super().__init__()
6869
self.auth = hs.get_auth()
6970
self.e2e_keys_handler = hs.get_e2e_keys_handler()
71+
self.device_handler = hs.get_device_handler()
7072

7173
@trace(opname="upload_keys")
7274
async def on_POST(self, request, device_id):
@@ -75,23 +77,28 @@ async def on_POST(self, request, device_id):
7577
body = parse_json_object_from_request(request)
7678

7779
if device_id is not None:
78-
# passing the device_id here is deprecated; however, we allow it
79-
# for now for compatibility with older clients.
80+
# Providing the device_id should only be done for setting keys
81+
# for dehydrated devices; however, we allow it for any device for
82+
# compatibility with older clients.
8083
if requester.device_id is not None and device_id != requester.device_id:
81-
set_tag("error", True)
82-
log_kv(
83-
{
84-
"message": "Client uploading keys for a different device",
85-
"logged_in_id": requester.device_id,
86-
"key_being_uploaded": device_id,
87-
}
88-
)
89-
logger.warning(
90-
"Client uploading keys for a different device "
91-
"(logged in as %s, uploading for %s)",
92-
requester.device_id,
93-
device_id,
84+
dehydrated_device = await self.device_handler.get_dehydrated_device(
85+
user_id
9486
)
87+
if dehydrated_device is not None and device_id != dehydrated_device[0]:
88+
set_tag("error", True)
89+
log_kv(
90+
{
91+
"message": "Client uploading keys for a different device",
92+
"logged_in_id": requester.device_id,
93+
"key_being_uploaded": device_id,
94+
}
95+
)
96+
logger.warning(
97+
"Client uploading keys for a different device "
98+
"(logged in as %s, uploading for %s)",
99+
requester.device_id,
100+
device_id,
101+
)
95102
else:
96103
device_id = requester.device_id
97104

0 commit comments

Comments
 (0)