mirror of
https://github.com/element-hq/synapse.git
synced 2026-11-04 09:07:22 +00:00
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:
1 parent
c692133ed4
commit
c320ced0fc
6 files changed
+244
-117
No files matched your search
+10
-11
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user