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

Commit

Permalink
Merge pull request #5251 from matrix-org/rav/server_keys/01-check_sig
Browse files Browse the repository at this point in the history
Ensure that server_keys fetched via a notary server are correctly signed.
  • Loading branch information
richvdh authored May 28, 2019
2 parents 5726378 + cbcfd64 commit 540f40f
Show file tree
Hide file tree
Showing 3 changed files with 168 additions and 50 deletions.
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 @@ -346,6 +347,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 @@ -391,8 +393,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 @@ -427,16 +428,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 @@ -546,7 +556,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 @@ -575,40 +594,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 @@ -621,6 +631,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 @@ -674,12 +731,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")

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

0 comments on commit 540f40f

Please sign in to comment.