Only a member key may assert its own linkage information

An `m.room.member` event's `content.localpart` and `content.domain` claim who
the member key in `state_key` belongs to, and the server named by
`content.domain` signs the event to vouch for the claim. That signature is
not enough on its own: a moderator kicking or banning a member can name a
domain they control, vouch for the claim with that domain's signing key, and
produce a verifiable but false statement about somebody else — defacing an
innocent member with an abusive localpart, or pointing them at a server that
then receives their traffic.

So require the claim to come from the member too. The member key named by
`state_key` signs the event under the entity `state_key`, with the member
keys' constant key ID, and linkage information on an event the member did not
send is ignored unless it is there. A moderator cannot produce that signature
without the member's key, and the absence of one costs them nothing: the kick
or the ban still takes effect, since this is not a validity rule. It only
decides whether anyone believes what the event says about who was kicked.

The events this server builds follow suit. A membership event sent by one of
our users about another — an invite — now carries the target's own signature
alongside ours, which is what makes it worth anything to the servers that
receive it. A kick, a ban or an unban carries no linkage information at all:
we would be asserting something we are not entitled to assert, and the
target's localpart has no business being published on somebody else's event.
Members' own events are unchanged; their `sender` signature is already their
assertion, and a second copy of it would say nothing more.

This replaces the previous arrangement, where the server's signature vouching
for linkage information was made under the constant `state_key` rather than
under the server's own name. That distinguished a claim about the target from
one about the sender, but it did not establish who was entitled to make it.
The entity name goes back to the server's name, as everywhere else in Matrix,
and `state_key` now names what it says: the member key in the event's state
key, signing for itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kegan DougalandClaude Opus 5 committed 2026-10-15 17:12:45 +02:00
1 parent c692133ed4
commit c320ced0fc
6 files changed
+244 -117

No files matched your search

