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

Add an Admin API to temporarily grant the ability to update an existing cross-signing key without UIA #16634

Merged
merged 12 commits into from
Nov 15, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/16634.misc
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 37 additions & 0 deletions docs/admin_api/user_admin_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +778 to +779
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wasn't sure if I should omit this. But I thought it might be best to err on the side of transparency.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm happy to keep this documented, it's clear enough that as an admin you shouldn't use that


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

Expand Down
20 changes: 13 additions & 7 deletions synapse/handlers/e2e_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -1450,19 +1450,25 @@ async def _retrieve_cross_signing_keys_for_remote_user(

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.
Comment on lines +1459 to +1461
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit naughty, since there aren't really 4 states here. But I think this makes things clearer at the call site.

"""
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(
Expand Down
2 changes: 2 additions & 0 deletions synapse/rest/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
UsersRestServletV2,
UserTokenRestServlet,
WhoisRestServlet,
UserReplaceMasterCrossSigningKeyRestServlet,
)
from synapse.types import JsonDict, RoomStreamToken, TaskStatus
from synapse.util import SYNAPSE_VERSION
Expand Down Expand Up @@ -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)

Expand Down
40 changes: 40 additions & 0 deletions synapse/rest/admin/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -1270,6 +1270,46 @@ async def on_GET(
}


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"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sandhose and I wondered if this should come with warnings e.g. "This is an internal endpoint, we reserve the right to break this". That's what the _ in _allow means.

)
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"""

Expand Down
16 changes: 11 additions & 5 deletions synapse/rest/client/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,19 +376,25 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
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
# require UIA when resetting cross-signing, and not when setting up the first
# 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",
Expand Down
77 changes: 77 additions & 0 deletions synapse/storage/databases/main/end_to_end_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -1383,6 +1383,51 @@ def _claim_e2e_one_time_keys_bulk(

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__(
Expand Down Expand Up @@ -1630,3 +1675,35 @@ async def store_e2e_cross_signing_signatures(
],
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
sandhose marked this conversation as resolved.
Show resolved Hide resolved
SET updatable_without_uia_before_ms = ?
WHERE user_id = ? AND keytype = 'master'
""",
(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,
)
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did think about adding a check constraint to enforce that only master keys may have this column set to a non-null value. But then I'd have to think about adding a background update to validate the check in the background; it didn't seem worth it.

sandhose marked this conversation as resolved.
Show resolved Hide resolved
47 changes: 47 additions & 0 deletions tests/handlers/test_e2e_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -1602,3 +1602,50 @@ def test_query_local_devices_appservice(self) -> None:
}
},
)

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)
sandhose marked this conversation as resolved.
Show resolved Hide resolved

# 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)
Loading
Loading