diff options
author | matrix.org <matrix@matrix.org> | 2014-08-12 15:10:52 +0100 |
---|---|---|
committer | matrix.org <matrix@matrix.org> | 2014-08-12 15:10:52 +0100 |
commit | 4f475c7697722e946e39e42f38f3dd03a95d8765 (patch) | |
tree | 076d96d3809fb836c7245fd9f7960e7b75888a77 /synapse/crypto | |
download | synapse-4f475c7697722e946e39e42f38f3dd03a95d8765.tar.xz |
Reference Matrix Home Server
Diffstat (limited to 'synapse/crypto')
-rw-r--r-- | synapse/crypto/__init__.py | 14 | ||||
-rw-r--r-- | synapse/crypto/config.py | 159 | ||||
-rw-r--r-- | synapse/crypto/keyclient.py | 118 | ||||
-rw-r--r-- | synapse/crypto/keyserver.py | 110 | ||||
-rw-r--r-- | synapse/crypto/resource/__init__.py | 14 | ||||
-rw-r--r-- | synapse/crypto/resource/key.py | 160 |
6 files changed, 575 insertions, 0 deletions
diff --git a/synapse/crypto/__init__.py b/synapse/crypto/__init__.py new file mode 100644 index 0000000000..fe8a073cd3 --- /dev/null +++ b/synapse/crypto/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/synapse/crypto/config.py b/synapse/crypto/config.py new file mode 100644 index 0000000000..801dfd8656 --- /dev/null +++ b/synapse/crypto/config.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import ConfigParser as configparser +import argparse +import socket +import sys +import os +from OpenSSL import crypto +import nacl.signing +from syutil.base64util import encode_base64 +import subprocess + + +def load_config(description, argv): + config_parser = argparse.ArgumentParser(add_help=False) + config_parser.add_argument("-c", "--config-path", metavar="CONFIG_FILE", + help="Specify config file") + config_args, remaining_args = config_parser.parse_known_args(argv) + if config_args.config_path: + config = configparser.SafeConfigParser() + config.read([config_args.config_path]) + defaults = dict(config.items("KeyServer")) + else: + defaults = {} + parser = argparse.ArgumentParser( + parents=[config_parser], + description=description, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.set_defaults(**defaults) + parser.add_argument("--server-name", default=socket.getfqdn(), + help="The name of the server") + parser.add_argument("--signing-key-path", + help="The signing key to sign responses with") + parser.add_argument("--tls-certificate-path", + help="PEM encoded X509 certificate for TLS") + parser.add_argument("--tls-private-key-path", + help="PEM encoded private key for TLS") + parser.add_argument("--tls-dh-params-path", + help="PEM encoded dh parameters for ephemeral keys") + parser.add_argument("--bind-port", type=int, + help="TCP port to listen on") + parser.add_argument("--bind-host", default="", + help="Local interface to listen on") + + args = parser.parse_args(remaining_args) + + server_config = vars(args) + del server_config["config_path"] + return server_config + + +def generate_config(argv): + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--config-path", help="Specify config file", + metavar="CONFIG_FILE", required=True) + parser.add_argument("--server-name", default=socket.getfqdn(), + help="The name of the server") + parser.add_argument("--signing-key-path", + help="The signing key to sign responses with") + parser.add_argument("--tls-certificate-path", + help="PEM encoded X509 certificate for TLS") + parser.add_argument("--tls-private-key-path", + help="PEM encoded private key for TLS") + parser.add_argument("--tls-dh-params-path", + help="PEM encoded dh parameters for ephemeral keys") + parser.add_argument("--bind-port", type=int, required=True, + help="TCP port to listen on") + parser.add_argument("--bind-host", default="", + help="Local interface to listen on") + + args = parser.parse_args(argv) + + dir_name = os.path.dirname(args.config_path) + base_key_name = os.path.join(dir_name, args.server_name) + + if args.signing_key_path is None: + args.signing_key_path = base_key_name + ".signing.key" + + if args.tls_certificate_path is None: + args.tls_certificate_path = base_key_name + ".tls.crt" + + if args.tls_private_key_path is None: + args.tls_private_key_path = base_key_name + ".tls.key" + + if args.tls_dh_params_path is None: + args.tls_dh_params_path = base_key_name + ".tls.dh" + + if not os.path.exists(args.signing_key_path): + with open(args.signing_key_path, "w") as signing_key_file: + key = nacl.signing.SigningKey.generate() + signing_key_file.write(encode_base64(key.encode())) + + if not os.path.exists(args.tls_private_key_path): + with open(args.tls_private_key_path, "w") as private_key_file: + tls_private_key = crypto.PKey() + tls_private_key.generate_key(crypto.TYPE_RSA, 2048) + private_key_pem = crypto.dump_privatekey( + crypto.FILETYPE_PEM, tls_private_key + ) + private_key_file.write(private_key_pem) + else: + with open(args.tls_private_key_path) as private_key_file: + private_key_pem = private_key_file.read() + tls_private_key = crypto.load_privatekey( + crypto.FILETYPE_PEM, private_key_pem + ) + + if not os.path.exists(args.tls_certificate_path): + with open(args.tls_certificate_path, "w") as certifcate_file: + cert = crypto.X509() + subject = cert.get_subject() + subject.CN = args.server_name + + cert.set_serial_number(1000) + cert.gmtime_adj_notBefore(0) + cert.gmtime_adj_notAfter(10 * 365 * 24 * 60 * 60) + cert.set_issuer(cert.get_subject()) + cert.set_pubkey(tls_private_key) + + cert.sign(tls_private_key, 'sha256') + + cert_pem = crypto.dump_certificate(crypto.FILETYPE_PEM, cert) + + certifcate_file.write(cert_pem) + + if not os.path.exists(args.tls_dh_params_path): + subprocess.check_call([ + "openssl", "dhparam", + "-outform", "PEM", + "-out", args.tls_dh_params_path, + "2048" + ]) + + config = configparser.SafeConfigParser() + config.add_section("KeyServer") + for key, value in vars(args).items(): + if key != "config_path": + config.set("KeyServer", key, str(value)) + + with open(args.config_path, "w") as config_file: + config.write(config_file) + + +if __name__ == "__main__": + generate_config(sys.argv[1:]) diff --git a/synapse/crypto/keyclient.py b/synapse/crypto/keyclient.py new file mode 100644 index 0000000000..b53d1c572b --- /dev/null +++ b/synapse/crypto/keyclient.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from twisted.web.http import HTTPClient +from twisted.internet import defer, reactor +from twisted.internet.protocol import ClientFactory +from twisted.names.srvconnect import SRVConnector +import json +import logging + + +logger = logging.getLogger(__name__) + + +@defer.inlineCallbacks +def fetch_server_key(server_name, ssl_context_factory): + """Fetch the keys for a remote server.""" + + factory = SynapseKeyClientFactory() + + SRVConnector( + reactor, "matrix", server_name, factory, + protocol="tcp", connectFuncName="connectSSL", defaultPort=443, + connectFuncKwArgs=dict(contextFactory=ssl_context_factory)).connect() + + server_key, server_certificate = yield factory.remote_key + + defer.returnValue((server_key, server_certificate)) + + +class SynapseKeyClientError(Exception): + """The key wasn't retireved from the remote server.""" + pass + + +class SynapseKeyClientProtocol(HTTPClient): + """Low level HTTPS client which retrieves an application/json response from + the server and extracts the X.509 certificate for the remote peer from the + SSL connection.""" + + def connectionMade(self): + logger.debug("Connected to %s", self.transport.getHost()) + self.sendCommand(b"GET", b"/key") + self.endHeaders() + self.timer = reactor.callLater( + self.factory.timeout_seconds, + self.on_timeout + ) + + def handleStatus(self, version, status, message): + if status != b"200": + logger.info("Non-200 response from %s: %s %s", + self.transport.getHost(), status, message) + self.transport.abortConnection() + + def handleResponse(self, response_body_bytes): + try: + json_response = json.loads(response_body_bytes) + except ValueError: + logger.info("Invalid JSON response from %s", + self.transport.getHost()) + self.transport.abortConnection() + return + + certificate = self.transport.getPeerCertificate() + self.factory.on_remote_key((json_response, certificate)) + self.transport.abortConnection() + self.timer.cancel() + + def on_timeout(self): + logger.debug("Timeout waiting for response from %s", + self.transport.getHost()) + self.transport.abortConnection() + + +class SynapseKeyClientFactory(ClientFactory): + protocol = SynapseKeyClientProtocol + max_retries = 5 + timeout_seconds = 30 + + def __init__(self): + self.succeeded = False + self.retries = 0 + self.remote_key = defer.Deferred() + + def on_remote_key(self, key): + self.succeeded = True + self.remote_key.callback(key) + + def retry_connection(self, connector): + self.retries += 1 + if self.retries < self.max_retries: + connector.connector = None + connector.connect() + else: + self.remote_key.errback( + SynapseKeyClientError("Max retries exceeded")) + + def clientConnectionFailed(self, connector, reason): + logger.info("Connection failed %s", reason) + self.retry_connection(connector) + + def clientConnectionLost(self, connector, reason): + logger.info("Connection lost %s", reason) + if not self.succeeded: + self.retry_connection(connector) diff --git a/synapse/crypto/keyserver.py b/synapse/crypto/keyserver.py new file mode 100644 index 0000000000..48bd380781 --- /dev/null +++ b/synapse/crypto/keyserver.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from twisted.internet import reactor, ssl +from twisted.web import server +from twisted.web.resource import Resource +from twisted.python.log import PythonLoggingObserver + +from synapse.crypto.resource.key import LocalKey +from synapse.crypto.config import load_config + +from syutil.base64util import decode_base64 + +from OpenSSL import crypto, SSL + +import logging +import nacl.signing +import sys + + +class KeyServerSSLContextFactory(ssl.ContextFactory): + """Factory for PyOpenSSL SSL contexts that are used to handle incoming + connections and to make connections to remote servers.""" + + def __init__(self, key_server): + self._context = SSL.Context(SSL.SSLv23_METHOD) + self.configure_context(self._context, key_server) + + @staticmethod + def configure_context(context, key_server): + context.set_options(SSL.OP_NO_SSLv2 | SSL.OP_NO_SSLv3) + context.use_certificate(key_server.tls_certificate) + context.use_privatekey(key_server.tls_private_key) + context.load_tmp_dh(key_server.tls_dh_params_path) + context.set_cipher_list("!ADH:HIGH+kEDH:!AECDH:HIGH+kEECDH") + + def getContext(self): + return self._context + + +class KeyServer(object): + """An HTTPS server serving LocalKey and RemoteKey resources.""" + + def __init__(self, server_name, tls_certificate_path, tls_private_key_path, + tls_dh_params_path, signing_key_path, bind_host, bind_port): + self.server_name = server_name + self.tls_certificate = self.read_tls_certificate(tls_certificate_path) + self.tls_private_key = self.read_tls_private_key(tls_private_key_path) + self.tls_dh_params_path = tls_dh_params_path + self.signing_key = self.read_signing_key(signing_key_path) + self.bind_host = bind_host + self.bind_port = int(bind_port) + self.ssl_context_factory = KeyServerSSLContextFactory(self) + + @staticmethod + def read_tls_certificate(cert_path): + with open(cert_path) as cert_file: + cert_pem = cert_file.read() + return crypto.load_certificate(crypto.FILETYPE_PEM, cert_pem) + + @staticmethod + def read_tls_private_key(private_key_path): + with open(private_key_path) as private_key_file: + private_key_pem = private_key_file.read() + return crypto.load_privatekey(crypto.FILETYPE_PEM, private_key_pem) + + @staticmethod + def read_signing_key(signing_key_path): + with open(signing_key_path) as signing_key_file: + signing_key_b64 = signing_key_file.read() + signing_key_bytes = decode_base64(signing_key_b64) + return nacl.signing.SigningKey(signing_key_bytes) + + def run(self): + root = Resource() + root.putChild("key", LocalKey(self)) + site = server.Site(root) + reactor.listenSSL( + self.bind_port, + site, + self.ssl_context_factory, + interface=self.bind_host + ) + + logging.basicConfig(level=logging.DEBUG) + observer = PythonLoggingObserver() + observer.start() + + reactor.run() + + +def main(): + key_server = KeyServer(**load_config(__doc__, sys.argv[1:])) + key_server.run() + + +if __name__ == "__main__": + main() diff --git a/synapse/crypto/resource/__init__.py b/synapse/crypto/resource/__init__.py new file mode 100644 index 0000000000..fe8a073cd3 --- /dev/null +++ b/synapse/crypto/resource/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/synapse/crypto/resource/key.py b/synapse/crypto/resource/key.py new file mode 100644 index 0000000000..6ce6e0b034 --- /dev/null +++ b/synapse/crypto/resource/key.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from twisted.web.resource import Resource +from twisted.web.server import NOT_DONE_YET +from twisted.internet import defer +from synapse.http.server import respond_with_json_bytes +from synapse.crypto.keyclient import fetch_server_key +from syutil.crypto.jsonsign import sign_json, verify_signed_json +from syutil.base64util import encode_base64, decode_base64 +from syutil.jsonutil import encode_canonical_json +from OpenSSL import crypto +from nacl.signing import VerifyKey +import logging + + +logger = logging.getLogger(__name__) + + +class LocalKey(Resource): + """HTTP resource containing encoding the TLS X.509 certificate and NACL + signature verification keys for this server:: + + GET /key HTTP/1.1 + + HTTP/1.1 200 OK + Content-Type: application/json + { + "server_name": "this.server.example.com" + "signature_verify_key": # base64 encoded NACL verification key. + "tls_certificate": # base64 ASN.1 DER encoded X.509 tls cert. + "signatures": { + "this.server.example.com": # NACL signature for this server. + } + } + """ + + def __init__(self, key_server): + self.key_server = key_server + self.response_body = encode_canonical_json( + self.response_json_object(key_server) + ) + Resource.__init__(self) + + @staticmethod + def response_json_object(key_server): + verify_key_bytes = key_server.signing_key.verify_key.encode() + x509_certificate_bytes = crypto.dump_certificate( + crypto.FILETYPE_ASN1, + key_server.tls_certificate + ) + json_object = { + u"server_name": key_server.server_name, + u"signature_verify_key": encode_base64(verify_key_bytes), + u"tls_certificate": encode_base64(x509_certificate_bytes) + } + signed_json = sign_json( + json_object, + key_server.server_name, + key_server.signing_key + ) + return signed_json + + def getChild(self, name, request): + logger.info("getChild %s %s", name, request) + if name == '': + return self + else: + return RemoteKey(name, self.key_server) + + def render_GET(self, request): + return respond_with_json_bytes(request, 200, self.response_body) + + +class RemoteKey(Resource): + """HTTP resource for retreiving the TLS certificate and NACL signature + verification keys for a another server. Checks that the reported X.509 TLS + certificate matches the one used in the HTTPS connection. Checks that the + NACL signature for the remote server is valid. Returns JSON signed by both + the remote server and by this server. + + GET /key/remote.server.example.com HTTP/1.1 + + HTTP/1.1 200 OK + Content-Type: application/json + { + "server_name": "remote.server.example.com" + "signature_verify_key": # base64 encoded NACL verification key. + "tls_certificate": # base64 ASN.1 DER encoded X.509 tls cert. + "signatures": { + "remote.server.example.com": # NACL signature for remote server. + "this.server.example.com": # NACL signature for this server. + } + } + """ + + isLeaf = True + + def __init__(self, server_name, key_server): + self.server_name = server_name + self.key_server = key_server + Resource.__init__(self) + + def render_GET(self, request): + self._async_render_GET(request) + return NOT_DONE_YET + + @defer.inlineCallbacks + def _async_render_GET(self, request): + try: + server_keys, certificate = yield fetch_server_key( + self.server_name, + self.key_server.ssl_context_factory + ) + + resp_server_name = server_keys[u"server_name"] + verify_key_b64 = server_keys[u"signature_verify_key"] + tls_certificate_b64 = server_keys[u"tls_certificate"] + verify_key = VerifyKey(decode_base64(verify_key_b64)) + + if resp_server_name != self.server_name: + raise ValueError("Wrong server name '%s' != '%s'" % + (resp_server_name, self.server_name)) + + x509_certificate_bytes = crypto.dump_certificate( + crypto.FILETYPE_ASN1, + certificate + ) + + if encode_base64(x509_certificate_bytes) != tls_certificate_b64: + raise ValueError("TLS certificate doesn't match") + + verify_signed_json(server_keys, self.server_name, verify_key) + + signed_json = sign_json( + server_keys, + self.key_server.server_name, + self.key_server.signing_key + ) + + json_bytes = encode_canonical_json(signed_json) + respond_with_json_bytes(request, 200, json_bytes) + + except Exception as e: + json_bytes = encode_canonical_json({ + u"error": {u"code": 502, u"message": e.message} + }) + respond_with_json_bytes(request, 502, json_bytes) |