+10 -11
View File
@@ -356,18 +356,17 @@ class MemberKeys:
SIGNATURE_ENTITY: Final = "sender"
"""The entity name under which the sender's member key signs an event. (The
authorising member key of a restricted join signs under the member key string
itself.)"""
itself, and a server vouching for linkage information signs under its name.)"""
LINKAGE_SIGNATURE_ENTITY: Final = "state_key"
"""The entity name under which a server co-signs the linkage information of an
`m.room.member` event: the claim that the member key in `state_key` belongs to
`content.localpart` at `content.domain`. Naming the entity for the member it is
about, rather than for the server making the claim, is what lets a third party
tell an assertion about the target from one about the sender: an invite is sent
by one member key and co-signed by the *invitee's* server. The signature is made
with the server's own key, so the key ID is a real server key ID rather than the
member keys' constant `ed25519:1`, and names the published key to check it
against."""
STATE_KEY_SIGNATURE_ENTITY: Final = "state_key"
"""The entity name under which the member key named by an `m.room.member`
event's `state_key` signs it, with the member keys' constant key ID. It is
what asserts the event's linkage information: a server vouches for a claim by
signing under its own name, but only the holder of the member key may make the
claim in the first place, so a membership event sent by somebody else (a kick,
a ban) carries no linkage information that anyone will believe. An event whose
`sender` and `state_key` are the same key needs no such signature: the `sender`
signature already is the member's own."""
KEY_ALGORITHM: Final = "ed25519"
KEY_VERSION: Final = "1"
+28 -11
View File
@@ -200,10 +200,12 @@ def add_hashes_and_signatures_from_signers(
* A member key string (the authorising member of a restricted join, MSC-Q §2):
the event must be a join naming that key in `join_authorised_via_users_server`,
and the key must be that member key.
* `MemberKeys.LINKAGE_SIGNATURE_ENTITY` (`state_key`), co-signing the linkage
* `MemberKeys.STATE_KEY_SIGNATURE_ENTITY` (`state_key`), asserting the linkage
information of the member named by `state_key`: the event must be an
`m.room.member` event whose `content.domain` is us, and the key must be our
server signing key.
`m.room.member` event sent by somebody else, and the key must be the member
key its `state_key` names.
* Our own server name (`server_name`), vouching for linkage information: the
event must be an `m.room.member` event whose `content.domain` is us.
Anything else is refused. To add one signature to an event that is already
hashed (one received from another server), use
@@ -324,14 +326,29 @@ def _check_member_key_room_signer(
)
return
# The co-signature on linkage information. The entity is the constant
# `state_key`, naming the member the claim is about; the claim itself is
# `content.domain`, which must be us, since this server signs for nobody else.
if signature_name != MemberKeys.LINKAGE_SIGNATURE_ENTITY:
raise refuse(
"not a signature this event may carry (expected %s)"
% (MemberKeys.LINKAGE_SIGNATURE_ENTITY,)
)
if signature_name == MemberKeys.STATE_KEY_SIGNATURE_ENTITY:
# The member's own assertion of their linkage information on an event
# somebody else sent about them.
if signing_key.version != MemberKeys.KEY_VERSION:
raise refuse("member keys sign with key ID %s" % (MemberKeys.KEY_ID,))
state_key = event_dict.get("state_key")
if event_dict.get("type") != EventTypes.Member or not isinstance(
state_key, str
):
raise refuse("only m.room.member events have a state key to sign for")
if MemberKey.from_signing_key(signing_key).to_string() != state_key:
raise refuse("the signing key is not the event's state key")
if event_dict.get("sender") == state_key:
raise refuse(
"the sender signature is already this member's; a second "
"signature under %s says nothing more"
% (MemberKeys.STATE_KEY_SIGNATURE_ENTITY,)
)
return
# Our server name: vouching for linkage information.
if signature_name != server_name:
raise refuse("not this server's name (%s)" % (server_name,))
if event_dict.get("type") != EventTypes.Member:
raise refuse("servers only co-sign m.room.member events")
if content.get(EventContentFields.MSC4430_DOMAIN) != server_name:
+44 -13
View File
@@ -111,6 +111,24 @@ def record_unsupported_path(server_name: str, path: str) -> None:
)
def _may_assert_linkage(event_dict: JsonDict, content: JsonDict) -> bool:
"""Whether a membership event we are building may carry its target's linkage
information.
Only the member themselves may assert who a member key belongs to, by signing
the event with that key: a claim made by anyone else is ignored by every other
server, since otherwise a moderator could kick a member while naming a
localpart and a domain of their choosing and have it believed. So an event the
member sent carries their linkage information, and an invite carries it because
the invitee's own server signs it with their key alongside its own. A kick, a
ban or an unban carries none, and so names nobody: it would be a claim nobody
believes, and would publish a localpart in a room that has no use for it.
"""
return event_dict.get("sender") == event_dict.get("state_key") or (
content.get(EventContentFields.MEMBERSHIP) == Membership.INVITE
)
class MemberKeyHandler:
"""Resolves room member identities (user IDs or member keys) to users and back,
and chooses the member keys and signatures for the events our users create."""
@@ -353,10 +371,11 @@ class MemberKeyHandler:
users gets our linkage information. Rooms without member keys are left
untouched.
Linkage information is asserted by servers, never by clients: whatever
the client put in `localpart` / `domain` is dropped, and the fields are
filled in only for a target we can vouch for. An erased user gets no
`localpart`, so their name is not published again.
Linkage information is never asserted by clients: whatever the client put
in `localpart` / `domain` is dropped, and the fields are filled in only
for a target we can vouch for, on an event that may assert them at all
(see `_may_assert_linkage`). An erased user gets no `localpart`, so their
name is not published again.
The `users` of an `m.room.power_levels` event are translated the same
way (user IDs of ours become keys; keys stay as they are, so a client
@@ -468,7 +487,7 @@ class MemberKeyHandler:
Codes.INVALID_PARAM,
)
if target_user_id is not None:
if target_user_id is not None and _may_assert_linkage(event_dict, content):
target = UserID.from_string(target_user_id)
content[EventContentFields.MSC4430_DOMAIN] = target.domain
if not await self._store.is_user_erased(target_user_id):
@@ -544,11 +563,12 @@ class MemberKeyHandler:
"""Who signs an event we are building in a member key room:
* the sender's member key, under the entity `sender`, if we hold it;
* our server signing key, under the entity `state_key`, on an
`m.room.member` event whose target is a key we hold and whose linkage
information is exactly what we vouch for (our domain, and the user's
localpart unless they are erased): that is what lets other servers
verify the member's link;
* on an `m.room.member` event whose target is a key we hold and whose
linkage information is exactly what we vouch for (our domain, and the
user's localpart unless they are erased): that member's own key, under
the entity `state_key`, if somebody else sent the event, asserting the
claim; and our server signing key, under our name, vouching for it.
Together they are what lets other servers verify the member's link;
* the authorising member's key, under its own name, on a restricted join
that names a key we hold.
@@ -584,9 +604,20 @@ class MemberKeyHandler:
if target_user_id is not None and await self._is_our_linkage(
target_user_id, content
):
signers.append(
(MemberKeys.LINKAGE_SIGNATURE_ENTITY, self._server_signing_key)
)
if state_key != sender:
# Somebody else's event about our member (an invite): their
# key asserts the claim, since ours is not enough for other
# servers to believe it. An event the member sent themselves
# needs no second signature.
target_signing_key = await self._signing_key_for(room_id, state_key)
if target_signing_key is not None:
signers.append(
(
MemberKeys.STATE_KEY_SIGNATURE_ENTITY,
target_signing_key,
)
)
signers.append((self.server_name, self._server_signing_key))
authorising_member = content.get(EventContentFields.AUTHORISING_USER)
if (
+93 -38
View File
@@ -339,34 +339,101 @@ class MemberKeySigningTestCase(unittest.TestCase):
)
def test_server_cosignature(self) -> None:
"""The server co-signs linkage information under the entity `state_key`,
naming the member the claim is about rather than itself, with its own key
ID: a verifier reads `content.domain`, fetches that server's published key
of that ID, and checks the `state_key` signature."""
event = self._sign(
self._member_event_dict(),
(MemberKeys.SIGNATURE_ENTITY, self.member_signing_key),
(MemberKeys.LINKAGE_SIGNATURE_ENTITY, self.server_signing_key),
(self.server_name, self.server_signing_key),
)
# Both signatures are present: the second signer added to the first. The
# co-signature carries the server's own key ID, not the member keys'
# constant `ed25519:1`, and the server's name appears nowhere.
# Both signatures are present: the second signer added to the first.
self.assertEqual(
{k: list(v) for k, v in self._signatures(event).items()},
{
MemberKeys.SIGNATURE_ENTITY: [MemberKeys.KEY_ID],
MemberKeys.LINKAGE_SIGNATURE_ENTITY: ["ed25519:srv"],
},
set(self._signatures(event)),
{MemberKeys.SIGNATURE_ENTITY, self.server_name},
)
redacted = self._redacted_pdu(event)
verify_signed_json(
redacted, MemberKeys.SIGNATURE_ENTITY, self.member_key.verify_key()
)
verify_signed_json(
redacted, self.server_name, get_verify_key(self.server_signing_key)
)
def test_state_key_signature(self) -> None:
"""On an event somebody else sent about a member, the member's own key
signs under the entity `state_key`: that is what asserts the linkage
information, which the server then vouches for under its own name."""
event_dict = self._member_event_dict()
event_dict["sender"] = self.other_member_key.to_string()
event_dict["content"]["membership"] = "invite"
event = self._sign(
event_dict,
(MemberKeys.SIGNATURE_ENTITY, self.other_signing_key),
(MemberKeys.STATE_KEY_SIGNATURE_ENTITY, self.member_signing_key),
(self.server_name, self.server_signing_key),
)
# The member's signature carries the member keys' constant key ID; the
# server's carries its own, which names the published key to check it
# against.
self.assertEqual(
{k: list(v) for k, v in self._signatures(event).items()},
{
MemberKeys.SIGNATURE_ENTITY: [MemberKeys.KEY_ID],
MemberKeys.STATE_KEY_SIGNATURE_ENTITY: [MemberKeys.KEY_ID],
self.server_name: ["ed25519:srv"],
},
)
redacted = self._redacted_pdu(event)
verify_signed_json(
redacted, MemberKeys.SIGNATURE_ENTITY, self.other_member_key.verify_key()
)
verify_signed_json(
redacted,
MemberKeys.LINKAGE_SIGNATURE_ENTITY,
get_verify_key(self.server_signing_key),
MemberKeys.STATE_KEY_SIGNATURE_ENTITY,
self.member_key.verify_key(),
)
verify_signed_json(
redacted, self.server_name, get_verify_key(self.server_signing_key)
)
def test_refuses_state_key_signature_by_the_wrong_key(self) -> None:
"""A `state_key` signature is the member's own assertion, so it must be
made by the key the event's `state_key` names, on a membership event, and
only where the member is not already the sender."""
event_dict = self._member_event_dict()
event_dict["sender"] = self.other_member_key.to_string()
# Another member's key, not the one named by `state_key`.
self._assert_refused(
event_dict,
(MemberKeys.STATE_KEY_SIGNATURE_ENTITY, self.other_signing_key),
"not the event's state key",
)
# The right key, but the member sent the event themselves: the `sender`
# signature is already theirs.
self._assert_refused(
self._member_event_dict(),
(MemberKeys.STATE_KEY_SIGNATURE_ENTITY, self.member_signing_key),
"says nothing more",
)
# Not a membership event.
message = self._member_event_dict()
message["type"] = "m.room.message"
del message["state_key"]
self._assert_refused(
message,
(MemberKeys.STATE_KEY_SIGNATURE_ENTITY, self.member_signing_key),
"state key to sign for",
)
# The key ID is fixed to `ed25519:1` by the MSC.
wrong_version = generate_signing_key("2")
event_dict = self._member_event_dict()
event_dict["sender"] = self.other_member_key.to_string()
event_dict["state_key"] = MemberKey.from_signing_key(wrong_version).to_string()
self._assert_refused(
event_dict,
(MemberKeys.STATE_KEY_SIGNATURE_ENTITY, wrong_version),
"key ID",
)
def test_authorising_member_key_signature(self) -> None:
@@ -447,7 +514,7 @@ class MemberKeySigningTestCase(unittest.TestCase):
event = self._sign(
self._member_event_dict(),
(MemberKeys.SIGNATURE_ENTITY, self.member_signing_key),
(MemberKeys.LINKAGE_SIGNATURE_ENTITY, self.server_signing_key),
(self.server_name, self.server_signing_key),
)
redacted = prune_event(event)
@@ -462,9 +529,7 @@ class MemberKeySigningTestCase(unittest.TestCase):
pdu, MemberKeys.SIGNATURE_ENTITY, self.member_key.verify_key()
)
verify_signed_json(
pdu,
MemberKeys.LINKAGE_SIGNATURE_ENTITY,
get_verify_key(self.server_signing_key),
pdu, self.server_name, get_verify_key(self.server_signing_key)
)
# ... and cover `domain`: changing it invalidates both.
tampered = copy.deepcopy(pdu)
@@ -475,9 +540,7 @@ class MemberKeySigningTestCase(unittest.TestCase):
)
with self.assertRaises(SignatureVerifyException):
verify_signed_json(
tampered,
MemberKeys.LINKAGE_SIGNATURE_ENTITY,
get_verify_key(self.server_signing_key),
tampered, self.server_name, get_verify_key(self.server_signing_key)
)
# The event ID (the reference hash) covers `domain` too...
@@ -573,18 +636,21 @@ class MemberKeySigningTestCase(unittest.TestCase):
def test_refuses_server_cosignature_on_wrong_event(self) -> None:
"""A server co-signs only `m.room.member` events whose `content.domain` is
itself: our co-signature is a statement about our own user."""
linkage = MemberKeys.LINKAGE_SIGNATURE_ENTITY
# Another domain's linkage information.
# Another server's name, even if it matches `content.domain`.
event_dict = self._member_event_dict()
event_dict["content"]["domain"] = "other.example"
self._assert_refused(
event_dict, (linkage, self.server_signing_key), "content.domain"
event_dict, ("other.example", self.server_signing_key), "not this server"
)
# Our name, on another domain's linkage information.
self._assert_refused(
event_dict, (self.server_name, self.server_signing_key), "content.domain"
)
# No linkage information at all.
event_dict = self._member_event_dict()
del event_dict["content"]["domain"]
self._assert_refused(
event_dict, (linkage, self.server_signing_key), "content.domain"
event_dict, (self.server_name, self.server_signing_key), "content.domain"
)
# Not a membership event.
event_dict = self._member_event_dict()
@@ -592,21 +658,10 @@ class MemberKeySigningTestCase(unittest.TestCase):
del event_dict["state_key"]
self._assert_refused(
event_dict,
(linkage, self.server_signing_key),
(self.server_name, self.server_signing_key),
"only co-sign m.room.member",
)
def test_refuses_signature_under_a_server_name(self) -> None:
"""Linkage is co-signed under the constant `state_key`, so a signature
under a server's name — ours or anyone else's — is not a signature a
member key room event may carry."""
for entity in (self.server_name, "other.example"):
self._assert_refused(
self._member_event_dict(),
(entity, self.server_signing_key),
"not a signature this event may carry",
)
def test_refuses_non_member_key_room(self) -> None:
for fn in (
lambda: add_hashes_and_signatures_from_signers(
+36 -9
View File
@@ -209,9 +209,40 @@ class EventTranslationTestCase(unittest.HomeserverTestCase):
)
self.assertEqual(
self._signers(event_dict),
[MemberKeys.SIGNATURE_ENTITY, MemberKeys.LINKAGE_SIGNATURE_ENTITY],
[
MemberKeys.SIGNATURE_ENTITY,
MemberKeys.STATE_KEY_SIGNATURE_ENTITY,
self.hs.hostname,
],
)
def test_moderation_asserts_nothing_about_its_target(self) -> None:
"""A kick, ban or unban of one of our users carries no linkage
information, even though we hold the target's key and know who they are.
Only the member's own key may assert who a key belongs to, and it has not
signed this event, so the claim would be ignored by every other server —
and a moderator who could make it would be able to deface a member by
naming a localpart and a domain of their choosing."""
for membership in ("leave", "ban"):
event_dict = self._translate(
{
"type": EventTypes.Member,
"room_id": ROOM_ID,
"sender": self.alice,
"state_key": self.bob,
"content": {
"membership": membership,
EventContentFields.MSC4430_LOCALPART: "some_abusive_localpart",
EventContentFields.MSC4430_DOMAIN: "evil.example",
},
}
)
self.assertEqual(event_dict["state_key"], self._key(self.bob))
self.assertEqual(event_dict["content"], {"membership": membership})
self.assertEqual(
self._signers(event_dict), [MemberKeys.SIGNATURE_ENTITY], membership
)
def test_erased_user_gets_no_localpart(self) -> None:
self.get_success(self.store.mark_user_erased(self.bob))
event_dict = self._translate(
@@ -232,7 +263,7 @@ class EventTranslationTestCase(unittest.HomeserverTestCase):
)
self.assertEqual(
self._signers(event_dict),
[MemberKeys.SIGNATURE_ENTITY, MemberKeys.LINKAGE_SIGNATURE_ENTITY],
[MemberKeys.SIGNATURE_ENTITY, self.hs.hostname],
)
def test_remote_target_is_refused(self) -> None:
@@ -305,11 +336,7 @@ class EventTranslationTestCase(unittest.HomeserverTestCase):
)
self.assertEqual(
self._signers(event_dict),
[
MemberKeys.SIGNATURE_ENTITY,
MemberKeys.LINKAGE_SIGNATURE_ENTITY,
alice_key,
],
[MemberKeys.SIGNATURE_ENTITY, self.hs.hostname, alice_key],
)
def _key_for(self, user_id: str) -> str:
@@ -466,7 +493,7 @@ class EventTranslationTestCase(unittest.HomeserverTestCase):
}
self.assertEqual(
signers_for(ours),
[MemberKeys.SIGNATURE_ENTITY, MemberKeys.LINKAGE_SIGNATURE_ENTITY],
[MemberKeys.SIGNATURE_ENTITY, self.hs.hostname],
)
for wrong in (
{**ours, EventContentFields.MSC4430_LOCALPART: "bob"},
@@ -481,7 +508,7 @@ class EventTranslationTestCase(unittest.HomeserverTestCase):
self.assertEqual(signers_for(ours), [MemberKeys.SIGNATURE_ENTITY])
self.assertEqual(
signers_for({EventContentFields.MSC4430_DOMAIN: self.hs.hostname}),
[MemberKeys.SIGNATURE_ENTITY, MemberKeys.LINKAGE_SIGNATURE_ENTITY],
[MemberKeys.SIGNATURE_ENTITY, self.hs.hostname],
)
def test_remote_additional_creator_is_refused(self) -> None:
+33 -35
View File
@@ -141,18 +141,24 @@ class _MemberKeyRoomsBase(unittest.HomeserverTestCase):
def _assert_linked_and_cosigned(self, event: EventBase, user_id: str) -> None:
"""A membership event about one of our users: it names them and we
co-signed it, next to the member key signature."""
co-signed it, next to the member key signature. An event the member did
not send themselves — an invite — additionally carries their own key's
signature under `state_key`, which is what asserts the linkage: ours
alone is a claim no other server believes."""
localpart, domain = user_id[1:].split(":", 1)
self.assertEqual(event.content[EventContentFields.MSC4430_LOCALPART], localpart)
self.assertEqual(event.content[EventContentFields.MSC4430_DOMAIN], domain)
expected = {
MemberKeys.SIGNATURE_ENTITY: {MemberKeys.KEY_ID},
self.hs.hostname: {
f"{self.hs.signing_key.alg}:{self.hs.signing_key.version}"
},
}
if event.sender != event.state_key:
expected[MemberKeys.STATE_KEY_SIGNATURE_ENTITY] = {MemberKeys.KEY_ID}
self.assertEqual(
{entity: set(sigs) for entity, sigs in event.signatures.as_dict().items()},
{
MemberKeys.SIGNATURE_ENTITY: {MemberKeys.KEY_ID},
MemberKeys.LINKAGE_SIGNATURE_ENTITY: {
f"{self.hs.signing_key.alg}:{self.hs.signing_key.version}"
},
},
expected,
event.signatures,
)
@@ -365,8 +371,8 @@ class MemberKeyRoomsTestCase(_MemberKeyRoomsBase):
self.assertEqual(
leave.content[EventContentFields.MSC4430_DOMAIN], self.hs.hostname
)
# Still ours, so still co-signed.
self.assertIn(MemberKeys.LINKAGE_SIGNATURE_ENTITY, leave.signatures)
# Their own event, so still co-signed.
self.assertIn(self.hs.hostname, leave.signatures)
def test_additional_creators(self) -> None:
"""Additional creators are named by key in the create event, which is the
@@ -496,20 +502,16 @@ class MemberKeyModerationTestCase(_MemberKeyRoomsBase):
def _membership(self, room_id: str, member_key: str) -> str:
return self._member_event(room_id, member_key).membership
def _assert_moderated_by(
self, event: EventBase, sender_key: str, target_user_id: str | None = None
) -> None:
"""A membership event sent by `sender_key` about another member: with
the target's linkage information and our co-signature if the target is
one of our users, else with neither and signed by the sender's key alone
(we vouch for nothing about a key we do not hold)."""
def _assert_moderated_by(self, event: EventBase, sender_key: str) -> None:
"""A kick, ban or unban: sent by `sender_key` about another member, and
naming nobody. Only the member's own key may assert who they are, and it
has not signed this, so linkage information here would be ignored by every
other server — including a target whose key we hold, whose localpart has
no business being published on somebody else's event."""
self.assertEqual(event.sender, sender_key)
if target_user_id is not None:
self._assert_linked_and_cosigned(event, target_user_id)
else:
self.assertNotIn(EventContentFields.MSC4430_LOCALPART, event.content)
self.assertNotIn(EventContentFields.MSC4430_DOMAIN, event.content)
self._assert_signed_by_member_key_only(event)
self.assertNotIn(EventContentFields.MSC4430_LOCALPART, event.content)
self.assertNotIn(EventContentFields.MSC4430_DOMAIN, event.content)
self._assert_signed_by_member_key_only(event)
def _power_levels(self, room_id: str) -> JsonDict:
return dict(self._current_state(room_id)[(EventTypes.PowerLevels, "")].content)
@@ -548,12 +550,12 @@ class MemberKeyModerationTestCase(_MemberKeyRoomsBase):
kick = self._member_event(room_id, bob_key)
self.assertEqual(kick.membership, Membership.LEAVE)
self.assertEqual(kick.content["reason"], "kick reason")
self._assert_moderated_by(kick, alice_key, self.bob)
self._assert_moderated_by(kick, alice_key)
self._moderate(room_id, "ban", self.bob)
ban = self._member_event(room_id, bob_key)
self.assertEqual(ban.membership, Membership.BAN)
self._assert_moderated_by(ban, alice_key, self.bob)
self._assert_moderated_by(ban, alice_key)
self.helper.join(
room_id, self.bob, tok=self.bob_tok, expect_code=HTTPStatus.FORBIDDEN
)
@@ -561,7 +563,7 @@ class MemberKeyModerationTestCase(_MemberKeyRoomsBase):
self._moderate(room_id, "unban", self.bob)
unban = self._member_event(room_id, bob_key)
self.assertEqual(unban.membership, Membership.LEAVE)
self._assert_moderated_by(unban, alice_key, self.bob)
self._assert_moderated_by(unban, alice_key)
# Back under the same key.
self.helper.join(room_id, self.bob, tok=self.bob_tok)
@@ -579,7 +581,7 @@ class MemberKeyModerationTestCase(_MemberKeyRoomsBase):
self._moderate(room_id, "kick", bob_key)
kick = self._member_event(room_id, bob_key)
self.assertEqual(kick.membership, Membership.LEAVE)
self._assert_moderated_by(kick, alice_key, self.bob)
self._assert_moderated_by(kick, alice_key)
self.assertEqual(
self.get_success(
self.store.get_local_current_membership_for_user_in_room(
@@ -598,19 +600,15 @@ class MemberKeyModerationTestCase(_MemberKeyRoomsBase):
self._moderate(room_id, "unban", bob_key)
unban = self._member_event(room_id, bob_key)
self.assertEqual(unban.membership, Membership.LEAVE)
self._assert_moderated_by(unban, alice_key, self.bob)
self._assert_moderated_by(unban, alice_key)
self.helper.join(room_id, self.bob, tok=self.bob_tok)
# Once erased, Bob is still ours (co-signed, our domain), unnamed.
# Erased or not, a ban names nobody: Bob's key has not signed it.
self.get_success(self.store.mark_user_erased(self.bob))
self._moderate(room_id, "ban", bob_key)
ban = self._member_event(room_id, bob_key)
self.assertEqual(ban.membership, Membership.BAN)
self.assertNotIn(EventContentFields.MSC4430_LOCALPART, ban.content)
self.assertEqual(
ban.content[EventContentFields.MSC4430_DOMAIN], self.hs.hostname
)
self.assertIn(MemberKeys.LINKAGE_SIGNATURE_ENTITY, ban.signatures.as_dict())
self._assert_moderated_by(ban, alice_key)
self._assert_no_user_ids_in_room(room_id)
def test_moderation_through_the_state_api(self) -> None:
@@ -630,7 +628,7 @@ class MemberKeyModerationTestCase(_MemberKeyRoomsBase):
)
ban = self._member_event(room_id, bob_key)
self.assertEqual(ban.membership, Membership.BAN)
self._assert_moderated_by(ban, alice_key, self.bob)
self._assert_moderated_by(ban, alice_key)
self.helper.send_state(
room_id,
@@ -1028,7 +1026,7 @@ class MemberKeyModerationTestCase(_MemberKeyRoomsBase):
self.assertEqual(self._power_levels(new_room)["users"], {new_bob_key: 50})
ban = self._member_event(new_room, new_carol_key)
self.assertEqual(ban.membership, Membership.BAN)
self._assert_moderated_by(ban, new_alice_key, self.carol)
self._assert_moderated_by(ban, new_alice_key)
self.assertEqual(self._membership(new_room, new_alice_key), Membership.JOIN)
self.assertNotIn(
(EventTypes.Member, new_bob_key), self._current_state(new_room)