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.
This commit is contained in:
Hubert Chathi 2020-10-07 08:00:17 -04:00 committed by GitHub
parent 43c622885c
commit 4cb44a1585
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 454 additions and 21 deletions

1
changelog.d/8380.feature Normal file
View file

@ -0,0 +1 @@
Add support for device dehydration ([MSC2697](https://github.com/matrix-org/matrix-doc/pull/2697)).

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2016 OpenMarket Ltd # Copyright 2016 OpenMarket Ltd
# Copyright 2019 New Vector Ltd # Copyright 2019 New Vector Ltd
# Copyright 2019 The Matrix.org Foundation C.I.C. # Copyright 2019,2020 The Matrix.org Foundation C.I.C.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -15,7 +15,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import logging import logging
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Tuple
from synapse.api import errors from synapse.api import errors
from synapse.api.constants import EventTypes from synapse.api.constants import EventTypes
@ -29,6 +29,7 @@ from synapse.api.errors import (
from synapse.logging.opentracing import log_kv, set_tag, trace from synapse.logging.opentracing import log_kv, set_tag, trace
from synapse.metrics.background_process_metrics import run_as_background_process from synapse.metrics.background_process_metrics import run_as_background_process
from synapse.types import ( from synapse.types import (
JsonDict,
StreamToken, StreamToken,
get_domain_from_id, get_domain_from_id,
get_verify_key_from_cross_signing_key, get_verify_key_from_cross_signing_key,
@ -505,6 +506,85 @@ class DeviceHandler(DeviceWorkerHandler):
# receive device updates. Mark this in DB. # receive device updates. Mark this in DB.
await self.store.mark_remote_user_device_list_as_unsubscribed(user_id) await self.store.mark_remote_user_device_list_as_unsubscribed(user_id)
async def store_dehydrated_device(
self,
user_id: str,
device_data: JsonDict,
initial_device_display_name: Optional[str] = None,
) -> str:
"""Store a dehydrated device for a user. If the user had a previous
dehydrated device, it is removed.
Args:
user_id: the user that we are storing the device for
device_data: the dehydrated device information
initial_device_display_name: The display name to use for the device
Returns:
device id of the dehydrated device
"""
device_id = await self.check_device_registered(
user_id, None, initial_device_display_name,
)
old_device_id = await self.store.store_dehydrated_device(
user_id, device_id, device_data
)
if old_device_id is not None:
await self.delete_device(user_id, old_device_id)
return device_id
async def get_dehydrated_device(
self, user_id: str
) -> Optional[Tuple[str, JsonDict]]:
"""Retrieve the information for a dehydrated device.
Args:
user_id: the user whose dehydrated device we are looking for
Returns:
a tuple whose first item is the device ID, and the second item is
the dehydrated device information
"""
return await self.store.get_dehydrated_device(user_id)
async def rehydrate_device(
self, user_id: str, access_token: str, device_id: str
) -> dict:
"""Process a rehydration request from the user.
Args:
user_id: the user who is rehydrating the device
access_token: the access token used for the request
device_id: the ID of the device that will be rehydrated
Returns:
a dict containing {"success": True}
"""
success = await self.store.remove_dehydrated_device(user_id, device_id)
if not success:
raise errors.NotFoundError()
# If the dehydrated device was successfully deleted (the device ID
# matched the stored dehydrated device), then modify the access
# token to use the dehydrated device's ID and copy the old device
# display name to the dehydrated device, and destroy the old device
# ID
old_device_id = await self.store.set_device_for_access_token(
access_token, device_id
)
old_device = await self.store.get_device(user_id, old_device_id)
await self.store.update_device(user_id, device_id, old_device["display_name"])
# can't call self.delete_device because that will clobber the
# access token so call the storage layer directly
await self.store.delete_device(user_id, old_device_id)
await self.store.delete_e2e_keys_by_device(
user_id=user_id, device_id=old_device_id
)
# tell everyone that the old device is gone and that the dehydrated
# device has a new display name
await self.notify_device_update(user_id, [old_device_id, device_id])
return {"success": True}
def _update_device_from_client_ips(device, client_ips): def _update_device_from_client_ips(device, client_ips):
ip = client_ips.get((device["user_id"], device["device_id"]), {}) ip = client_ips.get((device["user_id"], device["device_id"]), {})

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2015, 2016 OpenMarket Ltd # Copyright 2015, 2016 OpenMarket Ltd
# Copyright 2020 The Matrix.org Foundation C.I.C.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -21,6 +22,7 @@ from synapse.http.servlet import (
assert_params_in_dict, assert_params_in_dict,
parse_json_object_from_request, parse_json_object_from_request,
) )
from synapse.http.site import SynapseRequest
from ._base import client_patterns, interactive_auth_handler from ._base import client_patterns, interactive_auth_handler
@ -151,7 +153,139 @@ class DeviceRestServlet(RestServlet):
return 200, {} return 200, {}
class DehydratedDeviceServlet(RestServlet):
"""Retrieve or store a dehydrated device.
GET /org.matrix.msc2697.v2/dehydrated_device
HTTP/1.1 200 OK
Content-Type: application/json
{
"device_id": "dehydrated_device_id",
"device_data": {
"algorithm": "org.matrix.msc2697.v1.dehydration.v1.olm",
"account": "dehydrated_device"
}
}
PUT /org.matrix.msc2697/dehydrated_device
Content-Type: application/json
{
"device_data": {
"algorithm": "org.matrix.msc2697.v1.dehydration.v1.olm",
"account": "dehydrated_device"
}
}
HTTP/1.1 200 OK
Content-Type: application/json
{
"device_id": "dehydrated_device_id"
}
"""
PATTERNS = client_patterns("/org.matrix.msc2697.v2/dehydrated_device", releases=())
def __init__(self, hs):
super().__init__()
self.hs = hs
self.auth = hs.get_auth()
self.device_handler = hs.get_device_handler()
async def on_GET(self, request: SynapseRequest):
requester = await self.auth.get_user_by_req(request)
dehydrated_device = await self.device_handler.get_dehydrated_device(
requester.user.to_string()
)
if dehydrated_device is not None:
(device_id, device_data) = dehydrated_device
result = {"device_id": device_id, "device_data": device_data}
return (200, result)
else:
raise errors.NotFoundError("No dehydrated device available")
async def on_PUT(self, request: SynapseRequest):
submission = parse_json_object_from_request(request)
requester = await self.auth.get_user_by_req(request)
if "device_data" not in submission:
raise errors.SynapseError(
400, "device_data missing", errcode=errors.Codes.MISSING_PARAM,
)
elif not isinstance(submission["device_data"], dict):
raise errors.SynapseError(
400,
"device_data must be an object",
errcode=errors.Codes.INVALID_PARAM,
)
device_id = await self.device_handler.store_dehydrated_device(
requester.user.to_string(),
submission["device_data"],
submission.get("initial_device_display_name", None),
)
return 200, {"device_id": device_id}
class ClaimDehydratedDeviceServlet(RestServlet):
"""Claim a dehydrated device.
POST /org.matrix.msc2697.v2/dehydrated_device/claim
Content-Type: application/json
{
"device_id": "dehydrated_device_id"
}
HTTP/1.1 200 OK
Content-Type: application/json
{
"success": true,
}
"""
PATTERNS = client_patterns(
"/org.matrix.msc2697.v2/dehydrated_device/claim", releases=()
)
def __init__(self, hs):
super().__init__()
self.hs = hs
self.auth = hs.get_auth()
self.device_handler = hs.get_device_handler()
async def on_POST(self, request: SynapseRequest):
requester = await self.auth.get_user_by_req(request)
submission = parse_json_object_from_request(request)
if "device_id" not in submission:
raise errors.SynapseError(
400, "device_id missing", errcode=errors.Codes.MISSING_PARAM,
)
elif not isinstance(submission["device_id"], str):
raise errors.SynapseError(
400, "device_id must be a string", errcode=errors.Codes.INVALID_PARAM,
)
result = await self.device_handler.rehydrate_device(
requester.user.to_string(),
self.auth.get_access_token_from_request(request),
submission["device_id"],
)
return (200, result)
def register_servlets(hs, http_server): def register_servlets(hs, http_server):
DeleteDevicesRestServlet(hs).register(http_server) DeleteDevicesRestServlet(hs).register(http_server)
DevicesRestServlet(hs).register(http_server) DevicesRestServlet(hs).register(http_server)
DeviceRestServlet(hs).register(http_server) DeviceRestServlet(hs).register(http_server)
DehydratedDeviceServlet(hs).register(http_server)
ClaimDehydratedDeviceServlet(hs).register(http_server)

View file

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2015, 2016 OpenMarket Ltd # Copyright 2015, 2016 OpenMarket Ltd
# Copyright 2019 New Vector Ltd # Copyright 2019 New Vector Ltd
# Copyright 2020 The Matrix.org Foundation C.I.C.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -67,6 +68,7 @@ class KeyUploadServlet(RestServlet):
super().__init__() super().__init__()
self.auth = hs.get_auth() self.auth = hs.get_auth()
self.e2e_keys_handler = hs.get_e2e_keys_handler() self.e2e_keys_handler = hs.get_e2e_keys_handler()
self.device_handler = hs.get_device_handler()
@trace(opname="upload_keys") @trace(opname="upload_keys")
async def on_POST(self, request, device_id): async def on_POST(self, request, device_id):
@ -75,23 +77,28 @@ class KeyUploadServlet(RestServlet):
body = parse_json_object_from_request(request) body = parse_json_object_from_request(request)
if device_id is not None: if device_id is not None:
# passing the device_id here is deprecated; however, we allow it # Providing the device_id should only be done for setting keys
# for now for compatibility with older clients. # for dehydrated devices; however, we allow it for any device for
# compatibility with older clients.
if requester.device_id is not None and device_id != requester.device_id: if requester.device_id is not None and device_id != requester.device_id:
set_tag("error", True) dehydrated_device = await self.device_handler.get_dehydrated_device(
log_kv( user_id
{
"message": "Client uploading keys for a different device",
"logged_in_id": requester.device_id,
"key_being_uploaded": device_id,
}
)
logger.warning(
"Client uploading keys for a different device "
"(logged in as %s, uploading for %s)",
requester.device_id,
device_id,
) )
if dehydrated_device is not None and device_id != dehydrated_device[0]:
set_tag("error", True)
log_kv(
{
"message": "Client uploading keys for a different device",
"logged_in_id": requester.device_id,
"key_being_uploaded": device_id,
}
)
logger.warning(
"Client uploading keys for a different device "
"(logged in as %s, uploading for %s)",
requester.device_id,
device_id,
)
else: else:
device_id = requester.device_id device_id = requester.device_id

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2016 OpenMarket Ltd # Copyright 2016 OpenMarket Ltd
# Copyright 2019 New Vector Ltd # Copyright 2019 New Vector Ltd
# Copyright 2019 The Matrix.org Foundation C.I.C. # Copyright 2019,2020 The Matrix.org Foundation C.I.C.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -33,7 +33,7 @@ from synapse.storage.database import (
make_tuple_comparison_clause, make_tuple_comparison_clause,
) )
from synapse.types import Collection, JsonDict, get_verify_key_from_cross_signing_key from synapse.types import Collection, JsonDict, get_verify_key_from_cross_signing_key
from synapse.util import json_encoder from synapse.util import json_decoder, json_encoder
from synapse.util.caches.descriptors import Cache, cached, cachedList from synapse.util.caches.descriptors import Cache, cached, cachedList
from synapse.util.iterutils import batch_iter from synapse.util.iterutils import batch_iter
from synapse.util.stringutils import shortstr from synapse.util.stringutils import shortstr
@ -698,6 +698,80 @@ class DeviceWorkerStore(SQLBaseStore):
_mark_remote_user_device_list_as_unsubscribed_txn, _mark_remote_user_device_list_as_unsubscribed_txn,
) )
async def get_dehydrated_device(
self, user_id: str
) -> Optional[Tuple[str, JsonDict]]:
"""Retrieve the information for a dehydrated device.
Args:
user_id: the user whose dehydrated device we are looking for
Returns:
a tuple whose first item is the device ID, and the second item is
the dehydrated device information
"""
# FIXME: make sure device ID still exists in devices table
row = await self.db_pool.simple_select_one(
table="dehydrated_devices",
keyvalues={"user_id": user_id},
retcols=["device_id", "device_data"],
allow_none=True,
)
return (
(row["device_id"], json_decoder.decode(row["device_data"])) if row else None
)
def _store_dehydrated_device_txn(
self, txn, user_id: str, device_id: str, device_data: str
) -> Optional[str]:
old_device_id = self.db_pool.simple_select_one_onecol_txn(
txn,
table="dehydrated_devices",
keyvalues={"user_id": user_id},
retcol="device_id",
allow_none=True,
)
self.db_pool.simple_upsert_txn(
txn,
table="dehydrated_devices",
keyvalues={"user_id": user_id},
values={"device_id": device_id, "device_data": device_data},
)
return old_device_id
async def store_dehydrated_device(
self, user_id: str, device_id: str, device_data: JsonDict
) -> Optional[str]:
"""Store a dehydrated device for a user.
Args:
user_id: the user that we are storing the device for
device_id: the ID of the dehydrated device
device_data: the dehydrated device information
Returns:
device id of the user's previous dehydrated device, if any
"""
return await self.db_pool.runInteraction(
"store_dehydrated_device_txn",
self._store_dehydrated_device_txn,
user_id,
device_id,
json_encoder.encode(device_data),
)
async def remove_dehydrated_device(self, user_id: str, device_id: str) -> bool:
"""Remove a dehydrated device.
Args:
user_id: the user that the dehydrated device belongs to
device_id: the ID of the dehydrated device
"""
count = await self.db_pool.simple_delete(
"dehydrated_devices",
{"user_id": user_id, "device_id": device_id},
desc="remove_dehydrated_device",
)
return count >= 1
class DeviceBackgroundUpdateStore(SQLBaseStore): class DeviceBackgroundUpdateStore(SQLBaseStore):
def __init__(self, database: DatabasePool, db_conn, hs): def __init__(self, database: DatabasePool, db_conn, hs):

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2015, 2016 OpenMarket Ltd # Copyright 2015, 2016 OpenMarket Ltd
# Copyright 2019 New Vector Ltd # Copyright 2019 New Vector Ltd
# Copyright 2019 The Matrix.org Foundation C.I.C. # Copyright 2019,2020 The Matrix.org Foundation C.I.C.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -844,6 +844,11 @@ class EndToEndKeyStore(EndToEndKeyWorkerStore, SQLBaseStore):
self._invalidate_cache_and_stream( self._invalidate_cache_and_stream(
txn, self.count_e2e_one_time_keys, (user_id, device_id) txn, self.count_e2e_one_time_keys, (user_id, device_id)
) )
self.db_pool.simple_delete_txn(
txn,
table="dehydrated_devices",
keyvalues={"user_id": user_id, "device_id": device_id},
)
self.db_pool.simple_delete_txn( self.db_pool.simple_delete_txn(
txn, txn,
table="e2e_fallback_keys_json", table="e2e_fallback_keys_json",

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2014-2016 OpenMarket Ltd # Copyright 2014-2016 OpenMarket Ltd
# Copyright 2017-2018 New Vector Ltd # Copyright 2017-2018 New Vector Ltd
# Copyright 2019 The Matrix.org Foundation C.I.C. # Copyright 2019,2020 The Matrix.org Foundation C.I.C.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -964,6 +964,36 @@ class RegistrationStore(RegistrationBackgroundUpdateStore):
desc="add_access_token_to_user", desc="add_access_token_to_user",
) )
def _set_device_for_access_token_txn(self, txn, token: str, device_id: str) -> str:
old_device_id = self.db_pool.simple_select_one_onecol_txn(
txn, "access_tokens", {"token": token}, "device_id"
)
self.db_pool.simple_update_txn(
txn, "access_tokens", {"token": token}, {"device_id": device_id}
)
self._invalidate_cache_and_stream(txn, self.get_user_by_access_token, (token,))
return old_device_id
async def set_device_for_access_token(self, token: str, device_id: str) -> str:
"""Sets the device ID associated with an access token.
Args:
token: The access token to modify.
device_id: The new device ID.
Returns:
The old device ID associated with the access token.
"""
return await self.db_pool.runInteraction(
"set_device_for_access_token",
self._set_device_for_access_token_txn,
token,
device_id,
)
async def register_user( async def register_user(
self, self,
user_id: str, user_id: str,

View file

@ -0,0 +1,20 @@
/* Copyright 2020 The Matrix.org Foundation C.I.C
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
CREATE TABLE IF NOT EXISTS dehydrated_devices(
user_id TEXT NOT NULL PRIMARY KEY,
device_id TEXT NOT NULL,
device_data TEXT NOT NULL -- JSON-encoded client-defined data
);

View file

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2016 OpenMarket Ltd # Copyright 2016 OpenMarket Ltd
# Copyright 2018 New Vector Ltd # Copyright 2018 New Vector Ltd
# Copyright 2020 The Matrix.org Foundation C.I.C.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -224,3 +225,84 @@ class DeviceTestCase(unittest.HomeserverTestCase):
) )
) )
self.reactor.advance(1000) self.reactor.advance(1000)
class DehydrationTestCase(unittest.HomeserverTestCase):
def make_homeserver(self, reactor, clock):
hs = self.setup_test_homeserver("server", http_client=None)
self.handler = hs.get_device_handler()
self.registration = hs.get_registration_handler()
self.auth = hs.get_auth()
self.store = hs.get_datastore()
return hs
def test_dehydrate_and_rehydrate_device(self):
user_id = "@boris:dehydration"
self.get_success(self.store.register_user(user_id, "foobar"))
# First check if we can store and fetch a dehydrated device
stored_dehydrated_device_id = self.get_success(
self.handler.store_dehydrated_device(
user_id=user_id,
device_data={"device_data": {"foo": "bar"}},
initial_device_display_name="dehydrated device",
)
)
retrieved_device_id, device_data = self.get_success(
self.handler.get_dehydrated_device(user_id=user_id)
)
self.assertEqual(retrieved_device_id, stored_dehydrated_device_id)
self.assertEqual(device_data, {"device_data": {"foo": "bar"}})
# Create a new login for the user and dehydrated the device
device_id, access_token = self.get_success(
self.registration.register_device(
user_id=user_id, device_id=None, initial_display_name="new device",
)
)
# Trying to claim a nonexistent device should throw an error
self.get_failure(
self.handler.rehydrate_device(
user_id=user_id,
access_token=access_token,
device_id="not the right device ID",
),
synapse.api.errors.NotFoundError,
)
# dehydrating the right devices should succeed and change our device ID
# to the dehydrated device's ID
res = self.get_success(
self.handler.rehydrate_device(
user_id=user_id,
access_token=access_token,
device_id=retrieved_device_id,
)
)
self.assertEqual(res, {"success": True})
# make sure that our device ID has changed
user_info = self.get_success(self.auth.get_user_by_access_token(access_token))
self.assertEqual(user_info["device_id"], retrieved_device_id)
# make sure the device has the display name that was set from the login
res = self.get_success(self.handler.get_device(user_id, retrieved_device_id))
self.assertEqual(res["display_name"], "new device")
# make sure that the device ID that we were initially assigned no longer exists
self.get_failure(
self.handler.get_device(user_id, device_id),
synapse.api.errors.NotFoundError,
)
# make sure that there's no device available for dehydrating now
ret = self.get_success(self.handler.get_dehydrated_device(user_id=user_id))
self.assertIsNone(ret)