Files
synapse/tests/federation/test_federation_base.py
T
Kegan DougalandClaude Fable 5.1 e01562f1e0 B3: Verify and authorise events in MSC4430 member key rooms
In a room whose members are MSC4430 member keys, the `sender` of every event
and the `state_key` of every membership event is a per-room Ed25519 key, and
the event is signed by that key rather than by the sender's server. This
change teaches the two places every received event must pass, signature
verification and the auth rules, to work on keys. The member key room
version is not registered, so none of it is reachable; it is the acceptance
boundary every server has to agree on, so it depends on nothing but the
event.

Signatures (`_check_sigs_on_pdu`): in a member key room the event must carry a
valid signature under the entity `sender` by the key decoded from its `sender`,
with key ID `ed25519:1`, over the redacted event as for server signatures; a
restricted join must additionally carry a valid signature by the member key
named in `join_authorised_via_users_server`, under that member key string. No
server signature is required and no key is fetched, so every server reaches
the same verdict. The sender and the authoriser are parsed strictly: a
non-canonical base64 encoding of a key is not that key (otherwise one public
key would have two identities), and a user ID is rejected. A server
co-signature on a membership event's linkage information is not checked here,
on purpose: the MSC makes it decide whether the member's link is verified, not
whether the event is accepted, and some membership events legitimately carry
no linkage information at all. Hash checking, redaction on a hash mismatch,
the policy server and the spam checker are unchanged.

Auth rules (`event_auth`): `validate_event_for_room_version` checks that the
sender is a canonical member key and has signed the event, that a membership
event's `state_key` is a canonical member key, and that a restricted join
names a canonical member key as authoriser and carries its signature. The
`m.federate` domain comparison is skipped in member key rooms, in both the
general and the membership rule: keys have no domain, so it cannot be
evaluated, and in such rooms `m.federate: false` means no federation at all.
Power level `users` and `additional_creators` must be member keys;
`check_valid_additional_creators` therefore takes the room version, and the
room upgrade servlet passes the new one. Third-party invites need no rule
change: the rule compares `signed.mxid` with the `state_key` and verifies
`signed` with the key published in the inviter's `m.room.third_party_invite`
event, so an identity server that signs the member key produces an
acceptable invite; how it learns the key is unspecified. Everything else in
the rules already works on opaque identities: membership lookups, power
levels, creator power and the restricted-join authoriser are all compared as
strings. A membership event with no `membership` in its content is handled
without raising on both new paths (the legacy paths have the same latent
`KeyError`).

Redactions: in room versions since v3 a redaction by a sender without redact
power is applied only if both senders are from the same server, a check
deferred until the redacted event is known and made in three places (the event
cache, the read path, and persistence of an existing redaction). Those three
now share `redaction_sender_matches`, which keeps the server comparison for
every other room version and, in member key rooms where senders have no
server, applies only when the two senders are the very same key. That is
stricter than before (a server's other users can no longer redact a member's
events without power), and the only rule that needs no network.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 09:22:38 +02:00

331 lines
13 KiB
Python

