Skip to content
1 change: 1 addition & 0 deletions changelog.d/18934.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Update [MSC4284: Policy Servers](https://github.com/matrix-org/matrix-spec-proposals/pull/4284) implementation to support signatures when available.
Copy link
Contributor

Choose a reason for hiding this comment

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

I haven't reviewed the sound-ness of whether this works as intended, etc.

Everything looks optional and this saves us extra requests/checks 👍

4 changes: 2 additions & 2 deletions synapse/crypto/keyring.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ async def process_request(self, verify_request: VerifyJsonRequest) -> None:
if key_result.valid_until_ts < verify_request.minimum_valid_until_ts:
continue

await self._process_json(key_result.verify_key, verify_request)
await self.process_json(key_result.verify_key, verify_request)
verified = True

if not verified:
Expand All @@ -326,7 +326,7 @@ async def process_request(self, verify_request: VerifyJsonRequest) -> None:
Codes.UNAUTHORIZED,
)

async def _process_json(
async def process_json(
self, verify_key: VerifyKey, verify_request: VerifyJsonRequest
) -> None:
"""Processes the `VerifyJsonRequest`. Raises if the signature can't be
Expand Down
37 changes: 37 additions & 0 deletions synapse/federation/federation_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,43 @@ async def get_pdu_policy_recommendation(
)
return RECOMMENDATION_OK

@trace
@tag_args
async def ask_policy_server_to_sign_event(
self, destination: str, pdu: EventBase, timeout: Optional[int] = None
) -> Optional[JsonDict]:
"""Requests that the destination server (typically a policy server)
sign the event as not spam.

If the policy server could not be contacted or the policy server
returned an error, this returns no signature.

Args:
destination: The remote homeserver to ask (a policy server)
pdu: The event to sign
timeout: How long to try (in ms) the destination for before
giving up. None indicates no timeout.
Returns:
The signature from the policy server, structured in the same was as the 'signatures'
JSON in the event e.g { "$policy_server_via_domain" : { "ed25519:policy_server": "signature_base64" }}
"""
logger.debug(
"ask_policy_server_to_sign_event for event_id=%s from %s",
pdu.event_id,
destination,
)
try:
return await self.transport_layer.ask_policy_server_to_sign_event(
destination, pdu, timeout=timeout
)
except Exception as e:
logger.warning(
"ask_policy_server_to_sign_event: server %s responded with error: %s",
destination,
e,
)
return None

@trace
@tag_args
async def get_pdu(
Expand Down
26 changes: 26 additions & 0 deletions synapse/federation/transport/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,32 @@ async def get_policy_recommendation_for_pdu(
timeout=timeout,
)

async def ask_policy_server_to_sign_event(
self, destination: str, event: EventBase, timeout: Optional[int] = None
) -> JsonDict:
"""Requests that the destination server (typically a policy server)
sign the event as not spam.

If the policy server could not be contacted or the policy server
returned an error, this raises that error.

Args:
destination: The host name of the policy server / homeserver.
event: The event to sign.
timeout: How long to try (in ms) the destination for before giving up.
None indicates no timeout.
Returns:
The signature from the policy server, structured in the same was as the 'signatures'
JSON in the event e.g { "$policy_server_via_domain" : { "ed25519:policy_server": "signature_base64" }}
"""
return await self.client.post_json(
destination=destination,
path="/_matrix/policy/unstable/org.matrix.msc4284/sign",
data=event.get_pdu_json(),
ignore_backoff=True,
timeout=timeout,
)

async def backfill(
self, destination: str, room_id: str, event_tuples: Collection[str], limit: int
) -> Optional[Union[JsonDict, list]]:
Expand Down
6 changes: 6 additions & 0 deletions synapse/handlers/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -1138,6 +1138,12 @@ async def _create_and_send_nonmember_event_locked(
assert self.hs.is_mine_id(event.sender), "User must be our own: %s" % (
event.sender,
)
# if this room uses a policy server, try to get a signature now.
# We use verify=False here as we are about to call is_event_allowed on the same event
# which will do sig checks.
await self._policy_handler.ask_policy_server_to_sign_event(
event, verify=False
)

policy_allowed = await self._policy_handler.is_event_allowed(event)
if not policy_allowed:
Expand Down
98 changes: 96 additions & 2 deletions synapse/handlers/room_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
import logging
from typing import TYPE_CHECKING

from signedjson.key import decode_verify_key_bytes
from unpaddedbase64 import decode_base64

from synapse.api.errors import SynapseError
from synapse.crypto.keyring import VerifyJsonRequest
from synapse.events import EventBase
from synapse.types.handlers.policy_server import RECOMMENDATION_OK
from synapse.util.stringutils import parse_and_validate_server_name
Expand All @@ -26,6 +31,9 @@

logger = logging.getLogger(__name__)

POLICY_SERVER_EVENT_TYPE = "org.matrix.msc4284.policy"
POLICY_SERVER_KEY_ID = "ed25519:policy_server"


class RoomPolicyHandler:
def __init__(self, hs: "HomeServer"):
Expand Down Expand Up @@ -54,11 +62,11 @@ async def is_event_allowed(self, event: EventBase) -> bool:
Returns:
bool: True if the event is allowed in the room, False otherwise.
"""
if event.type == "org.matrix.msc4284.policy" and event.state_key is not None:
if event.type == POLICY_SERVER_EVENT_TYPE and event.state_key is not None:
return True # always allow policy server change events

policy_event = await self._storage_controllers.state.get_current_state_event(
event.room_id, "org.matrix.msc4284.policy", ""
event.room_id, POLICY_SERVER_EVENT_TYPE, ""
)
if not policy_event:
return True # no policy server == default allow
Expand All @@ -81,6 +89,22 @@ async def is_event_allowed(self, event: EventBase) -> bool:
if not is_in_room:
return True # policy server not in room == default allow

# Check if the event has been signed with the public key in the policy server state event.
# If it is, we can save an HTTP hit.
# We actually want to get the policy server state event BEFORE THE EVENT rather than
# the current state value, else changing the public key will cause all of these checks to fail.
# However, if we are checking outlier events (which we will due to is_event_allowed being called
# near the edges at _check_sigs_and_hash) we won't know the state before the event, so the
# only safe option is to use the current state
public_key = policy_event.content.get("public_key", None)
if public_key is not None and isinstance(public_key, str):
valid = await self._verify_policy_server_signature(
event, policy_server, public_key
)
if valid:
return True
# fallthrough to hit /check manually

# At this point, the server appears valid and is in the room, so ask it to check
# the event.
recommendation = await self._federation_client.get_pdu_policy_recommendation(
Expand All @@ -90,3 +114,73 @@ async def is_event_allowed(self, event: EventBase) -> bool:
return False

return True # default allow

async def _verify_policy_server_signature(
self, event: EventBase, policy_server: str, public_key: str
) -> bool:
# check the event is signed with this (via, public_key).
verify_json_req = VerifyJsonRequest.from_event(policy_server, event, 0)
try:
key_bytes = decode_base64(public_key)
verify_key = decode_verify_key_bytes(POLICY_SERVER_KEY_ID, key_bytes)
# We would normally use KeyRing.verify_event_for_server but we can't here as we don't
# want to fetch the server key, and instead want to use the public key in the state event.
await self._hs.get_keyring().process_json(verify_key, verify_json_req)
# if the event is correctly signed by the public key in the policy server state event = Allow
return True
except Exception as ex:
logger.warning(
"failed to verify event using public key in policy server event: %s", ex
)
return False

async def ask_policy_server_to_sign_event(
self, event: EventBase, verify: bool = False
) -> None:
"""Ask the policy server to sign this event. The signature is added to the event signatures block.

Does nothing if there is no policy server state event in the room. If the policy server
refuses to sign the event (as it's marked as spam) does nothing.

Args:
event: The event to sign
verify: If True, verify that the signature is correctly signed by the public_key in the
policy server state event.
Raises:
if verify=True and the policy server signed the event with an invalid signature. Does
not raise if the policy server refuses to sign the event.
"""
policy_event = await self._storage_controllers.state.get_current_state_event(
event.room_id, POLICY_SERVER_EVENT_TYPE, ""
)
if not policy_event:
return
policy_server = policy_event.content.get("via", None)
if policy_server is None or not isinstance(policy_server, str):
return
# Only ask to sign events if the policy state event has a public_key (so they can be subsequently verified)
public_key = policy_event.content.get("public_key", None)
if public_key is None or not isinstance(public_key, str):
return

# Ask the policy server to sign this event.
# We set a smallish timeout here as we don't want to block event sending too long.
signature = await self._federation_client.ask_policy_server_to_sign_event(
policy_server,
event,
timeout=3000,
)
if (
# the policy server returns {} if it refuses to sign the event.
signature and len(signature) > 0
):
event.signatures.update(signature)
if verify:
is_valid = await self._verify_policy_server_signature(
event, policy_server, public_key
)
if not is_valid:
raise SynapseError(
500,
Copy link
Contributor

Choose a reason for hiding this comment

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

Since this is an upstream server problem, seems like it should be a 502

Copy link
Member Author

Choose a reason for hiding this comment

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

It's not a network issue though - the server returned a result, but it's wrong.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think it still makes sense to return a 502 (HTTPStatus.BAD_GATEWAY) 🤔

The HTTP 502 Bad Gateway server error response status code indicates that a server was acting as a gateway or proxy and that it received an invalid response from the upstream server.

-- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/502


500 makes it seem like a problem with our server that will resolve itself.

Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps something to discuss on the MSC?

I think the only necessary action may be just to use HTTPStatus.xxx

Copy link
Member Author

Choose a reason for hiding this comment

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

Moving this to the MSC is probably best - I suspect we actually want to consider it a rejection rather than an error, but doing that at this level in the stack needs more consideration.

I'm not seeing HTTPStatus being used very much within Synapse, so for consistency won't be using it here.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not seeing HTTPStatus being used very much within Synapse, so for consistency won't be using it here.

389 results in synapse/, 591 results in tests/

f"policy server {policy_server} failed to sign event correctly",
)
Loading
Loading