diff options
-rw-r--r-- | synapse/api/auth.py | 35 | ||||
-rw-r--r-- | synapse/api/constants.py | 1 | ||||
-rw-r--r-- | synapse/federation/federation_client.py | 9 | ||||
-rw-r--r-- | synapse/federation/federation_server.py | 19 | ||||
-rw-r--r-- | synapse/federation/transport/client.py | 5 | ||||
-rw-r--r-- | synapse/federation/transport/server.py | 2 | ||||
-rw-r--r-- | synapse/handlers/_base.py | 15 | ||||
-rw-r--r-- | synapse/handlers/federation.py | 223 | ||||
-rw-r--r-- | synapse/handlers/room.py | 4 | ||||
-rw-r--r-- | synapse/rest/client/v1/room.py | 132 | ||||
-rw-r--r-- | synapse/util/thirdpartyinvites.py | 62 |
11 files changed, 356 insertions, 151 deletions
diff --git a/synapse/api/auth.py b/synapse/api/auth.py index e3b8c3099a..6607d08488 100644 --- a/synapse/api/auth.py +++ b/synapse/api/auth.py @@ -14,15 +14,19 @@ # limitations under the License. """This module contains classes for authenticating the user.""" +from nacl.exceptions import BadSignatureError from twisted.internet import defer from synapse.api.constants import EventTypes, Membership, JoinRules from synapse.api.errors import AuthError, Codes, SynapseError -from synapse.util.logutils import log_function from synapse.types import RoomID, UserID, EventID +from synapse.util.logutils import log_function +from synapse.util.thirdpartyinvites import ThirdPartyInvites +from unpaddedbase64 import decode_base64 import logging +import nacl.signing import pymacaroons logger = logging.getLogger(__name__) @@ -31,6 +35,7 @@ logger = logging.getLogger(__name__) AuthEventTypes = ( EventTypes.Create, EventTypes.Member, EventTypes.PowerLevels, EventTypes.JoinRules, EventTypes.RoomHistoryVisibility, + EventTypes.ThirdPartyInvite, ) @@ -341,7 +346,8 @@ class Auth(object): pass elif join_rule == JoinRules.INVITE: if not caller_in_room and not caller_invited: - raise AuthError(403, "You are not invited to this room.") + if not self._verify_third_party_invite(event, auth_events): + raise AuthError(403, "You are not invited to this room.") else: # TODO (erikj): may_join list # TODO (erikj): private rooms @@ -367,6 +373,31 @@ class Auth(object): return True + def _verify_third_party_invite(self, event, auth_events): + for key in ThirdPartyInvites.JOIN_KEYS: + if key not in event.content: + return False + token = event.content["token"] + invite_event = auth_events.get( + (EventTypes.ThirdPartyInvite, token,) + ) + if not invite_event: + return False + try: + public_key = event.content["public_key"] + key_validity_url = event.content["key_validity_url"] + if invite_event.content["public_key"] != public_key: + return False + if invite_event.content["key_validity_url"] != key_validity_url: + return False + verify_key = nacl.signing.VerifyKey(decode_base64(public_key)) + encoded_signature = event.content["signature"] + signature = decode_base64(encoded_signature) + verify_key.verify(token, signature) + return True + except (KeyError, BadSignatureError,): + return False + def _get_power_level_event(self, auth_events): key = (EventTypes.PowerLevels, "", ) return auth_events.get(key) diff --git a/synapse/api/constants.py b/synapse/api/constants.py index 008ee64727..41125e8719 100644 --- a/synapse/api/constants.py +++ b/synapse/api/constants.py @@ -63,6 +63,7 @@ class EventTypes(object): PowerLevels = "m.room.power_levels" Aliases = "m.room.aliases" Redaction = "m.room.redaction" + ThirdPartyInvite = "m.room.third_party_invite" RoomHistoryVisibility = "m.room.history_visibility" CanonicalAlias = "m.room.canonical_alias" diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index f5e346cdbc..bf22913d4f 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -25,6 +25,7 @@ from synapse.api.errors import ( from synapse.util import unwrapFirstError from synapse.util.caches.expiringcache import ExpiringCache from synapse.util.logutils import log_function +from synapse.util.thirdpartyinvites import ThirdPartyInvites from synapse.events import FrozenEvent import synapse.metrics @@ -356,18 +357,22 @@ class FederationClient(FederationBase): defer.returnValue(signed_auth) @defer.inlineCallbacks - def make_join(self, destinations, room_id, user_id): + def make_join(self, destinations, room_id, user_id, content): for destination in destinations: if destination == self.server_name: continue + args = {} + if ThirdPartyInvites.has_join_keys(content): + ThirdPartyInvites.copy_join_keys(content, args) try: ret = yield self.transport_layer.make_join( - destination, room_id, user_id + destination, room_id, user_id, args ) pdu_dict = ret["event"] + logger.debug("Got response to make_join: %s", pdu_dict) defer.returnValue( diff --git a/synapse/federation/federation_server.py b/synapse/federation/federation_server.py index 725c6f3fa5..d71ab44271 100644 --- a/synapse/federation/federation_server.py +++ b/synapse/federation/federation_server.py @@ -23,10 +23,12 @@ from synapse.util.logutils import log_function from synapse.events import FrozenEvent import synapse.metrics -from synapse.api.errors import FederationError, SynapseError +from synapse.api.errors import FederationError, SynapseError, Codes from synapse.crypto.event_signing import compute_event_signature +from synapse.util.thirdpartyinvites import ThirdPartyInvites + import simplejson as json import logging @@ -228,8 +230,19 @@ class FederationServer(FederationBase): ) @defer.inlineCallbacks - def on_make_join_request(self, room_id, user_id): - pdu = yield self.handler.on_make_join_request(room_id, user_id) + def on_make_join_request(self, room_id, user_id, query): + threepid_details = {} + if ThirdPartyInvites.has_join_keys(query): + for k in ThirdPartyInvites.JOIN_KEYS: + if not isinstance(query[k], list) or len(query[k]) != 1: + raise FederationError( + "FATAL", + Codes.MISSING_PARAM, + "key %s value %s" % (k, query[k],), + None + ) + threepid_details[k] = query[k][0] + pdu = yield self.handler.on_make_join_request(room_id, user_id, threepid_details) time_now = self._clock.time_msec() defer.returnValue({"event": pdu.get_pdu_json(time_now)}) diff --git a/synapse/federation/transport/client.py b/synapse/federation/transport/client.py index ced703364b..ae4195e83a 100644 --- a/synapse/federation/transport/client.py +++ b/synapse/federation/transport/client.py @@ -160,13 +160,14 @@ class TransportLayerClient(object): @defer.inlineCallbacks @log_function - def make_join(self, destination, room_id, user_id, retry_on_dns_fail=True): + def make_join(self, destination, room_id, user_id, args={}): path = PREFIX + "/make_join/%s/%s" % (room_id, user_id) content = yield self.client.get_json( destination=destination, path=path, - retry_on_dns_fail=retry_on_dns_fail, + args=args, + retry_on_dns_fail=True, ) defer.returnValue(content) diff --git a/synapse/federation/transport/server.py b/synapse/federation/transport/server.py index 36f250e1a3..6e394f039e 100644 --- a/synapse/federation/transport/server.py +++ b/synapse/federation/transport/server.py @@ -292,7 +292,7 @@ class FederationMakeJoinServlet(BaseFederationServlet): @defer.inlineCallbacks def on_GET(self, origin, content, query, context, user_id): - content = yield self.handler.on_make_join_request(context, user_id) + content = yield self.handler.on_make_join_request(context, user_id, query) defer.returnValue((200, content)) diff --git a/synapse/handlers/_base.py b/synapse/handlers/_base.py index c488ee0f6d..59c86187a9 100644 --- a/synapse/handlers/_base.py +++ b/synapse/handlers/_base.py @@ -21,6 +21,7 @@ from synapse.api.constants import Membership, EventTypes from synapse.types import UserID, RoomAlias from synapse.util.logcontext import PreserveLoggingContext +from synapse.util.thirdpartyinvites import ThirdPartyInvites import logging @@ -123,6 +124,20 @@ class BaseHandler(object): ) ) + if ( + event.type == EventTypes.Member and + event.content["membership"] == Membership.JOIN and + ThirdPartyInvites.has_join_keys(event.content) + ): + yield ThirdPartyInvites.check_key_valid( + self.hs.get_simple_http_client(), + event + ) + + (event_stream_id, max_stream_id) = yield self.store.persist_event( + event, context=context + ) + federation_handler = self.hs.get_handlers().federation_handler if event.type == EventTypes.Member: diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 3882ba79ed..8d99101619 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -39,7 +39,7 @@ from twisted.internet import defer import itertools import logging - +from synapse.util.thirdpartyinvites import ThirdPartyInvites logger = logging.getLogger(__name__) @@ -125,72 +125,60 @@ class FederationHandler(BaseHandler): ) if not is_in_room and not event.internal_metadata.is_outlier(): logger.debug("Got event for room we're not in.") + current_state = state - try: - event_stream_id, max_stream_id = yield self._persist_auth_tree( - auth_chain, state, event - ) - except AuthError as e: - raise FederationError( - "ERROR", - e.code, - e.msg, - affected=event.event_id, - ) + event_ids = set() + if state: + event_ids |= {e.event_id for e in state} + if auth_chain: + event_ids |= {e.event_id for e in auth_chain} - else: - event_ids = set() - if state: - event_ids |= {e.event_id for e in state} - if auth_chain: - event_ids |= {e.event_id for e in auth_chain} - - seen_ids = set( - (yield self.store.have_events(event_ids)).keys() - ) + seen_ids = set( + (yield self.store.have_events(event_ids)).keys() + ) - if state and auth_chain is not None: - # If we have any state or auth_chain given to us by the replication - # layer, then we should handle them (if we haven't before.) + if state and auth_chain is not None: + # If we have any state or auth_chain given to us by the replication + # layer, then we should handle them (if we haven't before.) - event_infos = [] + event_infos = [] - for e in itertools.chain(auth_chain, state): - if e.event_id in seen_ids: - continue - e.internal_metadata.outlier = True - auth_ids = [e_id for e_id, _ in e.auth_events] - auth = { - (e.type, e.state_key): e for e in auth_chain - if e.event_id in auth_ids or e.type == EventTypes.Create - } - event_infos.append({ - "event": e, - "auth_events": auth, - }) - seen_ids.add(e.event_id) + for e in itertools.chain(auth_chain, state): + if e.event_id in seen_ids: + continue + e.internal_metadata.outlier = True + auth_ids = [e_id for e_id, _ in e.auth_events] + auth = { + (e.type, e.state_key): e for e in auth_chain + if e.event_id in auth_ids + } + event_infos.append({ + "event": e, + "auth_events": auth, + }) + seen_ids.add(e.event_id) - yield self._handle_new_events( - origin, - event_infos, - outliers=True - ) + yield self._handle_new_events( + origin, + event_infos, + outliers=True + ) - try: - _, event_stream_id, max_stream_id = yield self._handle_new_event( - origin, - event, - state=state, - backfilled=backfilled, - current_state=current_state, - ) - except AuthError as e: - raise FederationError( - "ERROR", - e.code, - e.msg, - affected=event.event_id, - ) + try: + _, event_stream_id, max_stream_id = yield self._handle_new_event( + origin, + event, + state=state, + backfilled=backfilled, + current_state=current_state, + ) + except AuthError as e: + raise FederationError( + "ERROR", + e.code, + e.msg, + affected=event.event_id, + ) # if we're receiving valid events from an origin, # it's probably a good idea to mark it as not in retry-state @@ -584,7 +572,8 @@ class FederationHandler(BaseHandler): origin, pdu = yield self.replication_layer.make_join( target_hosts, room_id, - joinee + joinee, + content ) logger.debug("Got response to make_join: %s", pdu) @@ -661,8 +650,35 @@ class FederationHandler(BaseHandler): # FIXME pass - event_stream_id, max_stream_id = yield self._persist_auth_tree( - auth_chain, state, event + ev_infos = [] + for e in itertools.chain(state, auth_chain): + if e.event_id == event.event_id: + continue + + e.internal_metadata.outlier = True + auth_ids = [e_id for e_id, _ in e.auth_events] + ev_infos.append({ + "event": e, + "auth_events": { + (e.type, e.state_key): e for e in auth_chain + if e.event_id in auth_ids + } + }) + + yield self._handle_new_events(origin, ev_infos, outliers=True) + + auth_ids = [e_id for e_id, _ in event.auth_events] + auth_events = { + (e.type, e.state_key): e for e in auth_chain + if e.event_id in auth_ids + } + + _, event_stream_id, max_stream_id = yield self._handle_new_event( + origin, + new_event, + state=state, + current_state=state, + auth_events=auth_events, ) with PreserveLoggingContext(): @@ -697,14 +713,18 @@ class FederationHandler(BaseHandler): @defer.inlineCallbacks @log_function - def on_make_join_request(self, room_id, user_id): + def on_make_join_request(self, room_id, user_id, query): """ We've received a /make_join/ request, so we create a partial join event for the room and return that. We don *not* persist or process it until the other server has signed it and sent it back. """ + event_content = {"membership": Membership.JOIN} + if ThirdPartyInvites.has_join_keys(query): + ThirdPartyInvites.copy_join_keys(query, event_content) + builder = self.event_builder_factory.new({ "type": EventTypes.Member, - "content": {"membership": Membership.JOIN}, + "content": event_content, "room_id": room_id, "sender": user_id, "state_key": user_id, @@ -716,6 +736,9 @@ class FederationHandler(BaseHandler): self.auth.check(event, auth_events=context.current_state) + if ThirdPartyInvites.has_join_keys(event.content): + ThirdPartyInvites.check_key_valid(self.hs.get_simple_http_client(), event) + defer.returnValue(event) @defer.inlineCallbacks @@ -1012,76 +1035,6 @@ class FederationHandler(BaseHandler): ) @defer.inlineCallbacks - def _persist_auth_tree(self, auth_events, state, event): - """Checks the auth chain is valid (and passes auth checks) for the - state and event. Then persists the auth chain and state atomically. - Persists the event seperately. - - Returns: - 2-tuple of (event_stream_id, max_stream_id) from the persist_event - call for `event` - """ - events_to_context = {} - for e in itertools.chain(auth_events, state): - ctx = yield self.state_handler.compute_event_context( - e, outlier=True, - ) - events_to_context[e.event_id] = ctx - e.internal_metadata.outlier = True - - event_map = { - e.event_id: e - for e in auth_events - } - - create_event = None - for e in auth_events: - if (e.type, e.state_key) == (EventTypes.Create, ""): - create_event = e - break - - for e in itertools.chain(auth_events, state, [event]): - auth_for_e = { - (event_map[e_id].type, event_map[e_id].state_key): event_map[e_id] - for e_id, _ in e.auth_events - } - if create_event: - auth_for_e[(EventTypes.Create, "")] = create_event - - try: - self.auth.check(e, auth_events=auth_for_e) - except AuthError as err: - logger.warn( - "Rejecting %s because %s", - e.event_id, err.msg - ) - - if e == event: - raise - events_to_context[e.event_id].rejected = RejectedReason.AUTH_ERROR - - yield self.store.persist_events( - [ - (e, events_to_context[e.event_id]) - for e in itertools.chain(auth_events, state) - ], - is_new_state=False, - ) - - new_event_context = yield self.state_handler.compute_event_context( - event, old_state=state, outlier=False, - ) - - event_stream_id, max_stream_id = yield self.store.persist_event( - event, new_event_context, - backfilled=False, - is_new_state=True, - current_state=state, - ) - - defer.returnValue((event_stream_id, max_stream_id)) - - @defer.inlineCallbacks def _prep_event(self, origin, event, state=None, backfilled=False, current_state=None, auth_events=None): outlier = event.internal_metadata.is_outlier() diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py index 3364a5de14..b856b424a7 100644 --- a/synapse/handlers/room.py +++ b/synapse/handlers/room.py @@ -483,6 +483,10 @@ class RoomMemberHandler(BaseHandler): should_do_dance = not self.hs.is_mine(inviter) room_hosts = [inviter.domain] + elif "sender" in event.content: + inviter = UserID.from_string(event.content["sender"]) + should_do_dance = not self.hs.is_mine(inviter) + room_hosts = [inviter.domain] else: # return the same error as join_room_alias does raise SynapseError(404, "No known servers") diff --git a/synapse/rest/client/v1/room.py b/synapse/rest/client/v1/room.py index 23871f161e..ba37061290 100644 --- a/synapse/rest/client/v1/room.py +++ b/synapse/rest/client/v1/room.py @@ -17,7 +17,7 @@ from twisted.internet import defer from base import ClientV1RestServlet, client_path_pattern -from synapse.api.errors import SynapseError, Codes +from synapse.api.errors import SynapseError, Codes, AuthError from synapse.streams.config import PaginationConfig from synapse.api.constants import EventTypes, Membership from synapse.types import UserID, RoomID, RoomAlias @@ -26,7 +26,7 @@ from synapse.events.utils import serialize_event import simplejson as json import logging import urllib - +from synapse.util.thirdpartyinvites import ThirdPartyInvites logger = logging.getLogger(__name__) @@ -415,9 +415,35 @@ class RoomMembershipRestServlet(ClientV1RestServlet): # target user is you unless it is an invite state_key = user.to_string() if membership_action in ["invite", "ban", "kick"]: - if "user_id" not in content: - raise SynapseError(400, "Missing user_id key.") - state_key = content["user_id"] + try: + state_key = content["user_id"] + except KeyError: + if ( + membership_action != "invite" or + not ThirdPartyInvites.has_invite_keys(content) + ): + raise SynapseError(400, "Missing user_id key.") + + + id_server = content["id_server"] + medium = content["medium"] + address = content["address"] + display_name = content["display_name"] + state_key = yield self._lookup_3pid_user(id_server, medium, address) + if not state_key: + yield self._make_and_store_3pid_invite( + id_server, + display_name, + medium, + address, + room_id, + user, + token_id, + txn_id=txn_id + ) + defer.returnValue((200, {})) + return + # make sure it looks like a user ID; it'll throw if it's invalid. UserID.from_string(state_key) @@ -425,10 +451,18 @@ class RoomMembershipRestServlet(ClientV1RestServlet): membership_action = "leave" msg_handler = self.handlers.message_handler + + event_content = { + "membership": unicode(membership_action), + } + + if membership_action == "join" and ThirdPartyInvites.has_join_keys(content): + ThirdPartyInvites.copy_join_keys(content, event_content) + yield msg_handler.create_and_send_event( { "type": EventTypes.Member, - "content": {"membership": unicode(membership_action)}, + "content": event_content, "room_id": room_id, "sender": user.to_string(), "state_key": state_key, @@ -440,6 +474,92 @@ class RoomMembershipRestServlet(ClientV1RestServlet): defer.returnValue((200, {})) @defer.inlineCallbacks + def _lookup_3pid_user(self, id_server, medium, address): + """Looks up a 3pid in the passed identity server. + + Args: + id_server (str): The server name (including port, if required) + of the identity server to use. + medium (str): The type of the third party identifier (e.g. "email"). + address (str): The third party identifier (e.g. "foo@example.com"). + + Returns: + (str) the matrix ID of the 3pid, or None if it is not recognized. + """ + try: + data = yield self.hs.get_simple_http_client().get_json( + "https://%s/_matrix/identity/api/v1/lookup" % (id_server,), + { + "medium": medium, + "address": address, + } + ) + + if "mxid" in data: + # TODO: Validate the response signature and such + defer.returnValue(data["mxid"]) + except IOError: + # TODO: Log something maybe? + defer.returnValue(None) + + @defer.inlineCallbacks + def _make_and_store_3pid_invite( + self, + id_server, + display_name, + medium, + address, + room_id, + user, + token_id, + txn_id + ): + token, public_key, key_validity_url = ( + yield self._ask_id_server_for_third_party_invite( + id_server, + medium, + address, + room_id, + user.to_string() + ) + ) + msg_handler = self.handlers.message_handler + yield msg_handler.create_and_send_event( + { + "type": EventTypes.ThirdPartyInvite, + "content": { + "display_name": display_name, + "key_validity_url": key_validity_url, + "public_key": public_key, + }, + "room_id": room_id, + "sender": user.to_string(), + "state_key": token, + }, + token_id=token_id, + txn_id=txn_id, + ) + + @defer.inlineCallbacks + def _ask_id_server_for_third_party_invite( + self, id_server, medium, address, room_id, sender): + is_url = "https://%s/_matrix/identity/api/v1/nonce-it-up" % (id_server,) + data = yield self.hs.get_simple_http_client().post_urlencoded_get_json( + is_url, + { + "medium": medium, + "address": address, + "room_id": room_id, + "sender": sender, + } + ) + # TODO: Check for success + token = data["token"] + public_key = data["public_key"] + key_validity_url = "https://%s/_matrix/identity/api/v1/pubkey/isvalid" % (id_server,) + defer.returnValue((token, public_key, key_validity_url)) + + @defer.inlineCallbacks def on_PUT(self, request, room_id, membership_action, txn_id): try: defer.returnValue( diff --git a/synapse/util/thirdpartyinvites.py b/synapse/util/thirdpartyinvites.py new file mode 100644 index 0000000000..c30279de67 --- /dev/null +++ b/synapse/util/thirdpartyinvites.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 OpenMarket Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from twisted.internet import defer +from synapse.api.errors import AuthError + + +class ThirdPartyInvites(object): + INVITE_KEYS = {"id_server", "medium", "address", "display_name"} + + JOIN_KEYS = { + "token", + "public_key", + "key_validity_url", + "signature", + "sender", + } + + @classmethod + def has_invite_keys(cls, content): + for key in cls.INVITE_KEYS: + if key not in content: + return False + return True + + @classmethod + def has_join_keys(cls, content): + for key in cls.JOIN_KEYS: + if key not in content: + return False + return True + + @classmethod + def copy_join_keys(cls, src, dst): + for key in cls.JOIN_KEYS: + if key in src: + dst[key] = src[key] + + @classmethod + @defer.inlineCallbacks + def check_key_valid(cls, http_client, event): + try: + response = yield http_client.get_json( + event.content["key_validity_url"], + {"public_key": event.content["public_key"]} + ) + if not response["valid"]: + raise AuthError(403, "Third party certificate was invalid") + except IOError: + raise AuthError(403, "Third party certificate could not be checked") |