Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Ensure that server_keys fetched via a notary server are correctly signed. #5251

Merged
merged 3 commits into from
May 28, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/5251.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Ensure that server_keys fetched via a notary server are correctly signed.
133 changes: 92 additions & 41 deletions synapse/crypto/keyring.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import logging
from collections import namedtuple

import six
from six import raise_from
from six.moves import urllib

Expand Down Expand Up @@ -349,6 +350,7 @@ def get_keys(self, server_name_and_key_ids):
Args:
server_name_and_key_ids (iterable[Tuple[str, iterable[str]]]):
list of (server_name, iterable[key_id]) tuples to fetch keys for
Note that the iterables may be iterated more than once.

Returns:
Deferred[dict[str, dict[str, synapse.storage.keys.FetchKeyResult|None]]]:
Expand Down Expand Up @@ -394,8 +396,7 @@ def process_v2_response(
POST /_matrix/key/v2/query.

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
some reason.)
server is valid, and that there is at least one such signature.

Stores the json in server_keys_json so that it can be used for future responses
to /_matrix/key/v2/query.
Expand Down Expand Up @@ -430,16 +431,25 @@ def process_v2_response(
verify_key=verify_key, valid_until_ts=ts_valid_until_ms
)

# TODO: improve this signature checking
server_name = response_json["server_name"]
verified = False
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(
"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(
response_json, server_name, verify_keys[key_id].verify_key
verify_signed_json(response_json, server_name, key.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():
Expand Down Expand Up @@ -549,7 +559,16 @@ def get_server_verify_key_v2_indirect(
Returns:
Deferred[dict[str, dict[str, synapse.storage.keys.FetchKeyResult]]]: map
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
# the events being validated or the current time if validating
# an incoming request.
Expand Down Expand Up @@ -578,40 +597,31 @@ def get_server_verify_key_v2_indirect(
time_now_ms = self.clock.time_msec()

for response in query_response["server_keys"]:
if (
u"signatures" not in response
or perspective_name not in response[u"signatures"]
):
# do this first, so that we can give useful errors thereafter
server_name = response.get("server_name")
if not isinstance(server_name, six.string_types):
raise KeyLookupError(
"Key response not signed by perspective server"
" %r" % (perspective_name,)
"Malformed response from key notary server %s: invalid server_name"
% (perspective_name,)
)

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:
logging.info(
"Response from perspective server %r not signed with a"
" known key, signed with: %r, known keys: %r",
try:
processed_response = yield self._process_perspectives_response(
perspective_name,
list(response[u"signatures"][perspective_name]),
list(perspective_keys),
perspective_keys,
response,
time_added_ms=time_now_ms,
)
raise KeyLookupError(
"Response not signed with a known key for perspective"
" server %r" % (perspective_name,)
except KeyLookupError as e:
logger.warning(
"Error processing response from key notary server %s for origin "
"server %s: %s",
perspective_name,
server_name,
e,
)

processed_response = yield self.process_v2_response(
perspective_name, response, time_added_ms=time_now_ms
)
server_name = response["server_name"]
# we continue to process the rest of the response
continue

added_keys.extend(
(server_name, key_id, key) for key_id, key in processed_response.items()
Expand All @@ -624,6 +634,53 @@ def get_server_verify_key_v2_indirect(

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):
"""KeyFetcher impl which fetches keys from the origin servers"""
Expand Down Expand Up @@ -677,12 +734,6 @@ def get_server_verify_key_v2_direct(self, server_name, key_ids):
except HttpResponseException as 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")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is now redundant, since we check it properly in process_v2_response.


if response["server_name"] != server_name:
raise KeyLookupError(
"Expected a response for server %r not %r"
Expand Down
84 changes: 75 additions & 9 deletions tests/crypto/test_keyring.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,11 @@ def get_signed_key(self, server_name, verify_key):
key_id: {"key": signedjson.key.encode_verify_key_base64(verify_key)}
},
}
return self.get_signed_response(res)
self.sign_response(res)
return res

def get_signed_response(self, res):
def sign_response(self, res):
signedjson.sign.sign_json(res, self.server_name, self.key)
return res


class KeyringTestCase(unittest.HomeserverTestCase):
Expand Down Expand Up @@ -238,7 +238,7 @@ def test_get_keys_from_server(self):
testkey = signedjson.key.generate_signing_key("ver1")
testverifykey = signedjson.key.get_verify_key(testkey)
testverifykey_id = "ed25519:ver1"
VALID_UNTIL_TS = 1000
VALID_UNTIL_TS = 200 * 1000

# valid response
response = {
Expand Down Expand Up @@ -326,9 +326,10 @@ def test_get_keys_from_perspectives(self):
},
}

persp_resp = {
"server_keys": [self.mock_perspective_server.get_signed_response(response)]
}
# 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)

def post_json(destination, path, data, **kwargs):
self.assertEqual(destination, self.mock_perspective_server.server_name)
Expand All @@ -337,7 +338,7 @@ def post_json(destination, path, data, **kwargs):
# check that the request is for the expected key
q = data["server_keys"]
self.assertEqual(list(q[SERVER_NAME].keys()), ["key1"])
return persp_resp
return {"server_keys": [response]}

self.http_client.post_json.side_effect = post_json

Expand Down Expand Up @@ -365,9 +366,74 @@ def post_json(destination, path, data, **kwargs):

self.assertEqual(
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
def run_in_context(f, *args, **kwargs):
Expand Down