#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2026 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>.
#
#
import string
from unittest.mock import patch
from signedjson.key import (
encode_verify_key_base64,
generate_signing_key,
get_verify_key,
)
from signedjson.sign import sign_json
from twisted.internet.testing import MemoryReactor
from synapse.api.constants import EventContentFields, MemberKeys
from synapse.api.room_versions import RoomVersions
from synapse.crypto import keyring
from synapse.crypto.event_signing import (
EventSigner,
add_hashes_and_signatures,
add_hashes_and_signatures_from_signers,
compute_event_signature,
)
from synapse.events import EventBase, make_event_from_dict
from synapse.events.utils import prune_event
from synapse.federation.federation_base import InvalidEventSignatureError
from synapse.server import HomeServer
from synapse.types import JsonDict, MemberKey
from synapse.util.clock import Clock
from tests import unittest
class FederationBaseTestCase(unittest.HomeserverTestCase):
def test_events_signed_by_banned_key_are_refused(self) -> None:
"""Ensure that event JSON signed using a banned server_signing_key fails verification."""
event_dict = {
"content": {"body": "Here is the message content"},
"event_id": "$0:domain",
"origin_server_ts": 1000000,
"type": "m.room.message",
"room_id": "!r:domain",
"sender": f"@u:{self.hs.config.server.server_name}",
"auth_events": [],
"prev_events": [],
"depth": 1,
"signatures": {},
"unsigned": {"age_ts": 1000000},
}
add_hashes_and_signatures(
self.hs.config.server.default_room_version,
event_dict,
self.hs.config.server.server_name,
self.hs.signing_key,
)
event = make_event_from_dict(event_dict)
fs = self.hs.get_federation_server()
# Ensure the signatures check out normally
self.get_success(
fs._check_sigs_and_hash(self.hs.config.server.default_room_version, event)
)
# Patch the list of banned signing keys and ensure the signature check fails
with patch.object(
keyring,
"BANNED_SERVER_SIGNING_KEYS",
(encode_verify_key_base64(get_verify_key(self.hs.signing_key))),
):
self.get_failure(
fs._check_sigs_and_hash(
self.hs.config.server.default_room_version, event
),
InvalidEventSignatureError,
)
def _non_canonical_twin(member_key: str) -> str:
"""The same public key with a spare bit of the last base64 character set:
decodes to the same 32 bytes, but is not the canonical encoding."""
alphabet = string.ascii_uppercase + string.ascii_lowercase + string.digits + "-_"
return member_key[:-1] + alphabet[alphabet.index(member_key[-1]) + 1]
class MemberKeySignatureTestCase(unittest.HomeserverTestCase):
"""MSC4430: events in a member key room are verified against the member key in
their `sender`, with no server signature and no key fetching."""
room_version = RoomVersions.MSC4430v12
room_id = "!K3DOWWLmkHLl52yJ-vT8J5jX5wuYZati_KvC6PliIPE"
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.member_signing_key = generate_signing_key(MemberKeys.KEY_VERSION)
self.member_key = MemberKey.from_signing_key(
self.member_signing_key
).to_string()
self.other_signing_key = generate_signing_key(MemberKeys.KEY_VERSION)
self.other_key = MemberKey.from_signing_key(self.other_signing_key).to_string()
def _event_dict(self, sender: str, **overrides: object) -> JsonDict:
event_dict: JsonDict = {
"room_id": self.room_id,
"type": "m.room.message",
"sender": sender,
"content": {"body": "hello"},
"depth": 1,
"origin_server_ts": 1,
"prev_events": [],
"prev_state_events": [],
}
event_dict.update(overrides)
return event_dict
def _sign(self, event_dict: JsonDict, *signers: EventSigner) -> JsonDict:
add_hashes_and_signatures_from_signers(
self.room_version, event_dict, signers, server_name=self.hs.hostname
)
return event_dict
def _check(self, event_dict: JsonDict) -> EventBase:
event = make_event_from_dict(event_dict, self.room_version)
return self.get_success(
self.hs.get_federation_server()._check_sigs_and_hash(
self.room_version, event
)
)
def _check_fails(self, event_dict: JsonDict, reason: str) -> None:
event = make_event_from_dict(event_dict, self.room_version)
failure = self.get_failure(
self.hs.get_federation_server()._check_sigs_and_hash(
self.room_version, event
),
InvalidEventSignatureError,
)
self.assertIn(reason, str(failure.value))
def test_signed_by_sender(self) -> None:
event_dict = self._sign(
self._event_dict(self.member_key),
(MemberKeys.SIGNATURE_ENTITY, self.member_signing_key),
)
checked = self._check(event_dict)
self.assertEqual(checked.sender, self.member_key)
# Neither redacted nor soft-failed.
self.assertEqual(checked.content["body"], "hello")
self.assertFalse(checked.internal_metadata.is_soft_failed())
# The signature covers `hashes`: a changed content hash is a signature
# failure, not merely a hash mismatch (which would return a redaction).
hashes_changed = self._sign(
self._event_dict(self.member_key),
(MemberKeys.SIGNATURE_ENTITY, self.member_signing_key),
)
hashes_changed["hashes"]["sha256"] = (
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
)
self._check_fails(hashes_changed, "unable to verify member key signature")
def test_verified_json_is_the_redacted_event(self) -> None:
"""What is verified is the redacted event without `unsigned`, as for
server signatures: a signature made that way by hand, without the signing
helpers, is accepted."""
event_dict = self._event_dict(self.member_key, unsigned={"age": 1})
add_hashes_and_signatures_from_signers(
self.room_version, event_dict, [], server_name=self.hs.hostname
)
to_sign = prune_event(
make_event_from_dict(event_dict, self.room_version)
).get_pdu_json()
to_sign.pop("unsigned", None)
signed = sign_json(
to_sign, MemberKeys.SIGNATURE_ENTITY, self.member_signing_key
)
event_dict["signatures"] = signed["signatures"]
self.assertEqual(self._check(event_dict).content["body"], "hello")
def test_missing_or_wrong_key_id(self) -> None:
# No signatures at all.
unsigned = self._sign(self._event_dict(self.member_key))
self._check_fails(unsigned, "Not signed by sender")
# Signed under `sender`, but with a key ID other than `ed25519:1`.
other_version = generate_signing_key("2")
event_dict = self._event_dict(
MemberKey.from_signing_key(other_version).to_string()
)
add_hashes_and_signatures_from_signers(
self.room_version, event_dict, [], server_name=self.hs.hostname
)
event_dict["signatures"] = compute_event_signature(
self.room_version,
event_dict,
signature_name=MemberKeys.SIGNATURE_ENTITY,
signing_key=other_version,
)
self.assertEqual(
list(event_dict["signatures"][MemberKeys.SIGNATURE_ENTITY]),
["ed25519:2"],
)
self._check_fails(event_dict, "ed25519:1")
def test_signed_by_another_member_key(self) -> None:
"""A valid signature by a different key than the one `sender` names."""
event_dict = self._sign(
self._event_dict(self.other_key),
(MemberKeys.SIGNATURE_ENTITY, self.other_signing_key),
)
event_dict["sender"] = self.member_key
self._check_fails(event_dict, "unable to verify member key signature")
def test_server_signature_is_not_enough(self) -> None:
"""A signature by our server, as in every other room version, is not a
signature by the sender."""
event_dict = self._event_dict(self.member_key)
add_hashes_and_signatures(
self.room_version, event_dict, self.hs.hostname, self.hs.signing_key
)
self._check_fails(event_dict, "unable to verify member key signature")
def test_sender_must_be_a_canonical_member_key(self) -> None:
legacy_dict = self._event_dict(f"@alice:{self.hs.hostname}")
add_hashes_and_signatures(
self.room_version, legacy_dict, self.hs.hostname, self.hs.signing_key
)
self._check_fails(legacy_dict, "is not a valid member key")
# The same 32 bytes with a spare bit of the last character set: not
# canonical, so not the member's identity.
event_dict = self._sign(
self._event_dict(self.member_key),
(MemberKeys.SIGNATURE_ENTITY, self.member_signing_key),
)
event_dict["sender"] = _non_canonical_twin(self.member_key)
self._check_fails(event_dict, "is not a valid member key")
def test_restricted_join_authoriser(self) -> None:
"""The authorising member of a restricted join signs under the member key
string itself, and that signature is verified with that key."""
def join_dict() -> JsonDict:
return self._event_dict(
self.member_key,
type="m.room.member",
state_key=self.member_key,
content={
"membership": "join",
EventContentFields.AUTHORISING_USER: self.other_key,
},
)
both = self._sign(
join_dict(),
(MemberKeys.SIGNATURE_ENTITY, self.member_signing_key),
(self.other_key, self.other_signing_key),
)
self.assertEqual(self._check(both).sender, self.member_key)
joiner_only = self._sign(
join_dict(), (MemberKeys.SIGNATURE_ENTITY, self.member_signing_key)
)
self._check_fails(joiner_only, f"under {self.other_key!r}")
# A signature under the authoriser's name made by some other key (added
# directly, as a forger would; our own helpers refuse to make one).
forged = self._sign(
join_dict(), (MemberKeys.SIGNATURE_ENTITY, self.member_signing_key)
)
forged["signatures"] = compute_event_signature(
self.room_version,
forged,
signature_name=self.other_key,
signing_key=generate_signing_key(MemberKeys.KEY_VERSION),
)
self._check_fails(forged, f"under {self.other_key!r}")
# The authoriser is not a member key at all. (Set before signing: the
# field survives redaction, so changing it afterwards would break the
# sender's signature first.)
not_a_key = join_dict()
not_a_key["content"][EventContentFields.AUTHORISING_USER] = (
f"@mod:{self.hs.hostname}"
)
self._sign(not_a_key, (MemberKeys.SIGNATURE_ENTITY, self.member_signing_key))
self._check_fails(not_a_key, "authorising user")
not_a_string = join_dict()
not_a_string["content"][EventContentFields.AUTHORISING_USER] = {}
self._sign(not_a_string, (MemberKeys.SIGNATURE_ENTITY, self.member_signing_key))
self._check_fails(not_a_string, "authorising user")
# Only joins have an authoriser: a leave carrying the field needs no
# second signature, and a membership event without `membership` is
# rejected cleanly rather than crashing.
leave = join_dict()
leave["content"]["membership"] = "leave"
self._sign(leave, (MemberKeys.SIGNATURE_ENTITY, self.member_signing_key))
self.assertEqual(self._check(leave).sender, self.member_key)
no_membership = join_dict()
del no_membership["content"]["membership"]
self._sign(
no_membership, (MemberKeys.SIGNATURE_ENTITY, self.member_signing_key)
)
self.assertEqual(self._check(no_membership).sender, self.member_key)
def test_tampered_content_is_redacted(self) -> None:
"""The signature is over the redacted event, so a content change passes
the signature check and fails the hash check, as in every room version."""
event_dict = self._sign(
self._event_dict(self.member_key),
(MemberKeys.SIGNATURE_ENTITY, self.member_signing_key),
)
event_dict["content"]["body"] = "tampered"
checked = self._check(event_dict)
self.assertNotIn("body", checked.content)