Compare commits

...

3 Commits

Author SHA1 Message Date
Kegan Dougal
b338f886d6 Use ed25519:1 as the key ID 2025-09-10 15:35:37 +01:00
Kegan Dougal
c0ffe61adb Add account_keys table to store all key<->name mappings 2025-09-09 15:19:57 +01:00
Kegan Dougal
aefeb3cb58 Add MSC4243 flag, verify event signatures from the user ID 2025-09-09 13:11:34 +01:00
9 changed files with 435 additions and 16 deletions

View File

@@ -116,6 +116,8 @@ class RoomVersion:
msc4289_creator_power_enabled: bool
# MSC4291: Room IDs as hashes of the create event
msc4291_room_ids_as_hashes: bool
# MSC4243: User ID localparts as Account Keys
msc4243_account_keys: bool
class RoomVersions:
@@ -140,6 +142,7 @@ class RoomVersions:
msc3757_enabled=False,
msc4289_creator_power_enabled=False,
msc4291_room_ids_as_hashes=False,
msc4243_account_keys=False,
)
V2 = RoomVersion(
"2",
@@ -162,6 +165,7 @@ class RoomVersions:
msc3757_enabled=False,
msc4289_creator_power_enabled=False,
msc4291_room_ids_as_hashes=False,
msc4243_account_keys=False,
)
V3 = RoomVersion(
"3",
@@ -184,6 +188,7 @@ class RoomVersions:
msc3757_enabled=False,
msc4289_creator_power_enabled=False,
msc4291_room_ids_as_hashes=False,
msc4243_account_keys=False,
)
V4 = RoomVersion(
"4",
@@ -206,6 +211,7 @@ class RoomVersions:
msc3757_enabled=False,
msc4289_creator_power_enabled=False,
msc4291_room_ids_as_hashes=False,
msc4243_account_keys=False,
)
V5 = RoomVersion(
"5",
@@ -228,6 +234,7 @@ class RoomVersions:
msc3757_enabled=False,
msc4289_creator_power_enabled=False,
msc4291_room_ids_as_hashes=False,
msc4243_account_keys=False,
)
V6 = RoomVersion(
"6",
@@ -250,6 +257,7 @@ class RoomVersions:
msc3757_enabled=False,
msc4289_creator_power_enabled=False,
msc4291_room_ids_as_hashes=False,
msc4243_account_keys=False,
)
V7 = RoomVersion(
"7",
@@ -272,6 +280,7 @@ class RoomVersions:
msc3757_enabled=False,
msc4289_creator_power_enabled=False,
msc4291_room_ids_as_hashes=False,
msc4243_account_keys=False,
)
V8 = RoomVersion(
"8",
@@ -294,6 +303,7 @@ class RoomVersions:
msc3757_enabled=False,
msc4289_creator_power_enabled=False,
msc4291_room_ids_as_hashes=False,
msc4243_account_keys=False,
)
V9 = RoomVersion(
"9",
@@ -316,6 +326,7 @@ class RoomVersions:
msc3757_enabled=False,
msc4289_creator_power_enabled=False,
msc4291_room_ids_as_hashes=False,
msc4243_account_keys=False,
)
V10 = RoomVersion(
"10",
@@ -338,6 +349,7 @@ class RoomVersions:
msc3757_enabled=False,
msc4289_creator_power_enabled=False,
msc4291_room_ids_as_hashes=False,
msc4243_account_keys=False,
)
MSC1767v10 = RoomVersion(
# MSC1767 (Extensible Events) based on room version "10"
@@ -361,6 +373,7 @@ class RoomVersions:
msc3757_enabled=False,
msc4289_creator_power_enabled=False,
msc4291_room_ids_as_hashes=False,
msc4243_account_keys=False,
)
MSC3757v10 = RoomVersion(
# MSC3757 (Restricting who can overwrite a state event) based on room version "10"
@@ -384,6 +397,7 @@ class RoomVersions:
msc3757_enabled=True,
msc4289_creator_power_enabled=False,
msc4291_room_ids_as_hashes=False,
msc4243_account_keys=False,
)
V11 = RoomVersion(
"11",
@@ -406,6 +420,7 @@ class RoomVersions:
msc3757_enabled=False,
msc4289_creator_power_enabled=False,
msc4291_room_ids_as_hashes=False,
msc4243_account_keys=False,
)
MSC3757v11 = RoomVersion(
# MSC3757 (Restricting who can overwrite a state event) based on room version "11"
@@ -429,6 +444,7 @@ class RoomVersions:
msc3757_enabled=True,
msc4289_creator_power_enabled=False,
msc4291_room_ids_as_hashes=False,
msc4243_account_keys=False,
)
HydraV11 = RoomVersion(
"org.matrix.hydra.11",
@@ -451,6 +467,7 @@ class RoomVersions:
msc3757_enabled=False,
msc4289_creator_power_enabled=True, # Changed from v11
msc4291_room_ids_as_hashes=True, # Changed from v11
msc4243_account_keys=False,
)
V12 = RoomVersion(
"12",
@@ -473,6 +490,30 @@ class RoomVersions:
msc3757_enabled=False,
msc4289_creator_power_enabled=True, # Changed from v11
msc4291_room_ids_as_hashes=True, # Changed from v11
msc4243_account_keys=False,
)
MSC4243v12 = RoomVersion(
"org.matrix.12.4243",
RoomDisposition.STABLE,
EventFormatVersions.ROOM_V11_HYDRA_PLUS,
StateResolutionVersions.V2_1, # Changed from v11
enforce_key_validity=False, # No longer enforce key validity.
special_case_aliases_auth=False,
strict_canonicaljson=True,
limit_notifications_power_levels=True,
implicit_room_creator=True, # Used by MSC3820
updated_redaction_rules=True, # Used by MSC3820
restricted_join_rule=True,
restricted_join_rule_fix=True,
knock_join_rule=True,
msc3389_relation_redactions=False,
knock_restricted_join_rule=True,
enforce_int_power_levels=True,
msc3931_push_features=(),
msc3757_enabled=False,
msc4289_creator_power_enabled=True,
msc4291_room_ids_as_hashes=True,
msc4243_account_keys=True,
)
@@ -494,6 +535,7 @@ KNOWN_ROOM_VERSIONS: Dict[str, RoomVersion] = {
RoomVersions.MSC3757v10,
RoomVersions.MSC3757v11,
RoomVersions.HydraV11,
RoomVersions.MSC4243v12,
)
}

