diff --git a/changelog.d/11538.feature b/changelog.d/11538.feature
new file mode 100644
index 0000000000..b6229e2b45
--- /dev/null
+++ b/changelog.d/11538.feature
@@ -0,0 +1 @@
+Add experimental support for MSC3202: allowing application services to masquerade as specific devices.
\ No newline at end of file
diff --git a/synapse/api/auth.py b/synapse/api/auth.py
index 44883c6663..0bf58dff08 100644
--- a/synapse/api/auth.py
+++ b/synapse/api/auth.py
@@ -155,7 +155,11 @@ class Auth:
access_token = self.get_access_token_from_request(request)
- user_id, app_service = await self._get_appservice_user_id(request)
+ (
+ user_id,
+ device_id,
+ app_service,
+ ) = await self._get_appservice_user_id_and_device_id(request)
if user_id and app_service:
if ip_addr and self._track_appservice_user_ips:
await self.store.insert_client_ip(
@@ -163,16 +167,22 @@ class Auth:
access_token=access_token,
ip=ip_addr,
user_agent=user_agent,
- device_id="dummy-device", # stubbed
+ device_id="dummy-device"
+ if device_id is None
+ else device_id, # stubbed
)
- requester = create_requester(user_id, app_service=app_service)
+ requester = create_requester(
+ user_id, app_service=app_service, device_id=device_id
+ )
request.requester = user_id
if user_id in self._force_tracing_for_users:
opentracing.force_tracing()
opentracing.set_tag("authenticated_entity", user_id)
opentracing.set_tag("user_id", user_id)
+ if device_id is not None:
+ opentracing.set_tag("device_id", device_id)
opentracing.set_tag("appservice_id", app_service.id)
return requester
@@ -274,33 +284,81 @@ class Auth:
403, "Application service has not registered this user (%s)" % user_id
)
- async def _get_appservice_user_id(
+ async def _get_appservice_user_id_and_device_id(
self, request: Request
- ) -> Tuple[Optional[str], Optional[ApplicationService]]:
+ ) -> Tuple[Optional[str], Optional[str], Optional[ApplicationService]]:
+ """
+ Given a request, reads the request parameters to determine:
+ - whether it's an application service that's making this request
+ - what user the application service should be treated as controlling
+ (the user_id URI parameter allows an application service to masquerade
+ any applicable user in its namespace)
+ - what device the application service should be treated as controlling
+ (the device_id[^1] URI parameter allows an application service to masquerade
+ as any device that exists for the relevant user)
+
+ [^1] Unstable and provided by MSC3202.
+ Must use `org.matrix.msc3202.device_id` in place of `device_id` for now.
+
+ Returns:
+ 3-tuple of
+ (user ID?, device ID?, application service?)
+
+ Postconditions:
+ - If an application service is returned, so is a user ID
+ - A user ID is never returned without an application service
+ - A device ID is never returned without a user ID or an application service
+ - The returned application service, if present, is permitted to control the
+ returned user ID.
+ - The returned device ID, if present, has been checked to be a valid device ID
+ for the returned user ID.
+ """
+ DEVICE_ID_ARG_NAME = b"org.matrix.msc3202.device_id"
+
app_service = self.store.get_app_service_by_token(
self.get_access_token_from_request(request)
)
if app_service is None:
- return None, None
+ return None, None, None
if app_service.ip_range_whitelist:
ip_address = IPAddress(request.getClientIP())
if ip_address not in app_service.ip_range_whitelist:
- return None, None
+ return None, None, None
# This will always be set by the time Twisted calls us.
assert request.args is not None
- if b"user_id" not in request.args:
- return app_service.sender, app_service
+ if b"user_id" in request.args:
+ effective_user_id = request.args[b"user_id"][0].decode("utf8")
+ await self.validate_appservice_can_control_user_id(
+ app_service, effective_user_id
+ )
+ else:
+ effective_user_id = app_service.sender
- user_id = request.args[b"user_id"][0].decode("utf8")
- await self.validate_appservice_can_control_user_id(app_service, user_id)
+ effective_device_id: Optional[str] = None
- if app_service.sender == user_id:
- return app_service.sender, app_service
+ if (
+ self.hs.config.experimental.msc3202_device_masquerading_enabled
+ and DEVICE_ID_ARG_NAME in request.args
+ ):
+ effective_device_id = request.args[DEVICE_ID_ARG_NAME][0].decode("utf8")
+ # We only just set this so it can't be None!
+ assert effective_device_id is not None
+ device_opt = await self.store.get_device(
+ effective_user_id, effective_device_id
+ )
+ if device_opt is None:
+ # For now, use 400 M_EXCLUSIVE if the device doesn't exist.
+ # This is an open thread of discussion on MSC3202 as of 2021-12-09.
+ raise AuthError(
+ 400,
+ f"Application service trying to use a device that doesn't exist ('{effective_device_id}' for {effective_user_id})",
+ Codes.EXCLUSIVE,
+ )
- return user_id, app_service
+ return effective_user_id, effective_device_id, app_service
async def get_user_by_access_token(
self,
diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py
index d78a15097c..678c78d565 100644
--- a/synapse/config/experimental.py
+++ b/synapse/config/experimental.py
@@ -49,3 +49,8 @@ class ExperimentalConfig(Config):
# MSC3030 (Jump to date API endpoint)
self.msc3030_enabled: bool = experimental.get("msc3030_enabled", False)
+
+ # The portion of MSC3202 which is related to device masquerading.
+ self.msc3202_device_masquerading_enabled: bool = experimental.get(
+ "msc3202_device_masquerading", False
+ )
diff --git a/synapse/storage/databases/main/devices.py b/synapse/storage/databases/main/devices.py
index 3932599988..273adb61fd 100644
--- a/synapse/storage/databases/main/devices.py
+++ b/synapse/storage/databases/main/devices.py
@@ -128,6 +128,26 @@ class DeviceWorkerStore(SQLBaseStore):
allow_none=True,
)
+ async def get_device_opt(
+ self, user_id: str, device_id: str
+ ) -> Optional[Dict[str, Any]]:
+ """Retrieve a device. Only returns devices that are not marked as
+ hidden.
+
+ Args:
+ user_id: The ID of the user which owns the device
+ device_id: The ID of the device to retrieve
+ Returns:
+ A dict containing the device information, or None if the device does not exist.
+ """
+ return await self.db_pool.simple_select_one(
+ table="devices",
+ keyvalues={"user_id": user_id, "device_id": device_id, "hidden": False},
+ retcols=("user_id", "device_id", "display_name"),
+ desc="get_device",
+ allow_none=True,
+ )
+
async def get_devices_by_user(self, user_id: str) -> Dict[str, Dict[str, str]]:
"""Retrieve all of a user's registered devices. Only returns devices
that are not marked as hidden.
diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py
index 3aa9ba3c43..a2dfa1ed05 100644
--- a/tests/api/test_auth.py
+++ b/tests/api/test_auth.py
@@ -31,6 +31,7 @@ from synapse.types import Requester
from tests import unittest
from tests.test_utils import simple_async_mock
+from tests.unittest import override_config
from tests.utils import mock_getRawHeaders
@@ -210,6 +211,69 @@ class AuthTestCase(unittest.HomeserverTestCase):
request.requestHeaders.getRawHeaders = mock_getRawHeaders()
self.get_failure(self.auth.get_user_by_req(request), AuthError)
+ @override_config({"experimental_features": {"msc3202_device_masquerading": True}})
+ def test_get_user_by_req_appservice_valid_token_valid_device_id(self):
+ """
+ Tests that when an application service passes the device_id URL parameter
+ with the ID of a valid device for the user in question,
+ the requester instance tracks that device ID.
+ """
+ masquerading_user_id = b"@doppelganger:matrix.org"
+ masquerading_device_id = b"DOPPELDEVICE"
+ app_service = Mock(
+ token="foobar", url="a_url", sender=self.test_user, ip_range_whitelist=None
+ )
+ app_service.is_interested_in_user = Mock(return_value=True)
+ self.store.get_app_service_by_token = Mock(return_value=app_service)
+ # This just needs to return a truth-y value.
+ self.store.get_user_by_id = simple_async_mock({"is_guest": False})
+ self.store.get_user_by_access_token = simple_async_mock(None)
+ # This also needs to just return a truth-y value
+ self.store.get_device = simple_async_mock({"hidden": False})
+
+ request = Mock(args={})
+ request.getClientIP.return_value = "127.0.0.1"
+ request.args[b"access_token"] = [self.test_token]
+ request.args[b"user_id"] = [masquerading_user_id]
+ request.args[b"org.matrix.msc3202.device_id"] = [masquerading_device_id]
+ request.requestHeaders.getRawHeaders = mock_getRawHeaders()
+ requester = self.get_success(self.auth.get_user_by_req(request))
+ self.assertEquals(
+ requester.user.to_string(), masquerading_user_id.decode("utf8")
+ )
+ self.assertEquals(requester.device_id, masquerading_device_id.decode("utf8"))
+
+ @override_config({"experimental_features": {"msc3202_device_masquerading": True}})
+ def test_get_user_by_req_appservice_valid_token_invalid_device_id(self):
+ """
+ Tests that when an application service passes the device_id URL parameter
+ with an ID that is not a valid device ID for the user in question,
+ the request fails with the appropriate error code.
+ """
+ masquerading_user_id = b"@doppelganger:matrix.org"
+ masquerading_device_id = b"NOT_A_REAL_DEVICE_ID"
+ app_service = Mock(
+ token="foobar", url="a_url", sender=self.test_user, ip_range_whitelist=None
+ )
+ app_service.is_interested_in_user = Mock(return_value=True)
+ self.store.get_app_service_by_token = Mock(return_value=app_service)
+ # This just needs to return a truth-y value.
+ self.store.get_user_by_id = simple_async_mock({"is_guest": False})
+ self.store.get_user_by_access_token = simple_async_mock(None)
+ # This also needs to just return a falsey value
+ self.store.get_device = simple_async_mock(None)
+
+ request = Mock(args={})
+ request.getClientIP.return_value = "127.0.0.1"
+ request.args[b"access_token"] = [self.test_token]
+ request.args[b"user_id"] = [masquerading_user_id]
+ request.args[b"org.matrix.msc3202.device_id"] = [masquerading_device_id]
+ request.requestHeaders.getRawHeaders = mock_getRawHeaders()
+
+ failure = self.get_failure(self.auth.get_user_by_req(request), AuthError)
+ self.assertEquals(failure.value.code, 400)
+ self.assertEquals(failure.value.errcode, Codes.EXCLUSIVE)
+
def test_get_user_from_macaroon(self):
self.store.get_user_by_access_token = simple_async_mock(
TokenLookupResult(user_id="@baldrick:matrix.org", device_id="device")
|