summary refs log tree commit diff
path: root/synapse
diff options
context:
space:
mode:
Diffstat (limited to 'synapse')
-rw-r--r--synapse/api/auth.py58
-rw-r--r--synapse/api/constants.py2
-rwxr-xr-xsynapse/app/synctl.py4
-rw-r--r--synapse/events/utils.py5
-rw-r--r--synapse/federation/federation_client.py9
-rw-r--r--synapse/federation/federation_server.py19
-rw-r--r--synapse/federation/transport/client.py5
-rw-r--r--synapse/federation/transport/server.py2
-rw-r--r--synapse/handlers/_base.py37
-rw-r--r--synapse/handlers/federation.py16
-rw-r--r--synapse/handlers/message.py4
-rw-r--r--synapse/handlers/room.py27
-rw-r--r--synapse/rest/client/v1/room.py132
-rw-r--r--synapse/rest/client/v2_alpha/receipts.py4
-rw-r--r--synapse/util/thirdpartyinvites.py62
15 files changed, 361 insertions, 25 deletions
diff --git a/synapse/api/auth.py b/synapse/api/auth.py
index 847ff60671..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.types import RoomID, UserID, EventID
 from synapse.util.logutils import log_function
-from synapse.types import UserID, EventID
+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,
 )
 
 
@@ -80,6 +85,15 @@ class Auth(object):
                     "Room %r does not exist" % (event.room_id,)
                 )
 
+            creating_domain = RoomID.from_string(event.room_id).domain
+            originating_domain = UserID.from_string(event.sender).domain
+            if creating_domain != originating_domain:
+                if not self.can_federate(event, auth_events):
+                    raise AuthError(
+                        403,
+                        "This room has been marked as unfederatable."
+                    )
+
             # FIXME: Temp hack
             if event.type == EventTypes.Aliases:
                 return True
@@ -219,6 +233,11 @@ class Auth(object):
                 user_id, room_id, repr(member)
             ))
 
+    def can_federate(self, event, auth_events):
+        creation_event = auth_events.get((EventTypes.Create, ""))
+
+        return creation_event.content.get("m.federate", True) is True
+
     @log_function
     def is_membership_change_allowed(self, event, auth_events):
         membership = event.content["membership"]
@@ -234,6 +253,15 @@ class Auth(object):
 
         target_user_id = event.state_key
 
+        creating_domain = RoomID.from_string(event.room_id).domain
+        target_domain = UserID.from_string(target_user_id).domain
+        if creating_domain != target_domain:
+            if not self.can_federate(event, auth_events):
+                raise AuthError(
+                    403,
+                    "This room has been marked as unfederatable."
+                )
+
         # get info about the caller
         key = (EventTypes.Member, event.user_id, )
         caller = auth_events.get(key)
@@ -318,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
@@ -344,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 3385664394..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"
@@ -83,3 +84,4 @@ class RejectedReason(object):
 class RoomCreationPreset(object):
     PRIVATE_CHAT = "private_chat"
     PUBLIC_CHAT = "public_chat"
+    TRUSTED_PRIVATE_CHAT = "trusted_private_chat"
diff --git a/synapse/app/synctl.py b/synapse/app/synctl.py
index 1078d19b79..5d82beed0e 100755
--- a/synapse/app/synctl.py
+++ b/synapse/app/synctl.py
@@ -32,9 +32,9 @@ def start(configfile):
     print "Starting ...",
     args = SYNAPSE
     args.extend(["--daemonize", "-c", configfile])
-    cwd = os.path.dirname(os.path.abspath(__file__))
+
     try:
-        subprocess.check_call(args, cwd=cwd)
+        subprocess.check_call(args)
         print GREEN + "started" + NORMAL
     except subprocess.CalledProcessError as e:
         print (
diff --git a/synapse/events/utils.py b/synapse/events/utils.py
index 7bd78343f0..b36eec0993 100644
--- a/synapse/events/utils.py
+++ b/synapse/events/utils.py
@@ -103,7 +103,10 @@ def format_event_raw(d):
 def format_event_for_client_v1(d):
     d["user_id"] = d.pop("sender", None)
 
-    move_keys = ("age", "redacted_because", "replaces_state", "prev_content")
+    move_keys = (
+        "age", "redacted_because", "replaces_state", "prev_content",
+        "invite_room_state",
+    )
     for key in move_keys:
         if key in d["unsigned"]:
             d[key] = d["unsigned"][key]
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 60ac6617ae..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,16 @@ 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
         )
@@ -131,16 +142,35 @@ class BaseHandler(object):
 
         if event.type == EventTypes.Member:
             if event.content["membership"] == Membership.INVITE:
+                event.unsigned["invite_room_state"] = [
+                    {
+                        "type": e.type,
+                        "state_key": e.state_key,
+                        "content": e.content,
+                        "sender": e.sender,
+                    }
+                    for k, e in context.current_state.items()
+                    if e.type in (
+                        EventTypes.JoinRules,
+                        EventTypes.CanonicalAlias,
+                        EventTypes.RoomAvatar,
+                        EventTypes.Name,
+                    )
+                ]
+
                 invitee = UserID.from_string(event.state_key)
                 if not self.hs.is_mine(invitee):
                     # TODO: Can we add signature from remote server in a nicer
                     # way? If we have been invited by a remote server, we need
                     # to get them to sign the event.
