Stop hardcoding trust of old matrix.org key (#5374)

There are a few changes going on here:

* We make checking the signature on a key server response optional: if no
  verify_keys are specified, we trust to TLS to validate the connection.

* We change the default config so that it does not require responses to be
  signed by the old key.

* We replace the old 'perspectives' config with 'trusted_key_servers', which
  is also formatted slightly differently.

* We emit a warning to the logs every time we trust a key server response
  signed by the old key.
This commit is contained in:
Richard van der Hoff 2019-06-06 17:33:11 +01:00 committed by GitHub
parent 833c406b9b
commit 9fbb20a531
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 294 additions and 96 deletions

1
changelog.d/5374.feature Normal file
View File

@ -0,0 +1 @@
Replace the `perspectives` configuration section with `trusted_key_servers`, and make validating the signatures on responses optional (since TLS will do this job for us).

View File

@ -952,12 +952,43 @@ signing_key_path: "CONFDIR/SERVERNAME.signing.key"
# The trusted servers to download signing keys from. # The trusted servers to download signing keys from.
# #
#perspectives: # When we need to fetch a signing key, each server is tried in parallel.
# servers: #
# "matrix.org": # Normally, the connection to the key server is validated via TLS certificates.
# verify_keys: # Additional security can be provided by configuring a `verify key`, which
# "ed25519:auto": # will make synapse check that the response is signed by that key.
# key: "Noi6WqcDj0QmPxCNQqgezwTlBKrfqehY1u2FyWP9uYw" #
# This setting supercedes an older setting named `perspectives`. The old format
# is still supported for backwards-compatibility, but it is deprecated.
#
# Options for each entry in the list include:
#
# server_name: the name of the server. required.
#
# verify_keys: an optional map from key id to base64-encoded public key.
# If specified, we will check that the response is signed by at least
# one of the given keys.
#
# accept_keys_insecurely: a boolean. Normally, if `verify_keys` is unset,
# and federation_verify_certificates is not `true`, synapse will refuse
# to start, because this would allow anyone who can spoof DNS responses
# to masquerade as the trusted key server. If you know what you are doing
# and are sure that your network environment provides a secure connection
# to the key server, you can set this to `true` to override this
# behaviour.
#
# An example configuration might look like:
#
#trusted_key_servers:
# - server_name: "my_trusted_server.example.com"
# verify_keys:
# "ed25519:auto": "abcdefghijklmnopqrstuvwxyzabcdefghijklmopqr"
# - server_name: "my_other_trusted_server.example.com"
#
# The default configuration is:
#
#trusted_key_servers:
# - server_name: "matrix.org"
# Enable SAML2 for registration and login. Uses pysaml2. # Enable SAML2 for registration and login. Uses pysaml2.

View File

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2015, 2016 OpenMarket Ltd # Copyright 2015, 2016 OpenMarket Ltd
# Copyright 2019 The Matrix.org Foundation C.I.C.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -17,6 +18,8 @@ import hashlib
import logging import logging
import os import os
import attr
import jsonschema
from signedjson.key import ( from signedjson.key import (
NACL_ED25519, NACL_ED25519,
decode_signing_key_base64, decode_signing_key_base64,
@ -32,11 +35,27 @@ from synapse.util.stringutils import random_string, random_string_with_symbols
from ._base import Config, ConfigError from ._base import Config, ConfigError
INSECURE_NOTARY_ERROR = """\
Your server is configured to accept key server responses without signature
validation or TLS certificate validation. This is likely to be very insecure. If
you are *sure* you want to do this, set 'accept_keys_insecurely' on the
keyserver configuration."""
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class KeyConfig(Config): @attr.s
class TrustedKeyServer(object):
# string: name of the server.
server_name = attr.ib()
# dict[str,VerifyKey]|None: map from key id to key object, or None to disable
# signature verification.
verify_keys = attr.ib(default=None)
class KeyConfig(Config):
def read_config(self, config): def read_config(self, config):
# the signing key can be specified inline or in a separate file # the signing key can be specified inline or in a separate file
if "signing_key" in config: if "signing_key" in config:
@ -49,16 +68,27 @@ class KeyConfig(Config):
config.get("old_signing_keys", {}) config.get("old_signing_keys", {})
) )
self.key_refresh_interval = self.parse_duration( self.key_refresh_interval = self.parse_duration(
config.get("key_refresh_interval", "1d"), config.get("key_refresh_interval", "1d")
) )
self.perspectives = self.read_perspectives(
config.get("perspectives", {}).get("servers", { # if neither trusted_key_servers nor perspectives are given, use the default.
"matrix.org": {"verify_keys": { if "perspectives" not in config and "trusted_key_servers" not in config:
"ed25519:auto": { key_servers = [{"server_name": "matrix.org"}]
"key": "Noi6WqcDj0QmPxCNQqgezwTlBKrfqehY1u2FyWP9uYw", else:
} key_servers = config.get("trusted_key_servers", [])
}}
}) if not isinstance(key_servers, list):
raise ConfigError(
"trusted_key_servers, if given, must be a list, not a %s"
% (type(key_servers).__name__,)
)
# merge the 'perspectives' config into the 'trusted_key_servers' config.
key_servers.extend(_perspectives_to_key_servers(config))
# list of TrustedKeyServer objects
self.key_servers = list(
_parse_key_servers(key_servers, self.federation_verify_certificates)
) )
self.macaroon_secret_key = config.get( self.macaroon_secret_key = config.get(
@ -78,8 +108,9 @@ class KeyConfig(Config):
# falsification of values # falsification of values
self.form_secret = config.get("form_secret", None) self.form_secret = config.get("form_secret", None)
def default_config(self, config_dir_path, server_name, generate_secrets=False, def default_config(
**kwargs): self, config_dir_path, server_name, generate_secrets=False, **kwargs
):
base_key_name = os.path.join(config_dir_path, server_name) base_key_name = os.path.join(config_dir_path, server_name)
if generate_secrets: if generate_secrets:
@ -91,7 +122,8 @@ class KeyConfig(Config):
macaroon_secret_key = "# macaroon_secret_key: <PRIVATE STRING>" macaroon_secret_key = "# macaroon_secret_key: <PRIVATE STRING>"
form_secret = "# form_secret: <PRIVATE STRING>" form_secret = "# form_secret: <PRIVATE STRING>"
return """\ return (
"""\
# a secret which is used to sign access tokens. If none is specified, # a secret which is used to sign access tokens. If none is specified,
# the registration_shared_secret is used, if one is given; otherwise, # the registration_shared_secret is used, if one is given; otherwise,
# a secret key is derived from the signing key. # a secret key is derived from the signing key.
@ -133,33 +165,53 @@ class KeyConfig(Config):
# The trusted servers to download signing keys from. # The trusted servers to download signing keys from.
# #
#perspectives: # When we need to fetch a signing key, each server is tried in parallel.
# servers: #
# "matrix.org": # Normally, the connection to the key server is validated via TLS certificates.
# verify_keys: # Additional security can be provided by configuring a `verify key`, which
# "ed25519:auto": # will make synapse check that the response is signed by that key.
# key: "Noi6WqcDj0QmPxCNQqgezwTlBKrfqehY1u2FyWP9uYw" #
""" % locals() # This setting supercedes an older setting named `perspectives`. The old format
# is still supported for backwards-compatibility, but it is deprecated.
def read_perspectives(self, perspectives_servers): #
servers = {} # Options for each entry in the list include:
for server_name, server_config in perspectives_servers.items(): #
for key_id, key_data in server_config["verify_keys"].items(): # server_name: the name of the server. required.
if is_signing_algorithm_supported(key_id): #
key_base64 = key_data["key"] # verify_keys: an optional map from key id to base64-encoded public key.
key_bytes = decode_base64(key_base64) # If specified, we will check that the response is signed by at least
verify_key = decode_verify_key_bytes(key_id, key_bytes) # one of the given keys.
servers.setdefault(server_name, {})[key_id] = verify_key #
return servers # accept_keys_insecurely: a boolean. Normally, if `verify_keys` is unset,
# and federation_verify_certificates is not `true`, synapse will refuse
# to start, because this would allow anyone who can spoof DNS responses
# to masquerade as the trusted key server. If you know what you are doing
# and are sure that your network environment provides a secure connection
# to the key server, you can set this to `true` to override this
# behaviour.
#
# An example configuration might look like:
#
#trusted_key_servers:
# - server_name: "my_trusted_server.example.com"
# verify_keys:
# "ed25519:auto": "abcdefghijklmnopqrstuvwxyzabcdefghijklmopqr"
# - server_name: "my_other_trusted_server.example.com"
#
# The default configuration is:
#
#trusted_key_servers:
# - server_name: "matrix.org"
"""
% locals()
)
def read_signing_key(self, signing_key_path): def read_signing_key(self, signing_key_path):
signing_keys = self.read_file(signing_key_path, "signing_key") signing_keys = self.read_file(signing_key_path, "signing_key")
try: try:
return read_signing_keys(signing_keys.splitlines(True)) return read_signing_keys(signing_keys.splitlines(True))
except Exception as e: except Exception as e:
raise ConfigError( raise ConfigError("Error reading signing_key: %s" % (str(e)))
"Error reading signing_key: %s" % (str(e))
)
def read_old_signing_keys(self, old_signing_keys): def read_old_signing_keys(self, old_signing_keys):
keys = {} keys = {}
@ -182,9 +234,7 @@ class KeyConfig(Config):
if not self.path_exists(signing_key_path): if not self.path_exists(signing_key_path):
with open(signing_key_path, "w") as signing_key_file: with open(signing_key_path, "w") as signing_key_file:
key_id = "a_" + random_string(4) key_id = "a_" + random_string(4)
write_signing_keys( write_signing_keys(signing_key_file, (generate_signing_key(key_id),))
signing_key_file, (generate_signing_key(key_id),),
)
else: else:
signing_keys = self.read_file(signing_key_path, "signing_key") signing_keys = self.read_file(signing_key_path, "signing_key")
if len(signing_keys.split("\n")[0].split()) == 1: if len(signing_keys.split("\n")[0].split()) == 1:
@ -194,6 +244,106 @@ class KeyConfig(Config):
NACL_ED25519, key_id, signing_keys.split("\n")[0] NACL_ED25519, key_id, signing_keys.split("\n")[0]
) )
with open(signing_key_path, "w") as signing_key_file: with open(signing_key_path, "w") as signing_key_file:
write_signing_keys( write_signing_keys(signing_key_file, (key,))
signing_key_file, (key,),
def _perspectives_to_key_servers(config):
"""Convert old-style 'perspectives' configs into new-style 'trusted_key_servers'
Returns an iterable of entries to add to trusted_key_servers.
"""
# 'perspectives' looks like:
#
# {
# "servers": {
# "matrix.org": {
# "verify_keys": {
# "ed25519:auto": {
# "key": "Noi6WqcDj0QmPxCNQqgezwTlBKrfqehY1u2FyWP9uYw"
# }
# }
# }
# }
# }
#
# 'trusted_keys' looks like:
#
# [
# {
# "server_name": "matrix.org",
# "verify_keys": {
# "ed25519:auto": "Noi6WqcDj0QmPxCNQqgezwTlBKrfqehY1u2FyWP9uYw",
# }
# }
# ]
perspectives_servers = config.get("perspectives", {}).get("servers", {})
for server_name, server_opts in perspectives_servers.items():
trusted_key_server_entry = {"server_name": server_name}
verify_keys = server_opts.get("verify_keys")
if verify_keys is not None:
trusted_key_server_entry["verify_keys"] = {
key_id: key_data["key"] for key_id, key_data in verify_keys.items()
}
yield trusted_key_server_entry
TRUSTED_KEY_SERVERS_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "schema for the trusted_key_servers setting",
"type": "array",
"items": {
"type": "object",
"properties": {
"server_name": {"type": "string"},
"verify_keys": {
"type": "object",
# each key must be a base64 string
"additionalProperties": {"type": "string"},
},
},
"required": ["server_name"],
},
}
def _parse_key_servers(key_servers, federation_verify_certificates):
try:
jsonschema.validate(key_servers, TRUSTED_KEY_SERVERS_SCHEMA)
except jsonschema.ValidationError as e:
raise ConfigError("Unable to parse 'trusted_key_servers': " + e.message)
for server in key_servers:
server_name = server["server_name"]
result = TrustedKeyServer(server_name=server_name)
verify_keys = server.get("verify_keys")
if verify_keys is not None:
result.verify_keys = {}
for key_id, key_base64 in verify_keys.items():
if not is_signing_algorithm_supported(key_id):
raise ConfigError(
"Unsupported signing algorithm on key %s for server %s in "
"trusted_key_servers" % (key_id, server_name)
) )
try:
key_bytes = decode_base64(key_base64)
verify_key = decode_verify_key_bytes(key_id, key_bytes)
except Exception as e:
raise ConfigError(
"Unable to parse key %s for server %s in "
"trusted_key_servers: %s" % (key_id, server_name, e)
)
result.verify_keys[key_id] = verify_key
if (
not verify_keys
and not server.get("accept_keys_insecurely")
and not federation_verify_certificates
):
raise ConfigError(INSECURE_NOTARY_ERROR)
yield result

