930 lines
36 KiB
Python
930 lines
36 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2015, 2016 OpenMarket Ltd
|
|
# Copyright 2017 Vector Creations Ltd
|
|
# Copyright 2018 New Vector 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.
|
|
|
|
"""Utilities for interacting with Identity Servers"""
|
|
|
|
import logging
|
|
import urllib.parse
|
|
|
|
from canonicaljson import json
|
|
from signedjson.key import decode_verify_key_bytes
|
|
from signedjson.sign import verify_signed_json
|
|
from unpaddedbase64 import decode_base64
|
|
|
|
from twisted.internet.error import TimeoutError
|
|
|
|
from synapse.api.errors import (
|
|
AuthError,
|
|
CodeMessageException,
|
|
Codes,
|
|
HttpResponseException,
|
|
SynapseError,
|
|
)
|
|
from synapse.config.emailconfig import ThreepidBehaviour
|
|
from synapse.http.client import SimpleHttpClient
|
|
from synapse.util.hash import sha256_and_url_safe_base64
|
|
from synapse.util.stringutils import assert_valid_client_secret, random_string
|
|
|
|
from ._base import BaseHandler
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
id_server_scheme = "https://"
|
|
|
|
|
|
class IdentityHandler(BaseHandler):
|
|
def __init__(self, hs):
|
|
super(IdentityHandler, self).__init__(hs)
|
|
|
|
self.http_client = SimpleHttpClient(hs)
|
|
# We create a blacklisting instance of SimpleHttpClient for contacting identity
|
|
# servers specified by clients
|
|
self.blacklisting_http_client = SimpleHttpClient(
|
|
hs, ip_blacklist=hs.config.federation_ip_range_blacklist
|
|
)
|
|
self.federation_http_client = hs.get_http_client()
|
|
self.hs = hs
|
|
|
|
async def threepid_from_creds(self, id_server, creds):
|
|
"""
|
|
Retrieve and validate a threepid identifier from a "credentials" dictionary against a
|
|
given identity server
|
|
|
|
Args:
|
|
id_server (str): The identity server to validate 3PIDs against. Must be a
|
|
complete URL including the protocol (http(s)://)
|
|
|
|
creds (dict[str, str]): Dictionary containing the following keys:
|
|
* client_secret|clientSecret: A unique secret str provided by the client
|
|
* sid: The ID of the validation session
|
|
|
|
Returns:
|
|
Deferred[dict[str,str|int]|None]: A dictionary consisting of response params to
|
|
the /getValidated3pid endpoint of the Identity Service API, or None if the
|
|
threepid was not found
|
|
"""
|
|
client_secret = creds.get("client_secret") or creds.get("clientSecret")
|
|
if not client_secret:
|
|
raise SynapseError(
|
|
400, "Missing param client_secret in creds", errcode=Codes.MISSING_PARAM
|
|
)
|
|
assert_valid_client_secret(client_secret)
|
|
|
|
session_id = creds.get("sid")
|
|
if not session_id:
|
|
raise SynapseError(
|
|
400, "Missing param session_id in creds", errcode=Codes.MISSING_PARAM
|
|
)
|
|
|
|
query_params = {"sid": session_id, "client_secret": client_secret}
|
|
|
|
url = id_server + "/_matrix/identity/api/v1/3pid/getValidated3pid"
|
|
|
|
try:
|
|
data = await self.http_client.get_json(url, query_params)
|
|
except TimeoutError:
|
|
raise SynapseError(500, "Timed out contacting identity server")
|
|
except HttpResponseException as e:
|
|
logger.info(
|
|
"%s returned %i for threepid validation for: %s",
|
|
id_server,
|
|
e.code,
|
|
creds,
|
|
)
|
|
return None
|
|
|
|
# Old versions of Sydent return a 200 http code even on a failed validation
|
|
# check. Thus, in addition to the HttpResponseException check above (which
|
|
# checks for non-200 errors), we need to make sure validation_session isn't
|
|
# actually an error, identified by the absence of a "medium" key
|
|
# See https://github.com/matrix-org/sydent/issues/215 for details
|
|
if "medium" in data:
|
|
return data
|
|
|
|
logger.info("%s reported non-validated threepid: %s", id_server, creds)
|
|
return None
|
|
|
|
async def bind_threepid(
|
|
self, client_secret, sid, mxid, id_server, id_access_token=None, use_v2=True
|
|
):
|
|
"""Bind a 3PID to an identity server
|
|
|
|
Args:
|
|
client_secret (str): A unique secret provided by the client
|
|
|
|
sid (str): The ID of the validation session
|
|
|
|
mxid (str): The MXID to bind the 3PID to
|
|
|
|
id_server (str): The domain of the identity server to query
|
|
|
|
id_access_token (str): The access token to authenticate to the identity
|
|
server with, if necessary. Required if use_v2 is true
|
|
|
|
use_v2 (bool): Whether to use v2 Identity Service API endpoints. Defaults to True
|
|
|
|
Returns:
|
|
Deferred[dict]: The response from the identity server
|
|
"""
|
|
logger.debug("Proxying threepid bind request for %s to %s", mxid, id_server)
|
|
|
|
# If an id_access_token is not supplied, force usage of v1
|
|
if id_access_token is None:
|
|
use_v2 = False
|
|
|
|
# Decide which API endpoint URLs to use
|
|
headers = {}
|
|
bind_data = {"sid": sid, "client_secret": client_secret, "mxid": mxid}
|
|
if use_v2:
|
|
bind_url = "https://%s/_matrix/identity/v2/3pid/bind" % (id_server,)
|
|
headers["Authorization"] = create_id_access_token_header(id_access_token)
|
|
else:
|
|
bind_url = "https://%s/_matrix/identity/api/v1/3pid/bind" % (id_server,)
|
|
|
|
try:
|
|
# Use the blacklisting http client as this call is only to identity servers
|
|
# provided by a client
|
|
data = await self.blacklisting_http_client.post_json_get_json(
|
|
bind_url, bind_data, headers=headers
|
|
)
|
|
|
|
# Remember where we bound the threepid
|
|
await self.store.add_user_bound_threepid(
|
|
user_id=mxid,
|
|
medium=data["medium"],
|
|
address=data["address"],
|
|
id_server=id_server,
|
|
)
|
|
|
|
return data
|
|
except HttpResponseException as e:
|
|
if e.code != 404 or not use_v2:
|
|
logger.error("3PID bind failed with Matrix error: %r", e)
|
|
raise e.to_synapse_error()
|
|
except TimeoutError:
|
|
raise SynapseError(500, "Timed out contacting identity server")
|
|
except CodeMessageException as e:
|
|
data = json.loads(e.msg) # XXX WAT?
|
|
return data
|
|
|
|
logger.info("Got 404 when POSTing JSON %s, falling back to v1 URL", bind_url)
|
|
res = await self.bind_threepid(
|
|
client_secret, sid, mxid, id_server, id_access_token, use_v2=False
|
|
)
|
|
return res
|
|
|
|
async def try_unbind_threepid(self, mxid, threepid):
|
|
"""Attempt to remove a 3PID from an identity server, or if one is not provided, all
|
|
identity servers we're aware the binding is present on
|
|
|
|
Args:
|
|
mxid (str): Matrix user ID of binding to be removed
|
|
threepid (dict): Dict with medium & address of binding to be
|
|
removed, and an optional id_server.
|
|
|
|
Raises:
|
|
SynapseError: If we failed to contact the identity server
|
|
|
|
Returns:
|
|
Deferred[bool]: True on success, otherwise False if the identity
|
|
server doesn't support unbinding (or no identity server found to
|
|
contact).
|
|
"""
|
|
if threepid.get("id_server"):
|
|
id_servers = [threepid["id_server"]]
|
|
else:
|
|
id_servers = await self.store.get_id_servers_user_bound(
|
|
user_id=mxid, medium=threepid["medium"], address=threepid["address"]
|
|
)
|
|
|
|
# We don't know where to unbind, so we don't have a choice but to return
|
|
if not id_servers:
|
|
return False
|
|
|
|
changed = True
|
|
for id_server in id_servers:
|
|
changed &= await self.try_unbind_threepid_with_id_server(
|
|
mxid, threepid, id_server
|
|
)
|
|
|
|
return changed
|
|
|
|
async def try_unbind_threepid_with_id_server(self, mxid, threepid, id_server):
|
|
"""Removes a binding from an identity server
|
|
|
|
Args:
|
|
mxid (str): Matrix user ID of binding to be removed
|
|
threepid (dict): Dict with medium & address of binding to be removed
|
|
id_server (str): Identity server to unbind from
|
|
|
|
Raises:
|
|
SynapseError: If we failed to contact the identity server
|
|
|
|
Returns:
|
|
Deferred[bool]: True on success, otherwise False if the identity
|
|
server doesn't support unbinding
|
|
"""
|
|
url = "https://%s/_matrix/identity/api/v1/3pid/unbind" % (id_server,)
|
|
url_bytes = "/_matrix/identity/api/v1/3pid/unbind".encode("ascii")
|
|
|
|
content = {
|
|
"mxid": mxid,
|
|
"threepid": {"medium": threepid["medium"], "address": threepid["address"]},
|
|
}
|
|
|
|
# we abuse the federation http client to sign the request, but we have to send it
|
|
# using the normal http client since we don't want the SRV lookup and want normal
|
|
# 'browser-like' HTTPS.
|
|
auth_headers = self.federation_http_client.build_auth_headers(
|
|
destination=None,
|
|
method="POST",
|
|
url_bytes=url_bytes,
|
|
content=content,
|
|
destination_is=id_server,
|
|
)
|
|
headers = {b"Authorization": auth_headers}
|
|
|
|
try:
|
|
# Use the blacklisting http client as this call is only to identity servers
|
|
# provided by a client
|
|
await self.blacklisting_http_client.post_json_get_json(
|
|
url, content, headers
|
|
)
|
|
changed = True
|
|
except HttpResponseException as e:
|
|
changed = False
|
|
if e.code in (400, 404, 501):
|
|
# The remote server probably doesn't support unbinding (yet)
|
|
logger.warning("Received %d response while unbinding threepid", e.code)
|
|
else:
|
|
logger.error("Failed to unbind threepid on identity server: %s", e)
|
|
raise SynapseError(500, "Failed to contact identity server")
|
|
except TimeoutError:
|
|
raise SynapseError(500, "Timed out contacting identity server")
|
|
|
|
await self.store.remove_user_bound_threepid(
|
|
user_id=mxid,
|
|
medium=threepid["medium"],
|
|
address=threepid["address"],
|
|
id_server=id_server,
|
|
)
|
|
|
|
return changed
|
|
|
|
async def send_threepid_validation(
|
|
self,
|
|
email_address,
|
|
client_secret,
|
|
send_attempt,
|
|
send_email_func,
|
|
next_link=None,
|
|
):
|
|
"""Send a threepid validation email for password reset or
|
|
registration purposes
|
|
|
|
Args:
|
|
email_address (str): The user's email address
|
|
client_secret (str): The provided client secret
|
|
send_attempt (int): Which send attempt this is
|
|
send_email_func (func): A function that takes an email address, token,
|
|
client_secret and session_id, sends an email
|
|
and returns a Deferred.
|
|
next_link (str|None): The URL to redirect the user to after validation
|
|
|
|
Returns:
|
|
The new session_id upon success
|
|
|
|
Raises:
|
|
SynapseError is an error occurred when sending the email
|
|
"""
|
|
# Check that this email/client_secret/send_attempt combo is new or
|
|
# greater than what we've seen previously
|
|
session = await self.store.get_threepid_validation_session(
|
|
"email", client_secret, address=email_address, validated=False
|
|
)
|
|
|
|
# Check to see if a session already exists and that it is not yet
|
|
# marked as validated
|
|
if session and session.get("validated_at") is None:
|
|
session_id = session["session_id"]
|
|
last_send_attempt = session["last_send_attempt"]
|
|
|
|
# Check that the send_attempt is higher than previous attempts
|
|
if send_attempt <= last_send_attempt:
|
|
# If not, just return a success without sending an email
|
|
return session_id
|
|
else:
|
|
# An non-validated session does not exist yet.
|
|
# Generate a session id
|
|
session_id = random_string(16)
|
|
|
|
if next_link:
|
|
# Manipulate the next_link to add the sid, because the caller won't get
|
|
# it until we send a response, by which time we've sent the mail.
|
|
if "?" in next_link:
|
|
next_link += "&"
|
|
else:
|
|
next_link += "?"
|
|
next_link += "sid=" + urllib.parse.quote(session_id)
|
|
|
|
# Generate a new validation token
|
|
token = random_string(32)
|
|
|
|
# Send the mail with the link containing the token, client_secret
|
|
# and session_id
|
|
try:
|
|
await send_email_func(email_address, token, client_secret, session_id)
|
|
except Exception:
|
|
logger.exception(
|
|
"Error sending threepid validation email to %s", email_address
|
|
)
|
|
raise SynapseError(500, "An error was encountered when sending the email")
|
|
|
|
token_expires = (
|
|
self.hs.clock.time_msec() + self.hs.config.email_validation_token_lifetime
|
|
)
|
|
|
|
await self.store.start_or_continue_validation_session(
|
|
"email",
|
|
email_address,
|
|
session_id,
|
|
client_secret,
|
|
send_attempt,
|
|
next_link,
|
|
token,
|
|
token_expires,
|
|
)
|
|
|
|
return session_id
|
|
|
|
async def requestEmailToken(
|
|
self, id_server, email, client_secret, send_attempt, next_link=None
|
|
):
|
|
"""
|
|
Request an external server send an email on our behalf for the purposes of threepid
|
|
validation.
|
|
|
|
Args:
|
|
id_server (str): The identity server to proxy to
|
|
email (str): The email to send the message to
|
|
client_secret (str): The unique client_secret sends by the user
|
|
send_attempt (int): Which attempt this is
|
|
next_link: A link to redirect the user to once they submit the token
|
|
|
|
Returns:
|
|
The json response body from the server
|
|
"""
|
|
params = {
|
|
"email": email,
|
|
"client_secret": client_secret,
|
|
"send_attempt": send_attempt,
|
|
}
|
|
if next_link:
|
|
params["next_link"] = next_link
|
|
|
|
if self.hs.config.using_identity_server_from_trusted_list:
|
|
# Warn that a deprecated config option is in use
|
|
logger.warning(
|
|
'The config option "trust_identity_server_for_password_resets" '
|
|
'has been replaced by "account_threepid_delegate". '
|
|
"Please consult the sample config at docs/sample_config.yaml for "
|
|
"details and update your config file."
|
|
)
|
|
|
|
try:
|
|
data = await self.http_client.post_json_get_json(
|
|
id_server + "/_matrix/identity/api/v1/validate/email/requestToken",
|
|
params,
|
|
)
|
|
return data
|
|
except HttpResponseException as e:
|
|
logger.info("Proxied requestToken failed: %r", e)
|
|
raise e.to_synapse_error()
|
|
except TimeoutError:
|
|
raise SynapseError(500, "Timed out contacting identity server")
|
|
|
|
async def requestMsisdnToken(
|
|
self,
|
|
id_server,
|
|
country,
|
|
phone_number,
|
|
client_secret,
|
|
send_attempt,
|
|
next_link=None,
|
|
):
|
|
"""
|
|
Request an external server send an SMS message on our behalf for the purposes of
|
|
threepid validation.
|
|
Args:
|
|
id_server (str): The identity server to proxy to
|
|
country (str): The country code of the phone number
|
|
phone_number (str): The number to send the message to
|
|
client_secret (str): The unique client_secret sends by the user
|
|
send_attempt (int): Which attempt this is
|
|
next_link: A link to redirect the user to once they submit the token
|
|
|
|
Returns:
|
|
The json response body from the server
|
|
"""
|
|
params = {
|
|
"country": country,
|
|
"phone_number": phone_number,
|
|
"client_secret": client_secret,
|
|
"send_attempt": send_attempt,
|
|
}
|
|
if next_link:
|
|
params["next_link"] = next_link
|
|
|
|
if self.hs.config.using_identity_server_from_trusted_list:
|
|
# Warn that a deprecated config option is in use
|
|
logger.warning(
|
|
'The config option "trust_identity_server_for_password_resets" '
|
|
'has been replaced by "account_threepid_delegate". '
|
|
"Please consult the sample config at docs/sample_config.yaml for "
|
|
"details and update your config file."
|
|
)
|
|
|
|
try:
|
|
data = await self.http_client.post_json_get_json(
|
|
id_server + "/_matrix/identity/api/v1/validate/msisdn/requestToken",
|
|
params,
|
|
)
|
|
except HttpResponseException as e:
|
|
logger.info("Proxied requestToken failed: %r", e)
|
|
raise e.to_synapse_error()
|
|
except TimeoutError:
|
|
raise SynapseError(500, "Timed out contacting identity server")
|
|
|
|
assert self.hs.config.public_baseurl
|
|
|
|
# we need to tell the client to send the token back to us, since it doesn't
|
|
# otherwise know where to send it, so add submit_url response parameter
|
|
# (see also MSC2078)
|
|
data["submit_url"] = (
|
|
self.hs.config.public_baseurl
|
|
+ "_matrix/client/unstable/add_threepid/msisdn/submit_token"
|
|
)
|
|
return data
|
|
|
|
async def validate_threepid_session(self, client_secret, sid):
|
|
"""Validates a threepid session with only the client secret and session ID
|
|
Tries validating against any configured account_threepid_delegates as well as locally.
|
|
|
|
Args:
|
|
client_secret (str): A secret provided by the client
|
|
|
|
sid (str): The ID of the session
|
|
|
|
Returns:
|
|
Dict[str, str|int] if validation was successful, otherwise None
|
|
"""
|
|
# XXX: We shouldn't need to keep wrapping and unwrapping this value
|
|
threepid_creds = {"client_secret": client_secret, "sid": sid}
|
|
|
|
# We don't actually know which medium this 3PID is. Thus we first assume it's email,
|
|
# and if validation fails we try msisdn
|
|
validation_session = None
|
|
|
|
# Try to validate as email
|
|
if self.hs.config.threepid_behaviour_email == ThreepidBehaviour.REMOTE:
|
|
# Ask our delegated email identity server
|
|
validation_session = await self.threepid_from_creds(
|
|
self.hs.config.account_threepid_delegate_email, threepid_creds
|
|
)
|
|
elif self.hs.config.threepid_behaviour_email == ThreepidBehaviour.LOCAL:
|
|
# Get a validated session matching these details
|
|
validation_session = await self.store.get_threepid_validation_session(
|
|
"email", client_secret, sid=sid, validated=True
|
|
)
|
|
|
|
if validation_session:
|
|
return validation_session
|
|
|
|
# Try to validate as msisdn
|
|
if self.hs.config.account_threepid_delegate_msisdn:
|
|
# Ask our delegated msisdn identity server
|
|
validation_session = await self.threepid_from_creds(
|
|
self.hs.config.account_threepid_delegate_msisdn, threepid_creds
|
|
)
|
|
|
|
return validation_session
|
|
|
|
async def proxy_msisdn_submit_token(self, id_server, client_secret, sid, token):
|
|
"""Proxy a POST submitToken request to an identity server for verification purposes
|
|
|
|
Args:
|
|
id_server (str): The identity server URL to contact
|
|
|
|
client_secret (str): Secret provided by the client
|
|
|
|
sid (str): The ID of the session
|
|
|
|
token (str): The verification token
|
|
|
|
Raises:
|
|
SynapseError: If we failed to contact the identity server
|
|
|
|
Returns:
|
|
Deferred[dict]: The response dict from the identity server
|
|
"""
|
|
body = {"client_secret": client_secret, "sid": sid, "token": token}
|
|
|
|
try:
|
|
return await self.http_client.post_json_get_json(
|
|
id_server + "/_matrix/identity/api/v1/validate/msisdn/submitToken",
|
|
body,
|
|
)
|
|
except TimeoutError:
|
|
raise SynapseError(500, "Timed out contacting identity server")
|
|
except HttpResponseException as e:
|
|
logger.warning("Error contacting msisdn account_threepid_delegate: %s", e)
|
|
raise SynapseError(400, "Error contacting the identity server")
|
|
|
|
async def lookup_3pid(self, id_server, medium, address, id_access_token=None):
|
|
"""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").
|
|
id_access_token (str|None): The access token to authenticate to the identity
|
|
server with
|
|
|
|
Returns:
|
|
str|None: the matrix ID of the 3pid, or None if it is not recognized.
|
|
"""
|
|
if id_access_token is not None:
|
|
try:
|
|
results = await self._lookup_3pid_v2(
|
|
id_server, id_access_token, medium, address
|
|
)
|
|
return results
|
|
|
|
except Exception as e:
|
|
# Catch HttpResponseExcept for a non-200 response code
|
|
# Check if this identity server does not know about v2 lookups
|
|
if isinstance(e, HttpResponseException) and e.code == 404:
|
|
# This is an old identity server that does not yet support v2 lookups
|
|
logger.warning(
|
|
"Attempted v2 lookup on v1 identity server %s. Falling "
|
|
"back to v1",
|
|
id_server,
|
|
)
|
|
else:
|
|
logger.warning("Error when looking up hashing details: %s", e)
|
|
return None
|
|
|
|
return await self._lookup_3pid_v1(id_server, medium, address)
|
|
|
|
async def _lookup_3pid_v1(self, id_server, medium, address):
|
|
"""Looks up a 3pid in the passed identity server using v1 lookup.
|
|
|
|
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 = await self.blacklisting_http_client.get_json(
|
|
"%s%s/_matrix/identity/api/v1/lookup" % (id_server_scheme, id_server),
|
|
{"medium": medium, "address": address},
|
|
)
|
|
|
|
if "mxid" in data:
|
|
if "signatures" not in data:
|
|
raise AuthError(401, "No signatures on 3pid binding")
|
|
await self._verify_any_signature(data, id_server)
|
|
return data["mxid"]
|
|
except TimeoutError:
|
|
raise SynapseError(500, "Timed out contacting identity server")
|
|
except IOError as e:
|
|
logger.warning("Error from v1 identity server lookup: %s" % (e,))
|
|
|
|
return None
|
|
|
|
async def _lookup_3pid_v2(self, id_server, id_access_token, medium, address):
|
|
"""Looks up a 3pid in the passed identity server using v2 lookup.
|
|
|
|
Args:
|
|
id_server (str): The server name (including port, if required)
|
|
of the identity server to use.
|
|
id_access_token (str): The access token to authenticate to the identity server with
|
|
medium (str): The type of the third party identifier (e.g. "email").
|
|
address (str): The third party identifier (e.g. "foo@example.com").
|
|
|
|
Returns:
|
|
Deferred[str|None]: the matrix ID of the 3pid, or None if it is not recognised.
|
|
"""
|
|
# Check what hashing details are supported by this identity server
|
|
try:
|
|
hash_details = await self.blacklisting_http_client.get_json(
|
|
"%s%s/_matrix/identity/v2/hash_details" % (id_server_scheme, id_server),
|
|
{"access_token": id_access_token},
|
|
)
|
|
except TimeoutError:
|
|
raise SynapseError(500, "Timed out contacting identity server")
|
|
|
|
if not isinstance(hash_details, dict):
|
|
logger.warning(
|
|
"Got non-dict object when checking hash details of %s%s: %s",
|
|
id_server_scheme,
|
|
id_server,
|
|
hash_details,
|
|
)
|
|
raise SynapseError(
|
|
400,
|
|
"Non-dict object from %s%s during v2 hash_details request: %s"
|
|
% (id_server_scheme, id_server, hash_details),
|
|
)
|
|
|
|
# Extract information from hash_details
|
|
supported_lookup_algorithms = hash_details.get("algorithms")
|
|
lookup_pepper = hash_details.get("lookup_pepper")
|
|
if (
|
|
not supported_lookup_algorithms
|
|
or not isinstance(supported_lookup_algorithms, list)
|
|
or not lookup_pepper
|
|
or not isinstance(lookup_pepper, str)
|
|
):
|
|
raise SynapseError(
|
|
400,
|
|
"Invalid hash details received from identity server %s%s: %s"
|
|
% (id_server_scheme, id_server, hash_details),
|
|
)
|
|
|
|
# Check if any of the supported lookup algorithms are present
|
|
if LookupAlgorithm.SHA256 in supported_lookup_algorithms:
|
|
# Perform a hashed lookup
|
|
lookup_algorithm = LookupAlgorithm.SHA256
|
|
|
|
# Hash address, medium and the pepper with sha256
|
|
to_hash = "%s %s %s" % (address, medium, lookup_pepper)
|
|
lookup_value = sha256_and_url_safe_base64(to_hash)
|
|
|
|
elif LookupAlgorithm.NONE in supported_lookup_algorithms:
|
|
# Perform a non-hashed lookup
|
|
lookup_algorithm = LookupAlgorithm.NONE
|
|
|
|
# Combine together plaintext address and medium
|
|
lookup_value = "%s %s" % (address, medium)
|
|
|
|
else:
|
|
logger.warning(
|
|
"None of the provided lookup algorithms of %s are supported: %s",
|
|
id_server,
|
|
supported_lookup_algorithms,
|
|
)
|
|
raise SynapseError(
|
|
400,
|
|
"Provided identity server does not support any v2 lookup "
|
|
"algorithms that this homeserver supports.",
|
|
)
|
|
|
|
# Authenticate with identity server given the access token from the client
|
|
headers = {"Authorization": create_id_access_token_header(id_access_token)}
|
|
|
|
try:
|
|
lookup_results = await self.blacklisting_http_client.post_json_get_json(
|
|
"%s%s/_matrix/identity/v2/lookup" % (id_server_scheme, id_server),
|
|
{
|
|
"addresses": [lookup_value],
|
|
"algorithm": lookup_algorithm,
|
|
"pepper": lookup_pepper,
|
|
},
|
|
headers=headers,
|
|
)
|
|
except TimeoutError:
|
|
raise SynapseError(500, "Timed out contacting identity server")
|
|
except Exception as e:
|
|
logger.warning("Error when performing a v2 3pid lookup: %s", e)
|
|
raise SynapseError(
|
|
500, "Unknown error occurred during identity server lookup"
|
|
)
|
|
|
|
# Check for a mapping from what we looked up to an MXID
|
|
if "mappings" not in lookup_results or not isinstance(
|
|
lookup_results["mappings"], dict
|
|
):
|
|
logger.warning("No results from 3pid lookup")
|
|
return None
|
|
|
|
# Return the MXID if it's available, or None otherwise
|
|
mxid = lookup_results["mappings"].get(lookup_value)
|
|
return mxid
|
|
|
|
async def _verify_any_signature(self, data, server_hostname):
|
|
if server_hostname not in data["signatures"]:
|
|
raise AuthError(401, "No signature from server %s" % (server_hostname,))
|
|
for key_name, signature in data["signatures"][server_hostname].items():
|
|
try:
|
|
key_data = await self.blacklisting_http_client.get_json(
|
|
"%s%s/_matrix/identity/api/v1/pubkey/%s"
|
|
% (id_server_scheme, server_hostname, key_name)
|
|
)
|
|
except TimeoutError:
|
|
raise SynapseError(500, "Timed out contacting identity server")
|
|
if "public_key" not in key_data:
|
|
raise AuthError(
|
|
401, "No public key named %s from %s" % (key_name, server_hostname)
|
|
)
|
|
verify_signed_json(
|
|
data,
|
|
server_hostname,
|
|
decode_verify_key_bytes(
|
|
key_name, decode_base64(key_data["public_key"])
|
|
),
|
|
)
|
|
return
|
|
|
|
async def ask_id_server_for_third_party_invite(
|
|
self,
|
|
requester,
|
|
id_server,
|
|
medium,
|
|
address,
|
|
room_id,
|
|
inviter_user_id,
|
|
room_alias,
|
|
room_avatar_url,
|
|
room_join_rules,
|
|
room_name,
|
|
inviter_display_name,
|
|
inviter_avatar_url,
|
|
id_access_token=None,
|
|
):
|
|
"""
|
|
Asks an identity server for a third party invite.
|
|
|
|
Args:
|
|
requester (Requester)
|
|
id_server (str): hostname + optional port for the identity server.
|
|
medium (str): The literal string "email".
|
|
address (str): The third party address being invited.
|
|
room_id (str): The ID of the room to which the user is invited.
|
|
inviter_user_id (str): The user ID of the inviter.
|
|
room_alias (str): An alias for the room, for cosmetic notifications.
|
|
room_avatar_url (str): The URL of the room's avatar, for cosmetic
|
|
notifications.
|
|
room_join_rules (str): The join rules of the email (e.g. "public").
|
|
room_name (str): The m.room.name of the room.
|
|
inviter_display_name (str): The current display name of the
|
|
inviter.
|
|
inviter_avatar_url (str): The URL of the inviter's avatar.
|
|
id_access_token (str|None): The access token to authenticate to the identity
|
|
server with
|
|
|
|
Returns:
|
|
A deferred tuple containing:
|
|
token (str): The token which must be signed to prove authenticity.
|
|
public_keys ([{"public_key": str, "key_validity_url": str}]):
|
|
public_key is a base64-encoded ed25519 public key.
|
|
fallback_public_key: One element from public_keys.
|
|
display_name (str): A user-friendly name to represent the invited
|
|
user.
|
|
"""
|
|
invite_config = {
|
|
"medium": medium,
|
|
"address": address,
|
|
"room_id": room_id,
|
|
"room_alias": room_alias,
|
|
"room_avatar_url": room_avatar_url,
|
|
"room_join_rules": room_join_rules,
|
|
"room_name": room_name,
|
|
"sender": inviter_user_id,
|
|
"sender_display_name": inviter_display_name,
|
|
"sender_avatar_url": inviter_avatar_url,
|
|
}
|
|
|
|
# Add the identity service access token to the JSON body and use the v2
|
|
# Identity Service endpoints if id_access_token is present
|
|
data = None
|
|
base_url = "%s%s/_matrix/identity" % (id_server_scheme, id_server)
|
|
|
|
if id_access_token:
|
|
key_validity_url = "%s%s/_matrix/identity/v2/pubkey/isvalid" % (
|
|
id_server_scheme,
|
|
id_server,
|
|
)
|
|
|
|
# Attempt a v2 lookup
|
|
url = base_url + "/v2/store-invite"
|
|
try:
|
|
data = await self.blacklisting_http_client.post_json_get_json(
|
|
url,
|
|
invite_config,
|
|
{"Authorization": create_id_access_token_header(id_access_token)},
|
|
)
|
|
except TimeoutError:
|
|
raise SynapseError(500, "Timed out contacting identity server")
|
|
except HttpResponseException as e:
|
|
if e.code != 404:
|
|
logger.info("Failed to POST %s with JSON: %s", url, e)
|
|
raise e
|
|
|
|
if data is None:
|
|
key_validity_url = "%s%s/_matrix/identity/api/v1/pubkey/isvalid" % (
|
|
id_server_scheme,
|
|
id_server,
|
|
)
|
|
url = base_url + "/api/v1/store-invite"
|
|
|
|
try:
|
|
data = await self.blacklisting_http_client.post_json_get_json(
|
|
url, invite_config
|
|
)
|
|
except TimeoutError:
|
|
raise SynapseError(500, "Timed out contacting identity server")
|
|
except HttpResponseException as e:
|
|
logger.warning(
|
|
"Error trying to call /store-invite on %s%s: %s",
|
|
id_server_scheme,
|
|
id_server,
|
|
e,
|
|
)
|
|
|
|
if data is None:
|
|
# Some identity servers may only support application/x-www-form-urlencoded
|
|
# types. This is especially true with old instances of Sydent, see
|
|
# https://github.com/matrix-org/sydent/pull/170
|
|
try:
|
|
data = await self.blacklisting_http_client.post_urlencoded_get_json(
|
|
url, invite_config
|
|
)
|
|
except HttpResponseException as e:
|
|
logger.warning(
|
|
"Error calling /store-invite on %s%s with fallback "
|
|
"encoding: %s",
|
|
id_server_scheme,
|
|
id_server,
|
|
e,
|
|
)
|
|
raise e
|
|
|
|
# TODO: Check for success
|
|
token = data["token"]
|
|
public_keys = data.get("public_keys", [])
|
|
if "public_key" in data:
|
|
fallback_public_key = {
|
|
"public_key": data["public_key"],
|
|
"key_validity_url": key_validity_url,
|
|
}
|
|
else:
|
|
fallback_public_key = public_keys[0]
|
|
|
|
if not public_keys:
|
|
public_keys.append(fallback_public_key)
|
|
display_name = data["display_name"]
|
|
return token, public_keys, fallback_public_key, display_name
|
|
|
|
|
|
def create_id_access_token_header(id_access_token):
|
|
"""Create an Authorization header for passing to SimpleHttpClient as the header value
|
|
of an HTTP request.
|
|
|
|
Args:
|
|
id_access_token (str): An identity server access token.
|
|
|
|
Returns:
|
|
list[str]: The ascii-encoded bearer token encased in a list.
|
|
"""
|
|
# Prefix with Bearer
|
|
bearer_token = "Bearer %s" % id_access_token
|
|
|
|
# Encode headers to standard ascii
|
|
bearer_token.encode("ascii")
|
|
|
|
# Return as a list as that's how SimpleHttpClient takes header values
|
|
return [bearer_token]
|
|
|
|
|
|
class LookupAlgorithm:
|
|
"""
|
|
Supported hashing algorithms when performing a 3PID lookup.
|
|
|
|
SHA256 - Hashing an (address, medium, pepper) combo with sha256, then url-safe base64
|
|
encoding
|
|
NONE - Not performing any hashing. Simply sending an (address, medium) combo in plaintext
|
|
"""
|
|
|
|
SHA256 = "sha256"
|
|
NONE = "none"
|