View File

@@ -47,7 +47,7 @@ from synapse.events import EventBase
from synapse.events.utils import prune_event_dict
from synapse.logging.context import make_deferred_yieldable, run_in_background
from synapse.storage.keys import FetchKeyResult
from synapse.types import JsonDict
from synapse.types import JsonDict, UserID
from synapse.util import unwrapFirstError
from synapse.util.async_helpers import yieldable_gather_results
from synapse.util.batching_queue import BatchingQueue
@@ -83,6 +83,7 @@ class VerifyJsonRequest:
get_json_object: Callable[[], JsonDict]
minimum_valid_until_ts: int
key_ids: List[str]
key_ids_are_public_keys: bool = False
@staticmethod
def from_json_object(
@@ -118,6 +119,7 @@ class VerifyJsonRequest:
lambda: prune_event_dict(event.room_version, event.get_pdu_json()),
minimum_valid_until_ms,
key_ids=key_ids,
key_ids_are_public_keys=False,
)
@@ -265,6 +267,38 @@ class Keyring:
)
)
async def verify_event_for_account_key(
self,
user_id_str: str,
event: EventBase,
) -> None:
"""Verify that the given event has been signed by the provided account key, as determined by
the user_id provided.
Args:
user_id_str: The MSC4243 user ID, consisting of an account key localpart and a domain.
event: The PDU that should be signed with the account key. Room version must support MSC4243.
"""
assert event.room_version.msc4243_account_keys
user_id = UserID.from_string(user_id_str)
key_ids = list(event.signatures.get(user_id.domain, []))
# only keep the key ID that matches the desired user ID we want to verify as.
# Events can be signed by multiple parties e.g invites, restricted joins
expected_key_id = "ed25519:" + user_id.localpart
key_ids = [key_id for key_id in key_ids if key_id == expected_key_id]
assert len(key_ids) == 1 # the user must have signed the event.
await self.process_request(
VerifyJsonRequest(
user_id.domain,
# We defer creating the redacted json object, as it uses a lot more
# memory than the Event object itself.
lambda: prune_event_dict(event.room_version, event.get_pdu_json()),
0, # No validity times
key_ids=key_ids,
key_ids_are_public_keys=True,
)
)
async def process_request(self, verify_request: VerifyJsonRequest) -> None:
"""Processes the `VerifyJsonRequest`. Raises if the object is not signed
by the server, the signatures don't match or we failed to fetch the
@@ -278,6 +312,15 @@ class Keyring:
Codes.UNAUTHORIZED,
)
if verify_request.key_ids_are_public_keys:
# No need to fetch keys as we have them already.
assert len(verify_request.key_ids) == 1
key_id = verify_request.key_ids[0]
key_bytes = decode_base64(key_id.removeprefix("ed25519:"))
verify_key = decode_verify_key_bytes(key_id, key_bytes)
await self._process_json(verify_key, verify_request)
return
found_keys: Dict[str, FetchKeyResult] = {}
# If we are the originating server, short-circuit the key-fetch for any keys

View File

@@ -241,16 +241,28 @@ async def _check_sigs_on_pdu(
sender_domain = get_domain_from_id(pdu.sender)
if not _is_invite_via_3pid(pdu):
try:
await keyring.verify_event_for_server(
sender_domain,
pdu,
pdu.origin_server_ts if room_version.enforce_key_validity else 0,
)
if pdu.room_version.msc4243_account_keys:
await keyring.verify_event_for_account_key(
pdu.sender,
pdu,
)
else:
await keyring.verify_event_for_server(
sender_domain,
pdu,
pdu.origin_server_ts if room_version.enforce_key_validity else 0,
)
except Exception as e:
raise InvalidEventSignatureError(
f"unable to verify signature for sender domain {sender_domain}: {e}",
pdu.event_id,
) from None
if pdu.room_version.msc4243_account_keys:
raise InvalidEventSignatureError(
f"unable to verify signature for account key {pdu.sender}: {e}",
pdu.event_id,
) from None
else:
raise InvalidEventSignatureError(
f"unable to verify signature for sender domain {sender_domain}: {e}",
pdu.event_id,
) from None
# now let's look for events where the sender's domain is different to the
# event id's domain (normally only the case for joins/leaves), and add additional
@@ -283,11 +295,17 @@ async def _check_sigs_on_pdu(
pdu.content[EventContentFields.AUTHORISING_USER]
)
try:
await keyring.verify_event_for_server(
authorising_server,
pdu,
pdu.origin_server_ts if room_version.enforce_key_validity else 0,
)
if pdu.room_version.msc4243_account_keys:
await keyring.verify_event_for_account_key(
pdu.content[EventContentFields.AUTHORISING_USER],
pdu,
)
else:
await keyring.verify_event_for_server(
authorising_server,
pdu,
pdu.origin_server_ts if room_version.enforce_key_validity else 0,
)
except Exception as e:
raise InvalidEventSignatureError(
f"unable to verify signature for authorising serve {authorising_server}: {e}",

View File

@@ -32,6 +32,7 @@ from synapse.storage.database import (
LoggingDatabaseConnection,
LoggingTransaction,
)
from synapse.storage.databases.main.account_keys import AccountKeysStore
from synapse.storage.databases.main.sliding_sync import SlidingSyncStore
from synapse.storage.databases.main.stats import UserSortOrder
from synapse.storage.databases.main.thread_subscriptions import (
@@ -163,6 +164,7 @@ class DataStore(
TaskSchedulerWorkerStore,
SlidingSyncStore,
DelayedEventsStore,
AccountKeysStore,
):
def __init__(
self,

View File

@@ -0,0 +1,165 @@
#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2025 New Vector, Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
# Originally licensed under the Apache License, Version 2.0:
# <http://www.apache.org/licenses/LICENSE-2.0>.
#
# [This file includes modifications made by New Vector Limited]
#
#
from typing import TYPE_CHECKING, Collection, Dict, List, Tuple, cast
from signedjson.key import (
decode_signing_key_base64,
generate_signing_key,
get_verify_key,
)
from signedjson.types import SigningKey
from unpaddedbase64 import encode_base64
from synapse.api.errors import SynapseError
from synapse.storage._base import SQLBaseStore
from synapse.storage.database import (
DatabasePool,
LoggingDatabaseConnection,
LoggingTransaction,
make_in_list_sql_clause,
)
from synapse.types import get_domain_from_id, get_localpart_from_id
if TYPE_CHECKING:
from synapse.server import HomeServer
class AccountKeysStore(SQLBaseStore):
def __init__(
self,
database: DatabasePool,
db_conn: LoggingDatabaseConnection,
hs: "HomeServer",
):
super().__init__(database, db_conn, hs)
async def get_or_create_account_key_user_id_for_account_name_user_id(
self, account_name_user_id: str
) -> Tuple[str, SigningKey]:
"""
Get or create an account key for the given account name user ID.
The user ID must belong to this server.
Args:
account_name_user_id: An account name user ID e.g "@alice:example.com"
Returns:
A tuple of account key user ID e.g @l8Hft5qXKn1vfHrg3p4+W8gELQVo8N13JkluMfmn2sQ:example.com
and the private key for the account.
Raises:
if the provided account name user ID is not owned by this homeserver, or if the user
ID is invalid in some way.
"""
if not self.hs.is_mine_id(account_name_user_id):
raise SynapseError(
500,
(
"get_or_create_account_key_user_id_for_account_name_user_id: this server cannot"
f" create an account key for other servers: {account_name_user_id}"
),
)
row = await self.db_pool.simple_select_one(
table="account_keys",
keyvalues={
"account_name_user_id": account_name_user_id,
},
retcols=["account_key_user_id", "account_key"],
allow_none=True,
desc="get_or_create_account_key_user_id_for_account_name_user_id.get_key_txn",
)
if row is not None:
return row[0], decode_account_key(row[1])
# create a new account key for this account inside a txn to ensure we lock correctly.
def create_key_txn(txn: LoggingTransaction) -> Tuple[str, str]:
key, public_key_str = generate_account_key()
account_key_user_id = (
f"@{public_key_str}:{get_domain_from_id(account_name_user_id)}"
)
# Race to insert the key. The first one to make it will be returned here as we don't clobber
sql = (
"INSERT INTO account_keys(account_name_user_id, account_key_user_id, account_key)"
" VALUES(?, ?, ?)"
" ON CONFLICT DO NOTHING"
)
txn.execute(
sql,
(
account_name_user_id,
account_key_user_id,
encode_base64(key.encode(), urlsafe=True),
),
)
sql = "SELECT account_key_user_id, account_key FROM account_keys WHERE account_name_user_id = ?"
txn.execute(sql, (account_name_user_id,))
return cast(Tuple[str, str], txn.fetchone())
row = await self.db_pool.runInteraction(
"get_or_create_account_key_user_id_for_account_name_user_id.create_key_txn",
create_key_txn,
)
return row[0], decode_account_key(row[1])
async def get_account_name_user_ids_for_account_key_user_ids(
self,
account_key_user_ids: Collection[str],
) -> Dict[str, str]:
"""
Fetch the verified account name user IDs for the given account key user IDs. Unknown account key
user IDs will be omitted from the dict.
Args:
account_key_user_ids: A list of user IDs in account key format e.g
["@l8Hft5qXKn1vfHrg3p4+W8gELQVo8N13JkluMfmn2sQ:example.com"]
Returns:
A map of account key user IDs to account name user IDs e.g.
{"@l8Hft5qXKn1vfHrg3p4+W8gELQVo8N13JkluMfmn2sQ:example.com":"@alice:example.com"}
"""
clause, args = make_in_list_sql_clause(
self.database_engine, "account_key_user_id", account_key_user_ids
)
def f(txn: LoggingTransaction) -> List[Tuple[str, str]]:
sql = f"SELECT account_key_user_id, account_name_user_id FROM account_keys WHERE {clause} AND account_name_user_id IS NOT NULL"
txn.execute(sql, args)
return cast(List[Tuple[str, str]], txn.fetchall())
rows = await self.db_pool.runInteraction(
"get_account_name_user_ids_for_account_key_user_ids", f
)
return {row[0]: row[1] for row in rows}
def generate_account_key() -> Tuple[SigningKey, str]:
signing_key = generate_signing_key("1")
verify_key_str = encode_base64(get_verify_key(signing_key).encode(), urlsafe=True)
return signing_key, verify_key_str
def decode_account_key(signing_key: str) -> SigningKey:
return decode_signing_key_base64(
"ed25519",
"1",
signing_key,
)

View File

@@ -19,7 +19,7 @@
#
#
SCHEMA_VERSION = 92 # remember to update the list below when updating
SCHEMA_VERSION = 93 # remember to update the list below when updating
"""Represents the expectations made by the codebase about the database schema
This should be incremented whenever the codebase changes its requirements on the

