Merge pull request #5251 from matrix-org/rav/server_keys/01-check_sig
Ensure that server_keys fetched via a notary server are correctly signed.pull/5281/head
commit
540f40f0cd
|
@ -0,0 +1 @@
|
||||||
|
Ensure that server_keys fetched via a notary server are correctly signed.
|
|
@ -17,6 +17,7 @@
|
||||||
import logging
|
import logging
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
|
||||||
|
import six
|
||||||
from six import raise_from
|
from six import raise_from
|
||||||
from six.moves import urllib
|
from six.moves import urllib
|
||||||
|
|
||||||
|
@ -346,6 +347,7 @@ class KeyFetcher(object):
|
||||||
Args:
|
Args:
|
||||||
server_name_and_key_ids (iterable[Tuple[str, iterable[str]]]):
|
server_name_and_key_ids (iterable[Tuple[str, iterable[str]]]):
|
||||||
list of (server_name, iterable[key_id]) tuples to fetch keys for
|
list of (server_name, iterable[key_id]) tuples to fetch keys for
|
||||||
|
Note that the iterables may be iterated more than once.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Deferred[dict[str, dict[str, synapse.storage.keys.FetchKeyResult|None]]]:
|
Deferred[dict[str, dict[str, synapse.storage.keys.FetchKeyResult|None]]]:
|
||||||
|
@ -391,8 +393,7 @@ class BaseV2KeyFetcher(object):
|
||||||
POST /_matrix/key/v2/query.
|
POST /_matrix/key/v2/query.
|
||||||
|
|
||||||
Checks that each signature in the response that claims to come from the origin
|
Checks that each signature in the response that claims to come from the origin
|
||||||
server is valid. (Does not check that there actually is such a signature, for
|
server is valid, and that there is at least one such signature.
|
||||||
some reason.)
|
|
||||||
|
|
||||||
Stores the json in server_keys_json so that it can be used for future responses
|
Stores the json in server_keys_json so that it can be used for future responses
|
||||||
to /_matrix/key/v2/query.
|
to /_matrix/key/v2/query.
|
||||||
|
@ -427,16 +428,25 @@ class BaseV2KeyFetcher(object):
|
||||||
verify_key=verify_key, valid_until_ts=ts_valid_until_ms
|
verify_key=verify_key, valid_until_ts=ts_valid_until_ms
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: improve this signature checking
|
|
||||||
server_name = response_json["server_name"]
|
server_name = response_json["server_name"]
|
||||||
|
verified = False
|
||||||
for key_id in response_json["signatures"].get(server_name, {}):
|
for key_id in response_json["signatures"].get(server_name, {}):
|
||||||
if key_id not in verify_keys:
|
# each of the keys used for the signature must be present in the response
|
||||||
|
# json.
|
||||||
|
key = verify_keys.get(key_id)
|
||||||
|
if not key:
|
||||||
raise KeyLookupError(
|
raise KeyLookupError(
|
||||||
"Key response must include verification keys for all signatures"
|
"Key response is signed by key id %s:%s but that key is not "
|
||||||
|
"present in the response" % (server_name, key_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
verify_signed_json(
|
verify_signed_json(response_json, server_name, key.verify_key)
|
||||||
response_json, server_name, verify_keys[key_id].verify_key
|
verified = True
|
||||||
|
|
||||||
|
if not verified:
|
||||||
|
raise KeyLookupError(
|
||||||
|
"Key response for %s is not signed by the origin server"
|
||||||
|
% (server_name,)
|
||||||
)
|
)
|
||||||
|
|
||||||
for key_id, key_data in response_json["old_verify_keys"].items():
|
for key_id, key_data in response_json["old_verify_keys"].items():
|
||||||
|
@ -546,7 +556,16 @@ class PerspectivesKeyFetcher(BaseV2KeyFetcher):
|
||||||
Returns:
|
Returns:
|
||||||
Deferred[dict[str, dict[str, synapse.storage.keys.FetchKeyResult]]]: map
|
Deferred[dict[str, dict[str, synapse.storage.keys.FetchKeyResult]]]: map
|
||||||
from server_name -> key_id -> FetchKeyResult
|
from server_name -> key_id -> FetchKeyResult
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyLookupError if there was an error processing the entire response from
|
||||||
|
the server
|
||||||
"""
|
"""
|
||||||
|
logger.info(
|
||||||
|
"Requesting keys %s from notary server %s",
|
||||||
|
server_names_and_key_ids,
|
||||||
|
perspective_name,
|
||||||
|
)
|
||||||
# TODO(mark): Set the minimum_valid_until_ts to that needed by
|
# TODO(mark): Set the minimum_valid_until_ts to that needed by
|
||||||
# the events being validated or the current time if validating
|
# the events being validated or the current time if validating
|
||||||
# an incoming request.
|
# an incoming request.
|
||||||
|
@ -575,40 +594,31 @@ class PerspectivesKeyFetcher(BaseV2KeyFetcher):
|
||||||
time_now_ms = self.clock.time_msec()
|
time_now_ms = self.clock.time_msec()
|
||||||
|
|
||||||
for response in query_response["server_keys"]:
|
for response in query_response["server_keys"]:
|
||||||
if (
|
# do this first, so that we can give useful errors thereafter
|
||||||
u"signatures" not in response
|
server_name = response.get("server_name")
|
||||||
or perspective_name not in response[u"signatures"]
|
if not isinstance(server_name, six.string_types):
|
||||||
):
|
|
||||||
raise KeyLookupError(
|
raise KeyLookupError(
|
||||||
"Key response not signed by perspective server"
|
"Malformed response from key notary server %s: invalid server_name"
|
||||||
" %r" % (perspective_name,)
|
% (perspective_name,)
|
||||||
)
|
)
|
||||||
|
|
||||||
verified = False
|
try:
|
||||||
for key_id in response[u"signatures"][perspective_name]:
|
processed_response = yield self._process_perspectives_response(
|
||||||
if key_id in perspective_keys:
|
|
||||||
verify_signed_json(
|
|
||||||
response, perspective_name, perspective_keys[key_id]
|
|
||||||
)
|
|
||||||
verified = True
|
|
||||||
|
|
||||||
if not verified:
|
|
||||||
logging.info(
|
|
||||||
"Response from perspective server %r not signed with a"
|
|
||||||
" known key, signed with: %r, known keys: %r",
|
|
||||||
perspective_name,
|
perspective_name,
|
||||||
list(response[u"signatures"][perspective_name]),
|
perspective_keys,
|
||||||
list(perspective_keys),
|
response,
|
||||||
|
time_added_ms=time_now_ms,
|
||||||
)
|
)
|
||||||
raise KeyLookupError(
|
except KeyLookupError as e:
|
||||||
"Response not signed with a known key for perspective"
|
logger.warning(
|
||||||
" server %r" % (perspective_name,)
|
"Error processing response from key notary server %s for origin "
|
||||||
|
"server %s: %s",
|
||||||
|
perspective_name,
|
||||||
|
server_name,
|
||||||
|
e,
|
||||||
)
|
)
|
||||||
|
# we continue to process the rest of the response
|
||||||
processed_response = yield self.process_v2_response(
|
continue
|
||||||
perspective_name, response, time_added_ms=time_now_ms
|
|
||||||
)
|
|
||||||
server_name = response["server_name"]
|
|
||||||
|
|
||||||
added_keys.extend(
|
added_keys.extend(
|
||||||
(server_name, key_id, key) for key_id, key in processed_response.items()
|
(server_name, key_id, key) for key_id, key in processed_response.items()
|
||||||
|
@ -621,6 +631,53 @@ class PerspectivesKeyFetcher(BaseV2KeyFetcher):
|
||||||
|
|
||||||
defer.returnValue(keys)
|
defer.returnValue(keys)
|
||||||
|
|
||||||
|
def _process_perspectives_response(
|
||||||
|
self, perspective_name, perspective_keys, response, time_added_ms
|
||||||
|
):
|
||||||
|
"""Parse a 'Server Keys' structure from the result of a /key/query request
|
||||||
|
|
||||||
|
Checks that the entry is correctly signed by the perspectives server, and then
|
||||||
|
passes over to process_v2_response
|
||||||
|
|
||||||
|
Args:
|
||||||
|
perspective_name (str): the name of the notary server that produced this
|
||||||
|
result
|
||||||
|
|
||||||
|
perspective_keys (dict[str, VerifyKey]): map of key_id->key for the
|
||||||
|
notary server
|
||||||
|
|
||||||
|
response (dict): the json-decoded Server Keys response object
|
||||||
|
|
||||||
|
time_added_ms (int): the timestamp to record in server_keys_json
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Deferred[dict[str, FetchKeyResult]]: map from key_id to result object
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
u"signatures" not in response
|
||||||
|
or perspective_name not in response[u"signatures"]
|
||||||
|
):
|
||||||
|
raise KeyLookupError("Response not signed by the notary server")
|
||||||
|
|
||||||
|
verified = False
|
||||||
|
for key_id in response[u"signatures"][perspective_name]:
|
||||||
|
if key_id in perspective_keys:
|
||||||
|
verify_signed_json(response, perspective_name, perspective_keys[key_id])
|
||||||
|
verified = True
|
||||||
|
|
||||||
|
if not verified:
|
||||||
|
raise KeyLookupError(
|
||||||
|
"Response not signed with a known key: signed with: %r, known keys: %r"
|
||||||
|
% (
|
||||||
|
list(response[u"signatures"][perspective_name].keys()),
|
||||||
|
list(perspective_keys.keys()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.process_v2_response(
|
||||||
|
perspective_name, response, time_added_ms=time_added_ms
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ServerKeyFetcher(BaseV2KeyFetcher):
|
class ServerKeyFetcher(BaseV2KeyFetcher):
|
||||||
"""KeyFetcher impl which fetches keys from the origin servers"""
|
"""KeyFetcher impl which fetches keys from the origin servers"""
|
||||||
|
@ -674,12 +731,6 @@ class ServerKeyFetcher(BaseV2KeyFetcher):
|
||||||
except HttpResponseException as e:
|
except HttpResponseException as e:
|
||||||
raise_from(KeyLookupError("Remote server returned an error"), e)
|
raise_from(KeyLookupError("Remote server returned an error"), e)
|
||||||
|
|
||||||
if (
|
|
||||||
u"signatures" not in response
|
|
||||||
or server_name not in response[u"signatures"]
|
|
||||||
):
|
|
||||||
raise KeyLookupError("Key response not signed by remote server")
|
|
||||||
|
|
||||||
if response["server_name"] != server_name:
|
if response["server_name"] != server_name:
|
||||||
raise KeyLookupError(
|
raise KeyLookupError(
|
||||||
"Expected a response for server %r not %r"
|
"Expected a response for server %r not %r"
|
||||||
|
|
|
@ -55,12 +55,12 @@ class MockPerspectiveServer(object):
|
||||||
key_id: {"key": signedjson.key.encode_verify_key_base64(verify_key)}
|
key_id: {"key": signedjson.key.encode_verify_key_base64(verify_key)}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return self.get_signed_response(res)
|
self.sign_response(res)
|
||||||
|
|
||||||
def get_signed_response(self, res):
|
|
||||||
signedjson.sign.sign_json(res, self.server_name, self.key)
|
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
def sign_response(self, res):
|
||||||
|
signedjson.sign.sign_json(res, self.server_name, self.key)
|
||||||
|
|
||||||
|
|
||||||
class KeyringTestCase(unittest.HomeserverTestCase):
|
class KeyringTestCase(unittest.HomeserverTestCase):
|
||||||
def make_homeserver(self, reactor, clock):
|
def make_homeserver(self, reactor, clock):
|
||||||
|
@ -238,7 +238,7 @@ class ServerKeyFetcherTestCase(unittest.HomeserverTestCase):
|
||||||
testkey = signedjson.key.generate_signing_key("ver1")
|
testkey = signedjson.key.generate_signing_key("ver1")
|
||||||
testverifykey = signedjson.key.get_verify_key(testkey)
|
testverifykey = signedjson.key.get_verify_key(testkey)
|
||||||
testverifykey_id = "ed25519:ver1"
|
testverifykey_id = "ed25519:ver1"
|
||||||
VALID_UNTIL_TS = 1000
|
VALID_UNTIL_TS = 200 * 1000
|
||||||
|
|
||||||
# valid response
|
# valid response
|
||||||
response = {
|
response = {
|
||||||
|
@ -326,9 +326,10 @@ class PerspectivesKeyFetcherTestCase(unittest.HomeserverTestCase):
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
persp_resp = {
|
# the response must be signed by both the origin server and the perspectives
|
||||||
"server_keys": [self.mock_perspective_server.get_signed_response(response)]
|
# server.
|
||||||
}
|
signedjson.sign.sign_json(response, SERVER_NAME, testkey)
|
||||||
|
self.mock_perspective_server.sign_response(response)
|
||||||
|
|
||||||
def post_json(destination, path, data, **kwargs):
|
def post_json(destination, path, data, **kwargs):
|
||||||
self.assertEqual(destination, self.mock_perspective_server.server_name)
|
self.assertEqual(destination, self.mock_perspective_server.server_name)
|
||||||
|
@ -337,7 +338,7 @@ class PerspectivesKeyFetcherTestCase(unittest.HomeserverTestCase):
|
||||||
# check that the request is for the expected key
|
# check that the request is for the expected key
|
||||||
q = data["server_keys"]
|
q = data["server_keys"]
|
||||||
self.assertEqual(list(q[SERVER_NAME].keys()), ["key1"])
|
self.assertEqual(list(q[SERVER_NAME].keys()), ["key1"])
|
||||||
return persp_resp
|
return {"server_keys": [response]}
|
||||||
|
|
||||||
self.http_client.post_json.side_effect = post_json
|
self.http_client.post_json.side_effect = post_json
|
||||||
|
|
||||||
|
@ -365,9 +366,74 @@ class PerspectivesKeyFetcherTestCase(unittest.HomeserverTestCase):
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
bytes(res["key_json"]),
|
bytes(res["key_json"]),
|
||||||
canonicaljson.encode_canonical_json(persp_resp["server_keys"][0]),
|
canonicaljson.encode_canonical_json(response),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_invalid_perspectives_responses(self):
|
||||||
|
"""Check that invalid responses from the perspectives server are rejected"""
|
||||||
|
# arbitrarily advance the clock a bit
|
||||||
|
self.reactor.advance(100)
|
||||||
|
|
||||||
|
SERVER_NAME = "server2"
|
||||||
|
testkey = signedjson.key.generate_signing_key("ver1")
|
||||||
|
testverifykey = signedjson.key.get_verify_key(testkey)
|
||||||
|
testverifykey_id = "ed25519:ver1"
|
||||||
|
VALID_UNTIL_TS = 200 * 1000
|
||||||
|
|
||||||
|
def build_response():
|
||||||
|
# valid response
|
||||||
|
response = {
|
||||||
|
"server_name": SERVER_NAME,
|
||||||
|
"old_verify_keys": {},
|
||||||
|
"valid_until_ts": VALID_UNTIL_TS,
|
||||||
|
"verify_keys": {
|
||||||
|
testverifykey_id: {
|
||||||
|
"key": signedjson.key.encode_verify_key_base64(testverifykey)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# the response must be signed by both the origin server and the perspectives
|
||||||
|
# server.
|
||||||
|
signedjson.sign.sign_json(response, SERVER_NAME, testkey)
|
||||||
|
self.mock_perspective_server.sign_response(response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_key_from_perspectives(response):
|
||||||
|
fetcher = PerspectivesKeyFetcher(self.hs)
|
||||||
|
server_name_and_key_ids = [(SERVER_NAME, ("key1",))]
|
||||||
|
|
||||||
|
def post_json(destination, path, data, **kwargs):
|
||||||
|
self.assertEqual(destination, self.mock_perspective_server.server_name)
|
||||||
|
self.assertEqual(path, "/_matrix/key/v2/query")
|
||||||
|
return {"server_keys": [response]}
|
||||||
|
|
||||||
|
self.http_client.post_json.side_effect = post_json
|
||||||
|
|
||||||
|
return self.get_success(
|
||||||
|
fetcher.get_keys(server_name_and_key_ids)
|
||||||
|
)
|
||||||
|
|
||||||
|
# start with a valid response so we can check we are testing the right thing
|
||||||
|
response = build_response()
|
||||||
|
keys = get_key_from_perspectives(response)
|
||||||
|
k = keys[SERVER_NAME][testverifykey_id]
|
||||||
|
self.assertEqual(k.verify_key, testverifykey)
|
||||||
|
|
||||||
|
# remove the perspectives server's signature
|
||||||
|
response = build_response()
|
||||||
|
del response["signatures"][self.mock_perspective_server.server_name]
|
||||||
|
self.http_client.post_json.return_value = {"server_keys": [response]}
|
||||||
|
keys = get_key_from_perspectives(response)
|
||||||
|
self.assertEqual(keys, {}, "Expected empty dict with missing persp server sig")
|
||||||
|
|
||||||
|
# remove the origin server's signature
|
||||||
|
response = build_response()
|
||||||
|
del response["signatures"][SERVER_NAME]
|
||||||
|
self.http_client.post_json.return_value = {"server_keys": [response]}
|
||||||
|
keys = get_key_from_perspectives(response)
|
||||||
|
self.assertEqual(keys, {}, "Expected empty dict with missing origin server sig")
|
||||||
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def run_in_context(f, *args, **kwargs):
|
def run_in_context(f, *args, **kwargs):
|
||||||
|
|
Loading…
Reference in New Issue