Add an Admin API to temporarily grant the ability to update an existing cross-signing key without UIA (#16634)
parent
999bd77d3a
commit
43d1aa75e8
|
@ -0,0 +1 @@
|
|||
Add an internal [Admin API endpoint](https://matrix-org.github.io/synapse/v1.97/usage/configuration/config_documentation.html#allow-replacing-master-cross-signing-key-without-user-interactive-auth) to temporarily grant the ability to update an existing cross-signing key without UIA.
|
|
@ -773,6 +773,43 @@ Note: The token will expire if the *admin* user calls `/logout/all` from any
|
|||
of their devices, but the token will *not* expire if the target user does the
|
||||
same.
|
||||
|
||||
## Allow replacing master cross-signing key without User-Interactive Auth
|
||||
|
||||
This endpoint is not intended for server administrator usage;
|
||||
we describe it here for completeness.
|
||||
|
||||
This API temporarily permits a user to replace their master cross-signing key
|
||||
without going through
|
||||
[user-interactive authentication](https://spec.matrix.org/v1.8/client-server-api/#user-interactive-authentication-api) (UIA).
|
||||
This is useful when Synapse has delegated its authentication to the
|
||||
[Matrix Authentication Service](https://github.com/matrix-org/matrix-authentication-service/);
|
||||
as Synapse cannot perform UIA is not possible in these circumstances.
|
||||
|
||||
The API is
|
||||
|
||||
```http request
|
||||
POST /_synapse/admin/v1/users/<user_id>/_allow_cross_signing_replacement_without_uia
|
||||
{}
|
||||
```
|
||||
|
||||
If the user does not exist, or does exist but has no master cross-signing key,
|
||||
this will return with status code `404 Not Found`.
|
||||
|
||||
Otherwise, a response body like the following is returned, with status `200 OK`:
|
||||
|
||||
```json
|
||||
{
|
||||
"updatable_without_uia_before_ms": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
The response body is a JSON object with a single field:
|
||||
|
||||
- `updatable_without_uia_before_ms`: integer. The timestamp in milliseconds
|
||||
before which the user is permitted to replace their cross-signing key without
|
||||
going through UIA.
|
||||
|
||||
_Added in Synapse 1.97.0._
|
||||
|
||||
## User devices
|
||||
|
||||
|
|
|
@ -1450,19 +1450,25 @@ class E2eKeysHandler:
|
|||
|
||||
return desired_key_data
|
||||
|
||||
async def is_cross_signing_set_up_for_user(self, user_id: str) -> bool:
|
||||
async def check_cross_signing_setup(self, user_id: str) -> Tuple[bool, bool]:
|
||||
"""Checks if the user has cross-signing set up
|
||||
|
||||
Args:
|
||||
user_id: The user to check
|
||||
|
||||
Returns:
|
||||
True if the user has cross-signing set up, False otherwise
|
||||
Returns: a 2-tuple of booleans
|
||||
- whether the user has cross-signing set up, and
|
||||
- whether the user's master cross-signing key may be replaced without UIA.
|
||||
"""
|
||||
existing_master_key = await self.store.get_e2e_cross_signing_key(
|
||||
user_id, "master"
|
||||
)
|
||||
return existing_master_key is not None
|
||||
(
|
||||
exists,
|
||||
ts_replacable_without_uia_before,
|
||||
) = await self.store.get_master_cross_signing_key_updatable_before(user_id)
|
||||
|
||||
if ts_replacable_without_uia_before is None:
|
||||
return exists, False
|
||||
else:
|
||||
return exists, self.clock.time_msec() < ts_replacable_without_uia_before
|
||||
|
||||
|
||||
def _check_cross_signing_key(
|
||||
|
|
|
@ -88,6 +88,7 @@ from synapse.rest.admin.users import (
|
|||
UserByThreePid,
|
||||
UserMembershipRestServlet,
|
||||
UserRegisterServlet,
|
||||
UserReplaceMasterCrossSigningKeyRestServlet,
|
||||
UserRestServletV2,
|
||||
UsersRestServletV2,
|
||||
UserTokenRestServlet,
|
||||
|
@ -292,6 +293,7 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
|
|||
ListDestinationsRestServlet(hs).register(http_server)
|
||||
RoomMessagesRestServlet(hs).register(http_server)
|
||||
RoomTimestampToEventRestServlet(hs).register(http_server)
|
||||
UserReplaceMasterCrossSigningKeyRestServlet(hs).register(http_server)
|
||||
UserByExternalId(hs).register(http_server)
|
||||
UserByThreePid(hs).register(http_server)
|
||||
|
||||
|
|
|
@ -1270,6 +1270,46 @@ class AccountDataRestServlet(RestServlet):
|
|||
}
|
||||
|
||||
|
||||
class UserReplaceMasterCrossSigningKeyRestServlet(RestServlet):
|
||||
"""Allow a given user to replace their master cross-signing key without UIA.
|
||||
|
||||
This replacement is permitted for a limited period (currently 10 minutes).
|
||||
|
||||
While this is exposed via the admin API, this is intended for use by the
|
||||
Matrix Authentication Service rather than server admins.
|
||||
"""
|
||||
|
||||
PATTERNS = admin_patterns(
|
||||
"/users/(?P<user_id>[^/]*)/_allow_cross_signing_replacement_without_uia"
|
||||
)
|
||||
REPLACEMENT_PERIOD_MS = 10 * 60 * 1000 # 10 minutes
|
||||
|
||||
def __init__(self, hs: "HomeServer"):
|
||||
self._auth = hs.get_auth()
|
||||
self._store = hs.get_datastores().main
|
||||
|
||||
async def on_POST(
|
||||
self,
|
||||
request: SynapseRequest,
|
||||
user_id: str,
|
||||
) -> Tuple[int, JsonDict]:
|
||||
await assert_requester_is_admin(self._auth, request)
|
||||
|
||||
if user_id is None:
|
||||
raise NotFoundError("User not found")
|
||||
|
||||
timestamp = (
|
||||
await self._store.allow_master_cross_signing_key_replacement_without_uia(
|
||||
user_id, self.REPLACEMENT_PERIOD_MS
|
||||
)
|
||||
)
|
||||
|
||||
if timestamp is None:
|
||||
raise NotFoundError("User has no master cross-signing key")
|
||||
|
||||
return HTTPStatus.OK, {"updatable_without_uia_before_ms": timestamp}
|
||||
|
||||
|
||||
class UserByExternalId(RestServlet):
|
||||
"""Find a user based on an external ID from an auth provider"""
|
||||
|
||||
|
|
|
@ -376,9 +376,10 @@ class SigningKeyUploadServlet(RestServlet):
|
|||
user_id = requester.user.to_string()
|
||||
body = parse_json_object_from_request(request)
|
||||
|
||||
is_cross_signing_setup = (
|
||||
await self.e2e_keys_handler.is_cross_signing_set_up_for_user(user_id)
|
||||
)
|
||||
(
|
||||
is_cross_signing_setup,
|
||||
master_key_updatable_without_uia,
|
||||
) = await self.e2e_keys_handler.check_cross_signing_setup(user_id)
|
||||
|
||||
# Before MSC3967 we required UIA both when setting up cross signing for the
|
||||
# first time and when resetting the device signing key. With MSC3967 we only
|
||||
|
@ -386,9 +387,14 @@ class SigningKeyUploadServlet(RestServlet):
|
|||
# time. Because there is no UIA in MSC3861, for now we throw an error if the
|
||||
# user tries to reset the device signing key when MSC3861 is enabled, but allow
|
||||
# first-time setup.
|
||||
#
|
||||
# XXX: We now have a get-out clause by which MAS can temporarily mark the master
|
||||
# key as replaceable. It should do its own equivalent of user interactive auth
|
||||
# before doing so.
|
||||
if self.hs.config.experimental.msc3861.enabled:
|
||||
# There is no way to reset the device signing key with MSC3861
|
||||
if is_cross_signing_setup:
|
||||
# The auth service has to explicitly mark the master key as replaceable
|
||||
# without UIA to reset the device signing key with MSC3861.
|
||||
if is_cross_signing_setup and not master_key_updatable_without_uia:
|
||||
raise SynapseError(
|
||||
HTTPStatus.NOT_IMPLEMENTED,
|
||||
"Resetting cross signing keys is not yet supported with MSC3861",
|
||||
|
|
|
@ -1383,6 +1383,51 @@ class EndToEndKeyWorkerStore(EndToEndKeyBackgroundStore, CacheInvalidationWorker
|
|||
|
||||
return otk_rows
|
||||
|
||||
async def get_master_cross_signing_key_updatable_before(
|
||||
self, user_id: str
|
||||
) -> Tuple[bool, Optional[int]]:
|
||||
"""Get time before which a master cross-signing key may be replaced without UIA.
|
||||
|
||||
(UIA means "User-Interactive Auth".)
|
||||
|
||||
There are three cases to distinguish:
|
||||
(1) No master cross-signing key.
|
||||
(2) The key exists, but there is no replace-without-UI timestamp in the DB.
|
||||
(3) The key exists, and has such a timestamp recorded.
|
||||
|
||||
Returns: a 2-tuple of:
|
||||
- a boolean: is there a master cross-signing key already?
|
||||
- an optional timestamp, directly taken from the DB.
|
||||
|
||||
In terms of the cases above, these are:
|
||||
(1) (False, None).
|
||||
(2) (True, None).
|
||||
(3) (True, <timestamp in ms>).
|
||||
|
||||
"""
|
||||
|
||||
def impl(txn: LoggingTransaction) -> Tuple[bool, Optional[int]]:
|
||||
# We want to distinguish between three cases:
|
||||
txn.execute(
|
||||
"""
|
||||
SELECT updatable_without_uia_before_ms
|
||||
FROM e2e_cross_signing_keys
|
||||
WHERE user_id = ? AND keytype = 'master'
|
||||
ORDER BY stream_id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
row = cast(Optional[Tuple[Optional[int]]], txn.fetchone())
|
||||
if row is None:
|
||||
return False, None
|
||||
return True, row[0]
|
||||
|
||||
return await self.db_pool.runInteraction(
|
||||
"e2e_cross_signing_keys",
|
||||
impl,
|
||||
)
|
||||
|
||||
|
||||
class EndToEndKeyStore(EndToEndKeyWorkerStore, SQLBaseStore):
|
||||
def __init__(
|
||||
|
@ -1630,3 +1675,42 @@ class EndToEndKeyStore(EndToEndKeyWorkerStore, SQLBaseStore):
|
|||
],
|
||||
desc="add_e2e_signing_key",
|
||||
)
|
||||
|
||||
async def allow_master_cross_signing_key_replacement_without_uia(
|
||||
self, user_id: str, duration_ms: int
|
||||
) -> Optional[int]:
|
||||
"""Mark this user's latest master key as being replaceable without UIA.
|
||||
|
||||
Said replacement will only be permitted for a short time after calling this
|
||||
function. That time period is controlled by the duration argument.
|
||||
|
||||
Returns:
|
||||
None, if there is no such key.
|
||||
Otherwise, the timestamp before which replacement is allowed without UIA.
|
||||
"""
|
||||
timestamp = self._clock.time_msec() + duration_ms
|
||||
|
||||
def impl(txn: LoggingTransaction) -> Optional[int]:
|
||||
txn.execute(
|
||||
"""
|
||||
UPDATE e2e_cross_signing_keys
|
||||
SET updatable_without_uia_before_ms = ?
|
||||
WHERE stream_id = (
|
||||
SELECT stream_id
|
||||
FROM e2e_cross_signing_keys
|
||||
WHERE user_id = ? AND keytype = 'master'
|
||||
ORDER BY stream_id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
""",
|
||||
(timestamp, user_id),
|
||||
)
|
||||
if txn.rowcount == 0:
|
||||
return None
|
||||
|
||||
return timestamp
|
||||
|
||||
return await self.db_pool.runInteraction(
|
||||
"allow_master_cross_signing_key_replacement_without_uia",
|
||||
impl,
|
||||
)
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
/* Copyright 2023 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.
|
||||
*/
|
||||
ALTER TABLE e2e_cross_signing_keys ADD COLUMN updatable_without_uia_before_ms bigint DEFAULT NULL;
|
|
@ -1602,3 +1602,50 @@ class E2eKeysHandlerTestCase(unittest.HomeserverTestCase):
|
|||
}
|
||||
},
|
||||
)
|
||||
|
||||
def test_check_cross_signing_setup(self) -> None:
|
||||
# First check what happens with no master key.
|
||||
alice = "@alice:test"
|
||||
exists, replaceable_without_uia = self.get_success(
|
||||
self.handler.check_cross_signing_setup(alice)
|
||||
)
|
||||
self.assertIs(exists, False)
|
||||
self.assertIs(replaceable_without_uia, False)
|
||||
|
||||
# Upload a master key but don't specify a replacement timestamp.
|
||||
dummy_key = {"keys": {"a": "b"}}
|
||||
self.get_success(
|
||||
self.store.set_e2e_cross_signing_key("@alice:test", "master", dummy_key)
|
||||
)
|
||||
|
||||
# Should now find the key exists.
|
||||
exists, replaceable_without_uia = self.get_success(
|
||||
self.handler.check_cross_signing_setup(alice)
|
||||
)
|
||||
self.assertIs(exists, True)
|
||||
self.assertIs(replaceable_without_uia, False)
|
||||
|
||||
# Set an expiry timestamp in the future.
|
||||
self.get_success(
|
||||
self.store.allow_master_cross_signing_key_replacement_without_uia(
|
||||
alice,
|
||||
1000,
|
||||
)
|
||||
)
|
||||
|
||||
# Should now be allowed to replace the key without UIA.
|
||||
exists, replaceable_without_uia = self.get_success(
|
||||
self.handler.check_cross_signing_setup(alice)
|
||||
)
|
||||
self.assertIs(exists, True)
|
||||
self.assertIs(replaceable_without_uia, True)
|
||||
|
||||
# Wait 2 seconds, so that the timestamp is in the past.
|
||||
self.reactor.advance(2.0)
|
||||
|
||||
# Should no longer be allowed to replace the key without UIA.
|
||||
exists, replaceable_without_uia = self.get_success(
|
||||
self.handler.check_cross_signing_setup(alice)
|
||||
)
|
||||
self.assertIs(exists, True)
|
||||
self.assertIs(replaceable_without_uia, False)
|
||||
|
|
|
@ -4854,3 +4854,59 @@ class UsersByThreePidTestCase(unittest.HomeserverTestCase):
|
|||
{"user_id": self.other_user},
|
||||
channel.json_body,
|
||||
)
|
||||
|
||||
|
||||
class AllowCrossSigningReplacementTestCase(unittest.HomeserverTestCase):
|
||||
servlets = [
|
||||
synapse.rest.admin.register_servlets,
|
||||
login.register_servlets,
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def url(user: str) -> str:
|
||||
template = (
|
||||
"/_synapse/admin/v1/users/{}/_allow_cross_signing_replacement_without_uia"
|
||||
)
|
||||
return template.format(urllib.parse.quote(user))
|
||||
|
||||
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
||||
self.store = hs.get_datastores().main
|
||||
|
||||
self.admin_user = self.register_user("admin", "pass", admin=True)
|
||||
self.admin_user_tok = self.login("admin", "pass")
|
||||
|
||||
self.other_user = self.register_user("user", "pass")
|
||||
|
||||
def test_error_cases(self) -> None:
|
||||
fake_user = "@bums:other"
|
||||
channel = self.make_request(
|
||||
"POST", self.url(fake_user), access_token=self.admin_user_tok
|
||||
)
|
||||
# Fail: user doesn't exist
|
||||
self.assertEqual(404, channel.code, msg=channel.json_body)
|
||||
|
||||
channel = self.make_request(
|
||||
"POST", self.url(self.other_user), access_token=self.admin_user_tok
|
||||
)
|
||||
# Fail: user exists, but has no master cross-signing key
|
||||
self.assertEqual(404, channel.code, msg=channel.json_body)
|
||||
|
||||
def test_success(self) -> None:
|
||||
# Upload a master key.
|
||||
dummy_key = {"keys": {"a": "b"}}
|
||||
self.get_success(
|
||||
self.store.set_e2e_cross_signing_key(self.other_user, "master", dummy_key)
|
||||
)
|
||||
|
||||
channel = self.make_request(
|
||||
"POST", self.url(self.other_user), access_token=self.admin_user_tok
|
||||
)
|
||||
# Success!
|
||||
self.assertEqual(200, channel.code, msg=channel.json_body)
|
||||
|
||||
# Should now find that the key exists.
|
||||
_, timestamp = self.get_success(
|
||||
self.store.get_master_cross_signing_key_updatable_before(self.other_user)
|
||||
)
|
||||
assert timestamp is not None
|
||||
self.assertGreater(timestamp, self.clock.time_msec())
|
||||
|
|
|
@ -11,8 +11,9 @@
|
|||
# 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
|
||||
|
||||
import urllib.parse
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import patch
|
||||
|
||||
from signedjson.key import (
|
||||
encode_verify_key_base64,
|
||||
|
@ -24,12 +25,19 @@ from signedjson.sign import sign_json
|
|||
from synapse.api.errors import Codes
|
||||
from synapse.rest import admin
|
||||
from synapse.rest.client import keys, login
|
||||
from synapse.types import JsonDict
|
||||
from synapse.types import JsonDict, Requester, create_requester
|
||||
|
||||
from tests import unittest
|
||||
from tests.http.server._base import make_request_with_cancellation_test
|
||||
from tests.unittest import override_config
|
||||
|
||||
try:
|
||||
import authlib # noqa: F401
|
||||
|
||||
HAS_AUTHLIB = True
|
||||
except ImportError:
|
||||
HAS_AUTHLIB = False
|
||||
|
||||
|
||||
class KeyQueryTestCase(unittest.HomeserverTestCase):
|
||||
servlets = [
|
||||
|
@ -259,3 +267,179 @@ class KeyQueryTestCase(unittest.HomeserverTestCase):
|
|||
alice_token,
|
||||
)
|
||||
self.assertEqual(channel.code, HTTPStatus.OK, channel.result)
|
||||
|
||||
|
||||
class SigningKeyUploadServletTestCase(unittest.HomeserverTestCase):
|
||||
servlets = [
|
||||
admin.register_servlets,
|
||||
keys.register_servlets,
|
||||
]
|
||||
|
||||
OIDC_ADMIN_TOKEN = "_oidc_admin_token"
|
||||
|
||||
@unittest.skip_unless(HAS_AUTHLIB, "requires authlib")
|
||||
@override_config(
|
||||
{
|
||||
"enable_registration": False,
|
||||
"experimental_features": {
|
||||
"msc3861": {
|
||||
"enabled": True,
|
||||
"issuer": "https://issuer",
|
||||
"account_management_url": "https://my-account.issuer",
|
||||
"client_id": "id",
|
||||
"client_auth_method": "client_secret_post",
|
||||
"client_secret": "secret",
|
||||
"admin_token": OIDC_ADMIN_TOKEN,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
def test_master_cross_signing_key_replacement_msc3861(self) -> None:
|
||||
# Provision a user like MAS would, cribbing from
|
||||
# https://github.com/matrix-org/matrix-authentication-service/blob/08d46a79a4adb22819ac9d55e15f8375dfe2c5c7/crates/matrix-synapse/src/lib.rs#L224-L229
|
||||
alice = "@alice:test"
|
||||
channel = self.make_request(
|
||||
"PUT",
|
||||
f"/_synapse/admin/v2/users/{urllib.parse.quote(alice)}",
|
||||
access_token=self.OIDC_ADMIN_TOKEN,
|
||||
content={},
|
||||
)
|
||||
self.assertEqual(channel.code, HTTPStatus.CREATED, channel.json_body)
|
||||
|
||||
# Provision a device like MAS would, cribbing from
|
||||
# https://github.com/matrix-org/matrix-authentication-service/blob/08d46a79a4adb22819ac9d55e15f8375dfe2c5c7/crates/matrix-synapse/src/lib.rs#L260-L262
|
||||
alice_device = "alice_device"
|
||||
channel = self.make_request(
|
||||
"POST",
|
||||
f"/_synapse/admin/v2/users/{urllib.parse.quote(alice)}/devices",
|
||||
access_token=self.OIDC_ADMIN_TOKEN,
|
||||
content={"device_id": alice_device},
|
||||
)
|
||||
self.assertEqual(channel.code, HTTPStatus.CREATED, channel.json_body)
|
||||
|
||||
# Prepare a mock MAS access token.
|
||||
alice_token = "alice_token_1234_oidcwhatyoudidthere"
|
||||
|
||||
async def mocked_get_user_by_access_token(
|
||||
token: str, allow_expired: bool = False
|
||||
) -> Requester:
|
||||
self.assertEqual(token, alice_token)
|
||||
return create_requester(
|
||||
user_id=alice,
|
||||
device_id=alice_device,
|
||||
scope=[],
|
||||
is_guest=False,
|
||||
)
|
||||
|
||||
patch_get_user_by_access_token = patch.object(
|
||||
self.hs.get_auth(),
|
||||
"get_user_by_access_token",
|
||||
wraps=mocked_get_user_by_access_token,
|
||||
)
|
||||
|
||||
# Copied from E2eKeysHandlerTestCase
|
||||
master_pubkey = "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk"
|
||||
master_pubkey2 = "fHZ3NPiKxoLQm5OoZbKa99SYxprOjNs4TwJUKP+twCM"
|
||||
master_pubkey3 = "85T7JXPFBAySB/jwby4S3lBPTqY3+Zg53nYuGmu1ggY"
|
||||
|
||||
master_key: JsonDict = {
|
||||
"user_id": alice,
|
||||
"usage": ["master"],
|
||||
"keys": {"ed25519:" + master_pubkey: master_pubkey},
|
||||
}
|
||||
master_key2: JsonDict = {
|
||||
"user_id": alice,
|
||||
"usage": ["master"],
|
||||
"keys": {"ed25519:" + master_pubkey2: master_pubkey2},
|
||||
}
|
||||
master_key3: JsonDict = {
|
||||
"user_id": alice,
|
||||
"usage": ["master"],
|
||||
"keys": {"ed25519:" + master_pubkey3: master_pubkey3},
|
||||
}
|
||||
|
||||
with patch_get_user_by_access_token:
|
||||
# Upload an initial cross-signing key.
|
||||
channel = self.make_request(
|
||||
"POST",
|
||||
"/_matrix/client/v3/keys/device_signing/upload",
|
||||
access_token=alice_token,
|
||||
content={
|
||||
"master_key": master_key,
|
||||
},
|
||||
)
|
||||
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
|
||||
|
||||
# Should not be able to upload another master key.
|
||||
channel = self.make_request(
|
||||
"POST",
|
||||
"/_matrix/client/v3/keys/device_signing/upload",
|
||||
access_token=alice_token,
|
||||
content={
|
||||
"master_key": master_key2,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
channel.code, HTTPStatus.NOT_IMPLEMENTED, channel.json_body
|
||||
)
|
||||
|
||||
# Pretend that MAS did UIA and allowed us to replace the master key.
|
||||
channel = self.make_request(
|
||||
"POST",
|
||||
f"/_synapse/admin/v1/users/{urllib.parse.quote(alice)}/_allow_cross_signing_replacement_without_uia",
|
||||
access_token=self.OIDC_ADMIN_TOKEN,
|
||||
)
|
||||
self.assertEqual(HTTPStatus.OK, channel.code, msg=channel.json_body)
|
||||
|
||||
with patch_get_user_by_access_token:
|
||||
# Should now be able to upload master key2.
|
||||
channel = self.make_request(
|
||||
"POST",
|
||||
"/_matrix/client/v3/keys/device_signing/upload",
|
||||
access_token=alice_token,
|
||||
content={
|
||||
"master_key": master_key2,
|
||||
},
|
||||
)
|
||||
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
|
||||
|
||||
# Even though we're still in the grace period, we shouldn't be able to
|
||||
# upload master key 3 immediately after uploading key 2.
|
||||
channel = self.make_request(
|
||||
"POST",
|
||||
"/_matrix/client/v3/keys/device_signing/upload",
|
||||
access_token=alice_token,
|
||||
content={
|
||||
"master_key": master_key3,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
channel.code, HTTPStatus.NOT_IMPLEMENTED, channel.json_body
|
||||
)
|
||||
|
||||
# Pretend that MAS did UIA and allowed us to replace the master key.
|
||||
channel = self.make_request(
|
||||
"POST",
|
||||
f"/_synapse/admin/v1/users/{urllib.parse.quote(alice)}/_allow_cross_signing_replacement_without_uia",
|
||||
access_token=self.OIDC_ADMIN_TOKEN,
|
||||
)
|
||||
self.assertEqual(HTTPStatus.OK, channel.code, msg=channel.json_body)
|
||||
timestamp_ms = channel.json_body["updatable_without_uia_before_ms"]
|
||||
|
||||
# Advance to 1 second after the replacement period ends.
|
||||
self.reactor.advance(timestamp_ms - self.clock.time_msec() + 1000)
|
||||
|
||||
with patch_get_user_by_access_token:
|
||||
# We should not be able to upload master key3 because the replacement has
|
||||
# expired.
|
||||
channel = self.make_request(
|
||||
"POST",
|
||||
"/_matrix/client/v3/keys/device_signing/upload",
|
||||
access_token=alice_token,
|
||||
content={
|
||||
"master_key": master_key3,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
channel.code, HTTPStatus.NOT_IMPLEMENTED, channel.json_body
|
||||
)
|
||||
|
|
|
@ -0,0 +1,121 @@
|
|||
# Copyright 2023 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.
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from twisted.test.proto_helpers import MemoryReactor
|
||||
|
||||
from synapse.server import HomeServer
|
||||
from synapse.storage._base import db_to_json
|
||||
from synapse.storage.database import LoggingTransaction
|
||||
from synapse.types import JsonDict
|
||||
from synapse.util import Clock
|
||||
|
||||
from tests.unittest import HomeserverTestCase
|
||||
|
||||
|
||||
class EndToEndKeyWorkerStoreTestCase(HomeserverTestCase):
|
||||
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
||||
self.store = hs.get_datastores().main
|
||||
|
||||
def test_get_master_cross_signing_key_updatable_before(self) -> None:
|
||||
# Should return False, None when there is no master key.
|
||||
alice = "@alice:test"
|
||||
exists, timestamp = self.get_success(
|
||||
self.store.get_master_cross_signing_key_updatable_before(alice)
|
||||
)
|
||||
self.assertIs(exists, False)
|
||||
self.assertIsNone(timestamp)
|
||||
|
||||
# Upload a master key.
|
||||
dummy_key = {"keys": {"a": "b"}}
|
||||
self.get_success(
|
||||
self.store.set_e2e_cross_signing_key(alice, "master", dummy_key)
|
||||
)
|
||||
|
||||
# Should now find that the key exists.
|
||||
exists, timestamp = self.get_success(
|
||||
self.store.get_master_cross_signing_key_updatable_before(alice)
|
||||
)
|
||||
self.assertIs(exists, True)
|
||||
self.assertIsNone(timestamp)
|
||||
|
||||
# Write an updateable_before timestamp.
|
||||
written_timestamp = self.get_success(
|
||||
self.store.allow_master_cross_signing_key_replacement_without_uia(
|
||||
alice, 1000
|
||||
)
|
||||
)
|
||||
|
||||
# Should now find that the key exists.
|
||||
exists, timestamp = self.get_success(
|
||||
self.store.get_master_cross_signing_key_updatable_before(alice)
|
||||
)
|
||||
self.assertIs(exists, True)
|
||||
self.assertEqual(timestamp, written_timestamp)
|
||||
|
||||
def test_master_replacement_only_applies_to_latest_master_key(
|
||||
self,
|
||||
) -> None:
|
||||
"""We shouldn't allow updates w/o UIA to old master keys or other key types."""
|
||||
alice = "@alice:test"
|
||||
# Upload two master keys.
|
||||
key1 = {"keys": {"a": "b"}}
|
||||
key2 = {"keys": {"c": "d"}}
|
||||
key3 = {"keys": {"e": "f"}}
|
||||
self.get_success(self.store.set_e2e_cross_signing_key(alice, "master", key1))
|
||||
self.get_success(self.store.set_e2e_cross_signing_key(alice, "other", key2))
|
||||
self.get_success(self.store.set_e2e_cross_signing_key(alice, "master", key3))
|
||||
|
||||
# Third key should be the current one.
|
||||
key = self.get_success(
|
||||
self.store.get_e2e_cross_signing_key(alice, "master", alice)
|
||||
)
|
||||
self.assertEqual(key, key3)
|
||||
|
||||
timestamp = self.get_success(
|
||||
self.store.allow_master_cross_signing_key_replacement_without_uia(
|
||||
alice, 1000
|
||||
)
|
||||
)
|
||||
assert timestamp is not None
|
||||
|
||||
def check_timestamp_column(
|
||||
txn: LoggingTransaction,
|
||||
) -> List[Tuple[JsonDict, Optional[int]]]:
|
||||
"""Fetch all rows for Alice's keys."""
|
||||
txn.execute(
|
||||
"""
|
||||
SELECT keydata, updatable_without_uia_before_ms
|
||||
FROM e2e_cross_signing_keys
|
||||
WHERE user_id = ?
|
||||
ORDER BY stream_id ASC;
|
||||
""",
|
||||
(alice,),
|
||||
)
|
||||
return [(db_to_json(keydata), ts) for keydata, ts in txn.fetchall()]
|
||||
|
||||
values = self.get_success(
|
||||
self.store.db_pool.runInteraction(
|
||||
"check_timestamp_column",
|
||||
check_timestamp_column,
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
values,
|
||||
[
|
||||
(key1, None),
|
||||
(key2, None),
|
||||
(key3, timestamp),
|
||||
],
|
||||
)
|
Loading…
Reference in New Issue