View File

@@ -0,0 +1,25 @@
--
-- This file is licensed under the Affero General Public License (AGPL) version 3.
--
-- Copyright (C) 2025 New Vector, Ltd
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as
-- published by the Free Software Foundation, either version 3 of the
-- License, or (at your option) any later version.
--
-- See the GNU Affero General Public License for more details:
-- <https://www.gnu.org/licenses/agpl-3.0.html>.
-- Keeps a record of MSC4243 account key <--> account name mappings for all servers.
-- This mapping is permanent.
CREATE TABLE account_keys (
account_key_user_id TEXT PRIMARY KEY NOT NULL,
-- nullable if we cannot talk to the remote server.
account_name_user_id TEXT,
-- the private key as urlsafe base64, only for local accounts
account_key TEXT,
UNIQUE(account_key_user_id, account_name_user_id)
);
CREATE INDEX account_keys_key_for_name ON account_keys (account_name_user_id) WHERE account_name_user_id IS NOT NULL;

View File

@@ -34,12 +34,15 @@ from twisted.internet.defer import Deferred, ensureDeferred
from twisted.internet.testing import MemoryReactor
from synapse.api.errors import SynapseError
from synapse.api.room_versions import RoomVersions
from synapse.crypto import keyring
from synapse.crypto.event_signing import compute_event_signature
from synapse.crypto.keyring import (
PerspectivesKeyFetcher,
ServerKeyFetcher,
StoreKeyFetcher,
)
from synapse.events import make_event_from_dict
from synapse.logging.context import (
ContextRequest,
LoggingContext,
@@ -388,6 +391,35 @@ class KeyringTestCase(unittest.HomeserverTestCase):
mock_fetcher1.get_keys.assert_called_once()
mock_fetcher2.get_keys.assert_called_once()
def test_verify_event_for_account_key(self) -> None:
"""Test basic functionality of verify_event_for_account_key.
- That it parses the user ID correctly.
- That it doesn't rely on key fetchers.
"""
room_version = RoomVersions.MSC4243v12
# Make a signing key and replace the key ID from '1' to be the base64 public key
signing_key = signedjson.key.generate_signing_key("1")
verify_key_str = encode_verify_key_base64(get_verify_key(signing_key))
signing_key.version = verify_key_str
domain = "can.be.anything.com"
signing_user_id = f"@{verify_key_str}:{domain}"
event_dict = {
"type": "m.room.create",
"state_key": "",
"sender": signing_user_id,
"content": {
"room_version": room_version.identifier,
},
}
event_dict["signatures"] = compute_event_signature(
room_version, event_dict, signature_name=domain, signing_key=signing_key
)
event = make_event_from_dict(event_dict, room_version)
kr = keyring.Keyring(self.hs, key_fetchers=None)
self.get_success(kr.verify_event_for_account_key(signing_user_id, event))
@logcontext_clean
class ServerKeyFetcherTestCase(unittest.HomeserverTestCase):

View File

@@ -0,0 +1,92 @@
#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2025 New Vector, Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
# Originally licensed under the Apache License, Version 2.0:
# <http://www.apache.org/licenses/LICENSE-2.0>.
#
# [This file includes modifications made by New Vector Limited]
#
#
from signedjson.key import get_verify_key
from unpaddedbase64 import encode_base64
from twisted.internet.testing import MemoryReactor
from synapse.server import HomeServer
from synapse.types import get_localpart_from_id
from synapse.util import Clock
from tests import unittest
class AccountKeysTestCase(unittest.HomeserverTestCase):
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.store = self.hs.get_datastores().main
self.user = "@user:test"
def test_get_or_create_account_key_user_id_for_account_name_user_id(self) -> None:
key_user_id, key = self.get_success(
self.store.get_or_create_account_key_user_id_for_account_name_user_id(
self.user
)
)
# asserts the localpart is unpadded urlsafe base64
self.assertRegex(key_user_id, r"^@[A-Za-z0-9\-_]{43}:test$")
# asserts the public key is the localpart
self.assertEquals(encode_base64(get_verify_key(key).encode(), urlsafe=True), get_localpart_from_id(key_user_id))
# asserts the key ID is 1
self.assertEquals(key.version, "1")
# assert that repeated calls return the same key
key_user_id2, key2 = self.get_success(
self.store.get_or_create_account_key_user_id_for_account_name_user_id(
self.user
)
)
self.assertEquals(key_user_id, key_user_id2)
self.assertEquals(key.encode(), key2.encode())
def test_get_account_name_user_ids_for_account_key_user_ids(self) -> None:
key_user_id, _ = self.get_success(
self.store.get_or_create_account_key_user_id_for_account_name_user_id(
self.user,
)
)
result = self.get_success(
self.store.get_account_name_user_ids_for_account_key_user_ids(
[key_user_id]
),
)
self.assertEquals(result[key_user_id], self.user)
def test_get_account_name_user_ids_for_account_key_user_ids_multiple(self) -> None:
key_user_id_alice, _ = self.get_success(
self.store.get_or_create_account_key_user_id_for_account_name_user_id(
"@alice:test",
)
)
key_user_id_bob, _ = self.get_success(
self.store.get_or_create_account_key_user_id_for_account_name_user_id(
"@bob:test",
)
)
key_user_id_unknown = "@6fey6W1wS3-vbvUmHZnTd6Gi3o-TIxvIcwtEQP4nrW0:test"
result = self.get_success(
self.store.get_account_name_user_ids_for_account_key_user_ids(
[key_user_id_alice, key_user_id_bob, key_user_id_unknown]
),
)
self.assertEquals(result[key_user_id_alice], "@alice:test")
self.assertEquals(result[key_user_id_bob], "@bob:test")
self.assertEquals(result.get(key_user_id_unknown, None), None)