+
                     returned_invite = yield federation_handler.send_invite(
                         invitee.domain,
                         event,
                     )
 
+                    event.unsigned.pop("room_state", None)
+
                     # TODO: Make sure the signatures actually are correct.
                     event.signatures.update(
                         returned_invite.signatures
@@ -161,6 +191,10 @@ class BaseHandler(object):
                         "You don't have permission to redact events"
                     )
 
+        (event_stream_id, max_stream_id) = yield self.store.persist_event(
+            event, context=context
+        )
+
         destinations = set(extra_destinations)
         for k, s in context.current_state.items():
             try:
@@ -189,6 +223,9 @@ class BaseHandler(object):
 
         notify_d.addErrback(log_failure)
 
+        # If invite, remove room_state from unsigned before sending.
+        event.unsigned.pop("invite_room_state", None)
+
         federation_handler.handle_new_event(
             event, destinations=destinations,
         )
diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py
index f4dce712f9..d3d172b7b4 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__)
 
@@ -572,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)
@@ -712,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,
@@ -731,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
diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py
index bda8eb5f3f..30949ff7a6 100644
--- a/synapse/handlers/message.py
+++ b/synapse/handlers/message.py
@@ -383,8 +383,12 @@ class MessageHandler(BaseHandler):
             }
 
             if event.membership == Membership.INVITE:
+                time_now = self.clock.time_msec()
                 d["inviter"] = event.sender
 
+                invite_event = yield self.store.get_event(event.event_id)
+                d["invite"] = serialize_event(invite_event, time_now, as_client_event)
+
             rooms_ret.append(d)
 
             if event.membership not in (Membership.JOIN, Membership.LEAVE):
diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py
index 773f0a2e92..b856b424a7 100644
--- a/synapse/handlers/room.py
+++ b/synapse/handlers/room.py
@@ -41,6 +41,11 @@ class RoomCreationHandler(BaseHandler):
             "history_visibility": "shared",
             "original_invitees_have_ops": False,
         },
+        RoomCreationPreset.TRUSTED_PRIVATE_CHAT: {
+            "join_rules": JoinRules.INVITE,
+            "history_visibility": "shared",
+            "original_invitees_have_ops": True,
+        },
         RoomCreationPreset.PUBLIC_CHAT: {
             "join_rules": JoinRules.PUBLIC,
             "history_visibility": "shared",
@@ -149,12 +154,16 @@ class RoomCreationHandler(BaseHandler):
         for val in raw_initial_state:
             initial_state[(val["type"], val.get("state_key", ""))] = val["content"]
 
+        creation_content = config.get("creation_content", {})
+
         user = UserID.from_string(user_id)
         creation_events = self._create_events_for_new_room(
             user, room_id,
             preset_config=preset_config,
             invite_list=invite_list,
             initial_state=initial_state,
+            creation_content=creation_content,
+            room_alias=room_alias,
         )
 
         msg_handler = self.hs.get_handlers().message_handler
@@ -202,7 +211,8 @@ class RoomCreationHandler(BaseHandler):
         defer.returnValue(result)
 
     def _create_events_for_new_room(self, creator, room_id, preset_config,
-                                    invite_list, initial_state):
+                                    invite_list, initial_state, creation_content,
+                                    room_alias):
         config = RoomCreationHandler.PRESETS_DICT[preset_config]
 
         creator_id = creator.to_string()
@@ -224,9 +234,10 @@ class RoomCreationHandler(BaseHandler):
 
             return e
 
+        creation_content.update({"creator": creator.to_string()})
         creation_event = create(
             etype=EventTypes.Create,
-            content={"creator": creator.to_string()},
+            content=creation_content,
         )
 
         join_event = create(
@@ -271,6 +282,14 @@ class RoomCreationHandler(BaseHandler):
 
             returned_events.append(power_levels_event)
 
+        if room_alias and (EventTypes.CanonicalAlias, '') not in initial_state:
+            room_alias_event = create(
+                etype=EventTypes.CanonicalAlias,
+                content={"alias": room_alias.to_string()},
+            )
+
+            returned_events.append(room_alias_event)
+
         if (EventTypes.JoinRules, '') not in initial_state:
             join_rules_event = create(
                 etype=EventTypes.JoinRules,
@@ -464,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/rest/client/v2_alpha/receipts.py b/synapse/rest/client/v2_alpha/receipts.py
index 52e99f54d5..b107b7ce17 100644
--- a/synapse/rest/client/v2_alpha/receipts.py
+++ b/synapse/rest/client/v2_alpha/receipts.py
@@ -15,6 +15,7 @@
 
 from twisted.internet import defer
 
+from synapse.api.errors import SynapseError
 from synapse.http.servlet import RestServlet
 from ._base import client_v2_pattern
 
@@ -41,6 +42,9 @@ class ReceiptRestServlet(RestServlet):
     def on_POST(self, request, room_id, receipt_type, event_id):
         user, _ = yield self.auth.get_user_by_req(request)
 
+        if receipt_type != "m.read":
+            raise SynapseError(400, "Receipt type must be 'm.read'")
+
         yield self.receipts_handler.received_client_receipt(
             room_id,
             receipt_type,
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")