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.
This commit is contained in:
Richard van der Hoff 2019-05-28 21:32:17 +01:00 committed by GitHub
commit 540f40f0cd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 169 additions and 51 deletions

1
changelog.d/5251.bugfix Normal file
View file

@ -0,0 +1 @@
Ensure that server_keys fetched via a notary server are correctly signed.

View file

@ -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"

View file

@ -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):