View File

@ -585,25 +585,27 @@ class PerspectivesKeyFetcher(BaseV2KeyFetcher):
super(PerspectivesKeyFetcher, self).__init__(hs) super(PerspectivesKeyFetcher, self).__init__(hs)
self.clock = hs.get_clock() self.clock = hs.get_clock()
self.client = hs.get_http_client() self.client = hs.get_http_client()
self.perspective_servers = self.config.perspectives self.key_servers = self.config.key_servers
@defer.inlineCallbacks @defer.inlineCallbacks
def get_keys(self, keys_to_fetch): def get_keys(self, keys_to_fetch):
"""see KeyFetcher.get_keys""" """see KeyFetcher.get_keys"""
@defer.inlineCallbacks @defer.inlineCallbacks
def get_key(perspective_name, perspective_keys): def get_key(key_server):
try: try:
result = yield self.get_server_verify_key_v2_indirect( result = yield self.get_server_verify_key_v2_indirect(
keys_to_fetch, perspective_name, perspective_keys keys_to_fetch, key_server
) )
defer.returnValue(result) defer.returnValue(result)
except KeyLookupError as e: except KeyLookupError as e:
logger.warning("Key lookup failed from %r: %s", perspective_name, e) logger.warning(
"Key lookup failed from %r: %s", key_server.server_name, e
)
except Exception as e: except Exception as e:
logger.exception( logger.exception(
"Unable to get key from %r: %s %s", "Unable to get key from %r: %s %s",
perspective_name, key_server.server_name,
type(e).__name__, type(e).__name__,
str(e), str(e),
) )
@ -613,8 +615,8 @@ class PerspectivesKeyFetcher(BaseV2KeyFetcher):
results = yield logcontext.make_deferred_yieldable( results = yield logcontext.make_deferred_yieldable(
defer.gatherResults( defer.gatherResults(
[ [
run_in_background(get_key, p_name, p_keys) run_in_background(get_key, server)
for p_name, p_keys in self.perspective_servers.items() for server in self.key_servers
], ],
consumeErrors=True, consumeErrors=True,
).addErrback(unwrapFirstError) ).addErrback(unwrapFirstError)
@ -629,17 +631,15 @@ class PerspectivesKeyFetcher(BaseV2KeyFetcher):
@defer.inlineCallbacks @defer.inlineCallbacks
def get_server_verify_key_v2_indirect( def get_server_verify_key_v2_indirect(
self, keys_to_fetch, perspective_name, perspective_keys self, keys_to_fetch, key_server
): ):
""" """
Args: Args:
keys_to_fetch (dict[str, dict[str, int]]): keys_to_fetch (dict[str, dict[str, int]]):
the keys to be fetched. server_name -> key_id -> min_valid_ts the keys to be fetched. server_name -> key_id -> min_valid_ts
perspective_name (str): name of the notary server to query for the keys key_server (synapse.config.key.TrustedKeyServer): notary server to query for
the keys
perspective_keys (dict[str, VerifyKey]): map of key_id->key for the
notary server
Returns: Returns:
Deferred[dict[str, dict[str, synapse.storage.keys.FetchKeyResult]]]: map Deferred[dict[str, dict[str, synapse.storage.keys.FetchKeyResult]]]: map
@ -649,6 +649,7 @@ class PerspectivesKeyFetcher(BaseV2KeyFetcher):
KeyLookupError if there was an error processing the entire response from KeyLookupError if there was an error processing the entire response from
the server the server
""" """
perspective_name = key_server.server_name
logger.info( logger.info(
"Requesting keys %s from notary server %s", "Requesting keys %s from notary server %s",
keys_to_fetch.items(), keys_to_fetch.items(),
@ -689,11 +690,13 @@ class PerspectivesKeyFetcher(BaseV2KeyFetcher):
) )
try: try:
processed_response = yield self._process_perspectives_response( self._validate_perspectives_response(
perspective_name, key_server,
perspective_keys,
response, response,
time_added_ms=time_now_ms, )
processed_response = yield self.process_v2_response(
perspective_name, response, time_added_ms=time_now_ms
) )
except KeyLookupError as e: except KeyLookupError as e:
logger.warning( logger.warning(
@ -717,28 +720,24 @@ class PerspectivesKeyFetcher(BaseV2KeyFetcher):
defer.returnValue(keys) defer.returnValue(keys)
def _process_perspectives_response( def _validate_perspectives_response(
self, perspective_name, perspective_keys, response, time_added_ms self, key_server, response,
): ):
"""Parse a 'Server Keys' structure from the result of a /key/query request """Optionally check the signature on 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: Args:
perspective_name (str): the name of the notary server that produced this key_server (synapse.config.key.TrustedKeyServer): the notary server that
result 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 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
""" """
perspective_name = key_server.server_name
perspective_keys = key_server.verify_keys
if perspective_keys is None:
# signature checking is disabled on this server
return
if ( if (
u"signatures" not in response u"signatures" not in response
or perspective_name not in response[u"signatures"] or perspective_name not in response[u"signatures"]
@ -751,6 +750,13 @@ class PerspectivesKeyFetcher(BaseV2KeyFetcher):
verify_signed_json(response, perspective_name, perspective_keys[key_id]) verify_signed_json(response, perspective_name, perspective_keys[key_id])
verified = True verified = True
if perspective_name == "matrix.org" and key_id == "ed25519:auto":
logger.warning(
"Trusting trusted_key_server responses signed by the "
"compromised matrix.org signing key 'ed25519:auto'. "
"This is a placebo."
)
if not verified: if not verified:
raise KeyLookupError( raise KeyLookupError(
"Response not signed with a known key: signed with: %r, known keys: %r" "Response not signed with a known key: signed with: %r, known keys: %r"
@ -760,10 +766,6 @@ class PerspectivesKeyFetcher(BaseV2KeyFetcher):
) )
) )
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"""

View File

@ -19,7 +19,7 @@ from mock import Mock
import canonicaljson import canonicaljson
import signedjson.key import signedjson.key
import signedjson.sign import signedjson.sign
from signedjson.key import get_verify_key from signedjson.key import encode_verify_key_base64, get_verify_key
from twisted.internet import defer from twisted.internet import defer
@ -40,7 +40,7 @@ class MockPerspectiveServer(object):
def get_verify_keys(self): def get_verify_keys(self):
vk = signedjson.key.get_verify_key(self.key) vk = signedjson.key.get_verify_key(self.key)
return {"%s:%s" % (vk.alg, vk.version): vk} return {"%s:%s" % (vk.alg, vk.version): encode_verify_key_base64(vk)}
def get_signed_key(self, server_name, verify_key): def get_signed_key(self, server_name, verify_key):
key_id = "%s:%s" % (verify_key.alg, verify_key.version) key_id = "%s:%s" % (verify_key.alg, verify_key.version)
@ -48,9 +48,7 @@ class MockPerspectiveServer(object):
"server_name": server_name, "server_name": server_name,
"old_verify_keys": {}, "old_verify_keys": {},
"valid_until_ts": time.time() * 1000 + 3600, "valid_until_ts": time.time() * 1000 + 3600,
"verify_keys": { "verify_keys": {key_id: {"key": encode_verify_key_base64(verify_key)}},
key_id: {"key": signedjson.key.encode_verify_key_base64(verify_key)}
},
} }
self.sign_response(res) self.sign_response(res)
return res return res
@ -63,10 +61,18 @@ class KeyringTestCase(unittest.HomeserverTestCase):
def make_homeserver(self, reactor, clock): def make_homeserver(self, reactor, clock):
self.mock_perspective_server = MockPerspectiveServer() self.mock_perspective_server = MockPerspectiveServer()
self.http_client = Mock() self.http_client = Mock()
hs = self.setup_test_homeserver(handlers=None, http_client=self.http_client)
keys = self.mock_perspective_server.get_verify_keys() config = self.default_config()
hs.config.perspectives = {self.mock_perspective_server.server_name: keys} config["trusted_key_servers"] = [
return hs {
"server_name": self.mock_perspective_server.server_name,
"verify_keys": self.mock_perspective_server.get_verify_keys(),
}
]
return self.setup_test_homeserver(
handlers=None, http_client=self.http_client, config=config
)
def check_context(self, _, expected): def check_context(self, _, expected):
self.assertEquals( self.assertEquals(
@ -371,10 +377,18 @@ class PerspectivesKeyFetcherTestCase(unittest.HomeserverTestCase):
def make_homeserver(self, reactor, clock): def make_homeserver(self, reactor, clock):
self.mock_perspective_server = MockPerspectiveServer() self.mock_perspective_server = MockPerspectiveServer()
self.http_client = Mock() self.http_client = Mock()
hs = self.setup_test_homeserver(handlers=None, http_client=self.http_client)
keys = self.mock_perspective_server.get_verify_keys() config = self.default_config()
hs.config.perspectives = {self.mock_perspective_server.server_name: keys} config["trusted_key_servers"] = [
return hs {
"server_name": self.mock_perspective_server.server_name,
"verify_keys": self.mock_perspective_server.get_verify_keys(),
}
]
return self.setup_test_homeserver(
handlers=None, http_client=self.http_client, config=config
)
def test_get_keys_from_perspectives(self): def test_get_keys_from_perspectives(self):
# arbitrarily advance the clock a bit # arbitrarily advance the clock a bit
@ -439,8 +453,7 @@ class PerspectivesKeyFetcherTestCase(unittest.HomeserverTestCase):
self.assertEqual(res["ts_valid_until_ms"], VALID_UNTIL_TS) self.assertEqual(res["ts_valid_until_ms"], VALID_UNTIL_TS)
self.assertEqual( self.assertEqual(
bytes(res["key_json"]), bytes(res["key_json"]), canonicaljson.encode_canonical_json(response)
canonicaljson.encode_canonical_json(response),
) )
def test_invalid_perspectives_responses(self): def test_invalid_perspectives_responses(self):

View File

@ -57,6 +57,7 @@ class MatrixFederationAgentTests(TestCase):
# present will not be trusted. We should do better here, though. # present will not be trusted. We should do better here, though.
config_dict = default_config("test", parse=False) config_dict = default_config("test", parse=False)
config_dict["federation_verify_certificates"] = False config_dict["federation_verify_certificates"] = False
config_dict["trusted_key_servers"] = []
config = HomeServerConfig() config = HomeServerConfig()
config.parse_config_dict(config_dict) config.parse_config_dict(config_dict)