From 5e63c79449ab8797da1860178d35b50b22761d1a Mon Sep 17 00:00:00 2001 From: Philip Jenvey Date: Fri, 9 Sep 2016 16:24:21 -0700 Subject: [PATCH] feat: add a new client_certs endpoint config option requires whitelisted TLS client certificates for connections to the endpoint except to health/log_check handlers closes #498 --- .coveragerc | 4 +- autopush/base.py | 30 ++++ autopush/endpoint.py | 2 + autopush/log_check.py | 4 + autopush/main.py | 59 ++++++-- autopush/settings.py | 3 + autopush/ssl.py | 23 +++ autopush/tests/certs/__init__.py | 0 autopush/tests/certs/client1.pem | 52 +++++++ autopush/tests/certs/client2.pem | 52 +++++++ autopush/tests/certs/makecerts.py | 71 +++++++++ autopush/tests/certs/server.pem | 52 +++++++ autopush/tests/test_integration.py | 210 ++++++++++++++++++++++++--- autopush/tests/test_main.py | 37 +++++ autopush/web/base.py | 2 + configs/autopush_endpoint.ini.sample | 10 ++ 16 files changed, 573 insertions(+), 38 deletions(-) create mode 100644 autopush/tests/certs/__init__.py create mode 100644 autopush/tests/certs/client1.pem create mode 100644 autopush/tests/certs/client2.pem create mode 100644 autopush/tests/certs/makecerts.py create mode 100644 autopush/tests/certs/server.pem diff --git a/.coveragerc b/.coveragerc index 4623533e..191e73b8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,3 +1,5 @@ [report] -omit = *noseplugin* +omit = + *noseplugin* + autopush/tests/certs/makecerts.py show_missing = true diff --git a/autopush/base.py b/autopush/base.py index be8086ad..e1f142d8 100644 --- a/autopush/base.py +++ b/autopush/base.py @@ -45,3 +45,33 @@ def write_error(self, code, **kwargs): self.log.failure("Error in handler: %s" % code, client_info=self._client_info) self.finish() + + def authenticate_peer_cert(self): + """Authenticate the client per the configured client_certs. + + Aborts the request w/ a 401 on failure. + + """ + cert = self.request.connection.transport.getPeerCertificate() + assert cert, "Expected a TLS peer cert (VERIFY_FAIL_IF_NO_PEER_CERT)" + + cert_signature = cert.digest('sha256') + cn = cert.get_subject().CN + auth = self.ap_settings.client_certs.get(cert_signature) + if auth is not None: + # TLS authenticated + self._client_info['tls_auth'] = auth + self._client_info['tls_auth_sha256'] = cert_signature + self._client_info['tls_auth_cn'] = cn + return + + self.log.warn("Failed TLS auth", + tls_failed_sha256=cert_signature, + tls_failed_cn=cn, + client_info=self._client_info) + self.set_status(401) + # "Transport mode" isn't standard, inspired by: + # http://www6.ietf.org/mail-archive/web/tls/current/msg05589.html + self.set_header('WWW-Authenticate', + 'Transport mode="tls-client-certificate"') + self.finish() diff --git a/autopush/endpoint.py b/autopush/endpoint.py index 4758fed5..1e94c4f4 100644 --- a/autopush/endpoint.py +++ b/autopush/endpoint.py @@ -132,6 +132,8 @@ def initialize(self, ap_settings): def prepare(self): """Common request preparation""" + if self.ap_settings.enable_tls_auth: + self.authenticate_peer_cert() if self.ap_settings.cors: self.set_header("Access-Control-Allow-Origin", "*") self.set_header("Access-Control-Allow-Methods", diff --git a/autopush/log_check.py b/autopush/log_check.py index be7a8b5c..eeb03d2c 100644 --- a/autopush/log_check.py +++ b/autopush/log_check.py @@ -13,6 +13,10 @@ def initialize(self, ap_settings): self.ap_settings = ap_settings self._client_info = self._init_info() + def authenticate_peer_cert(self): + """LogCheck skips authentication checks""" + pass + @cyclone.web.asynchronous def get(self, err_type=None): """HTTP GET diff --git a/autopush/main.py b/autopush/main.py index adb23b50..180caed8 100644 --- a/autopush/main.py +++ b/autopush/main.py @@ -86,7 +86,7 @@ def add_shared_args(parser): default="", env_var="SSL_CERT") parser.add_argument('--ssl_dh_param', help="SSL DH Param file (openssl dhparam 1024)", - type=str, default="", env_var="SSL_DH_PARAM") + type=str, default=None, env_var="SSL_DH_PARAM") parser.add_argument('--router_tablename', help="DynamoDB Router Tablename", type=str, default="router", env_var="ROUTER_TABLENAME") parser.add_argument('--storage_tablename', @@ -297,6 +297,9 @@ def _parse_endpoint(sysargs, use_files=True): parser.add_argument('--auth_key', help='Bearer Token source key', type=str, default=[], env_var='AUTH_KEY', action="append") + parser.add_argument('--client_certs', + help="Allowed TLS client certificates", + type=str, env_var='CLIENT_CERTS', default="{}") add_shared_args(parser) @@ -342,6 +345,33 @@ def make_settings(args, **kwargs): "max_data": args.max_data, "collapsekey": args.gcm_collapsekey, "senderIDs": sender_ids} + + client_certs = None + # endpoint only + if getattr(args, 'client_certs', None): + try: + client_certs_arg = json.loads(args.client_certs) + except (ValueError, TypeError): + log.critical(format="Invalid JSON specified for client_certs") + return + if client_certs_arg: + if not args.ssl_key: + log.critical(format="client_certs specified without SSL " + "enabled (no ssl_key specified)") + return + client_certs = {} + for name, sigs in client_certs_arg.iteritems(): + if not isinstance(sigs, list): + log.critical( + format="Invalid JSON specified for client_certs") + return + for sig in sigs: + sig = sig.upper() + if not (name and sig) or sig in client_certs: + log.critical(format="Invalid client_certs argument") + return + client_certs[sig] = name + if args.fcm_enabled: # Create a common gcmclient if not args.fcm_auth: @@ -383,6 +413,7 @@ def make_settings(args, **kwargs): resolve_hostname=args.resolve_hostname, wake_timeout=args.wake_timeout, ami_id=ami_id, + client_certs=client_certs, **kwargs ) @@ -472,21 +503,20 @@ def connection_main(sysargs=None, use_files=True): # Start the WebSocket listener. if args.ssl_key: - context_factory = AutopushSSLContextFactory(args.ssl_key, - args.ssl_cert) - if args.ssl_dh_param: - context_factory.getContext().load_tmp_dh(args.ssl_dh_param) - + context_factory = AutopushSSLContextFactory( + args.ssl_key, + args.ssl_cert, + dh_file=args.ssl_dh_param) reactor.listenSSL(args.port, site_factory, context_factory) else: reactor.listenTCP(args.port, site_factory) # Start the internal routing listener. if args.router_ssl_key: - context_factory = AutopushSSLContextFactory(args.router_ssl_key, - args.router_ssl_cert) - if args.ssl_dh_param: - context_factory.getContext().load_tmp_dh(args.ssl_dh_param) + context_factory = AutopushSSLContextFactory( + args.router_ssl_key, + args.router_ssl_cert, + dh_file=args.ssl_dh_param) reactor.listenSSL(args.router_port, site, context_factory) else: reactor.listenTCP(args.router_port, site) @@ -558,10 +588,11 @@ def endpoint_main(sysargs=None, use_files=True): # start the senderIDs refresh timer if args.ssl_key: - context_factory = AutopushSSLContextFactory(args.ssl_key, - args.ssl_cert) - if args.ssl_dh_param: - context_factory.getContext().load_tmp_dh(args.ssl_dh_param) + context_factory = AutopushSSLContextFactory( + args.ssl_key, + args.ssl_cert, + dh_file=args.ssl_dh_param, + require_peer_certs=settings.enable_tls_auth) reactor.listenSSL(args.port, site, context_factory) else: reactor.listenTCP(args.port, site) diff --git a/autopush/settings.py b/autopush/settings.py index bef5711f..f76b6d34 100644 --- a/autopush/settings.py +++ b/autopush/settings.py @@ -88,6 +88,7 @@ def __init__(self, bear_hash_key=None, preflight_uaid="deadbeef00000000deadbeef000000000", ami_id=None, + client_certs=None, ): """Initialize the Settings object @@ -153,6 +154,8 @@ def __init__(self, self.endpoint_hostname, endpoint_port ) + self.enable_tls_auth = client_certs is not None + self.client_certs = client_certs # Database objects self.router_table = get_router_table(router_tablename, diff --git a/autopush/ssl.py b/autopush/ssl.py index 6c70ac91..071e7f5a 100644 --- a/autopush/ssl.py +++ b/autopush/ssl.py @@ -38,6 +38,12 @@ class AutopushSSLContextFactory(ssl.DefaultOpenSSLContextFactory): """A SSL context factory""" + + def __init__(self, *args, **kwargs): + self.dh_file = kwargs.pop('dh_file', None) + self.require_peer_certs = kwargs.pop('require_peer_certs', False) + ssl.DefaultOpenSSLContextFactory.__init__(self, *args, **kwargs) + def cacheContext(self): """Setup the main context factory with custom SSL settings""" if self._context is None: @@ -54,4 +60,21 @@ def cacheContext(self): ctx.use_certificate_chain_file(self.certificateFileName) ctx.use_privatekey_file(self.privateKeyFileName) + if self.dh_file: + ctx.load_tmp_dh(self.dh_file) + + if self.require_peer_certs: + # Require peer certs but only for use by + # RequestHandlers + ctx.set_verify( + SSL.VERIFY_PEER | + SSL.VERIFY_FAIL_IF_NO_PEER_CERT | + SSL.VERIFY_CLIENT_ONCE, + self._allow_peer) + self._context = ctx + + def _allow_peer(self, conn, cert, errno, depth, preverify_ok): + # skip verification: we only care about whitelisted signatures + # on file + return True diff --git a/autopush/tests/certs/__init__.py b/autopush/tests/certs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/autopush/tests/certs/client1.pem b/autopush/tests/certs/client1.pem new file mode 100644 index 00000000..5ba51abc --- /dev/null +++ b/autopush/tests/certs/client1.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDP2gdtxnN8cUyJ +Ix9nR3AG0DANs5jkhogfa23wiWtwHsRlACKPEtJ4ScKsofmshwf0EsC+k7jO7qOc +COscgWtjASbNBGx4lY35dOTvM3SlTnPLMbU1rOVYuGYo8l8mE9P6g63pIQPSRp5P +uQwPJVLopVlsM37F15KXrOw55+djPG76sjgLLZgLGTmVf6y4/JFgapKhktdA6W1p +zwM+dzLXFdP5P6OGp2QhFzktq4zhMnh6PwuOHjdLK+qD6Po1pPMHoSvXdUegQJXz +qOO5sxstEVhnGUcNw47jikXSpFRio3CFrvm4gFgWsiIz9H02S8520ksbuzs5462I +e9HU0OvfAgMBAAECggEAAIUnJ5srhtAxxNYVqgh/u0SiGias50V+6bU3HTb60dkB +3M7XR0xgwnRMzGnLWicsWewCF3f/KmVme2l6TSP1xNWn5zNvog2dwVYNjJuwWwmL +OKLzNSMtn782YjvZCRtbmHDj6oVuoQQUOVbZNOB/CJ4BT0gBtTUI5OBF5w8xgQoQ +hV2drp7PR/1NBSuCOxGozh7nPahn4+Ru0mCsHlEXREnofp/jmVXxkNspCpal37MP +uFfMv4eFc49dJN9sLXQj3hvfZ36iKhZvRHXaEWRlCz3X2JgyIH7T2EFon7DoU79M +NGchi8Bz2Q1jsr9OsvQ0eRKTTVs9svgMJalc167TQQKBgQDtOuzcmbVC0ItWs+oA +vGMKaksABWOtifAdK9YcJz6nw7RolZWL5kdhjNEKsLe/ge8z+WvuzqmFUk/PZvB9 +sXRovilZ68/yJqXUodnbN9S81s+YKHof+nTxk/7ALqaueH2iT2j2YPzZXC3D1t7I +uQm9+6OwRqvMOREsMWIhZCv1eQKBgQDgTAuGyB3zM2bYx8iO/MVuVDyqtbYcwvUT +qOiExCmVaPYM5i/8vS/GA4fnE0p+x/MAwANcO+nMYbvJkrhoarfX8nV1P+Eu4tDV +xrIca0r0wiP0onVw7FqSQRnCXOrjVOEylPgLVGTmhqJqo1Hi6R1+4JHsHARrjbSq +YTs+autOFwKBgCWr0McrJWyJv0ayZTterv+NZ4GGWZDKMbYAKwzncnyjiDd/YXMI +y1cDTIK9E0C2+mwvdGNEsAi6zG+r8g6Tql+jqt9bofbbCkRcu0KjeAXQusB31QTU ++dMO5EpSXiegfJrUr19IgX5ms+HAcjo/n/tqRVENt+RDP6Xb5bBVvuFJAoGBAKi2 +irUHMgANWf8Vx7ZGS/uBQWDm7eUUgGQZWU0EgILyQKHTQ6VIaPb5EPCvggl7PT4D +MIPgTSx/F1G4Gx3vp/m3VsKrGia6VXt3yeG2ktsobQNGcDBQmJAKh+W7HrOA1SPH +Cgz7nioIe4La9m1IC/ez1A9Vw71jCdJe8MEyi2xhAoGBALaQqDfMmn/LbzXMlkTQ +WJTKsZi5UplJvtQ7aIOL4Fgvj2WQvQ5TYZEhW9Ur9W7JSik/MwmagqYgORxaEI1B +6macvD97j5Z3rmxN4G27mPLPoAt7ntQof5cHYu3XoZbFDUbHDt6V4pgtClXoljWe +cycS7E1EDLhlDwfbaOpiWCEu +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIID8zCCAtsCEQDed3iLgJtF7olgvxiTh0nUMA0GCSqGSIb3DQEBBQUAMIGoMQsw +CQYDVQQGEwJUUjEPMA0GA1UECAwGw4dvcnVtMRQwEgYDVQQHDAtCYcWfbWFrw6fE +sTESMBAGA1UEAwwJbG9jYWxob3N0MRUwEwYDVQQKDAxNb3ppbGxhIFRlc3QxITAf +BgNVBAsMGEF1dG9wdXNoIFRlc3Qgc2VydmVyLnBlbTEkMCIGCSqGSIb3DQEJARYV +b3R0by5wdXNoQGV4YW1wbGUuY29tMCAXDTE2MDkyMTIxMzYwNloYDzIxMTYwODI4 +MjEzNjA2WjCBqTELMAkGA1UEBhMCVFIxDzANBgNVBAgMBsOHb3J1bTEUMBIGA1UE +BwwLQmHFn21ha8OnxLExEjAQBgNVBAMMCWxvY2FsaG9zdDEVMBMGA1UECgwMTW96 +aWxsYSBUZXN0MSIwIAYDVQQLDBlBdXRvcHVzaCBUZXN0IGNsaWVudDEucGVtMSQw +IgYJKoZIhvcNAQkBFhVvdHRvLnB1c2hAZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQDP2gdtxnN8cUyJIx9nR3AG0DANs5jkhogfa23w +iWtwHsRlACKPEtJ4ScKsofmshwf0EsC+k7jO7qOcCOscgWtjASbNBGx4lY35dOTv +M3SlTnPLMbU1rOVYuGYo8l8mE9P6g63pIQPSRp5PuQwPJVLopVlsM37F15KXrOw5 +5+djPG76sjgLLZgLGTmVf6y4/JFgapKhktdA6W1pzwM+dzLXFdP5P6OGp2QhFzkt +q4zhMnh6PwuOHjdLK+qD6Po1pPMHoSvXdUegQJXzqOO5sxstEVhnGUcNw47jikXS +pFRio3CFrvm4gFgWsiIz9H02S8520ksbuzs5462Ie9HU0OvfAgMBAAGjGDAWMBQG +A1UdEQQNMAuCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQUFAAOCAQEAq49K5htZ3Sw9 +osT3CcKGS7m7r4FuvpL0gzE6ZDMElMK4q1u7M/DJKsXpINUqjT6cYwIJZyl02pmO +HMwuImdCWpuWSE0lUyMjVSOXAGBkkcJiVE3iP9tGH1kcCTLKoY2Ajc8w65HNxRR7 +edSprTDay4Zt2ay6AG4pYl8uGN5HuvgRzyjFPjVcye4ZfF3TaJRZAMl9lJSI1qMl +DSziBbcjkx7N4TbMe8m01YYA1HmX2ZIckqEiTa05d1H548p5Dbw6GhoM/MzbEUeB +s9/XlKoFQPVADqrd6yJcTGi6mMnuKMPkUhjPjzB4ah0m4gL0oB8E/TEs7yPAYPtw +g17mJupYMw== +-----END CERTIFICATE----- diff --git a/autopush/tests/certs/client2.pem b/autopush/tests/certs/client2.pem new file mode 100644 index 00000000..8c98fbfb --- /dev/null +++ b/autopush/tests/certs/client2.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDgoFA4pkilcycS +P1q8Dfd8PSTYvabFITo4Qu3aDSezhw6WnoEPSO+O2Bd/GAAmoLzmMcHPdRUsUhp7 +RIuSNnd6+g6ulXyf9BFcNclBmdIhvUAvVxy4/fBoJ0or8eNgETRRHj19j7S0tAUt +oSAVW1XniSS+qX11kGsLqk6qIUl2v+p9j6GVdk1DkMO/xat2qdGpPcleexz1LD3o +Tqs2rE8hdFlbJXE5K7jN7miej1cnEb+BV99N7P/3RBwkQdAfNYyzueY/UsfCaieF +892+yo7eNA8qMZxWs86f7IjZAXJaO9sgg6YBCP+J0Oed83nc48Jq6LnCsNC9U7l5 +k/UPbywZAgMBAAECggEAHXkLbZNr3sGQM9W6Owh+G4AOUJ35vs3QTMeDW+Pz/JtQ +77RWbMH+JLj3xEZK5saaYn3O10CeiZUwQlJJGeMppCohOQkGNBqbGIU6JfBf4Otq ++4srip5PJ+tX0RJI6jb3rVkRamMrq7YfI9CLXCIC0IFvH9EvU/iiAwLSlYIOmJTw +6e96Um5eIR2NjEJk2Mlv8HpJ0Ai09gDttnYFyGMUa1USGX4SeqViRN/6mb2zqvdB +OCrwhYwuPsA+WSHISKuj8xHusDCuxSTKCpYv08r/E1fMywYPP6BEp/IkrQfc6Ajv +23CJ41rNGxg3SGi1O4v1ADygUNweb44eSZyDDkyZAQKBgQDyV0U/WdRXKhk5ZTil +GWuseAt7V6UvqTHA0q7CBdDT+0ICospcENLOIul6ER16rDdAC2qeVrbReKYqska6 +uVQq8513lvnt9fQwdgdH5kwSkEp4Ci0G6pnxAaJawJS3X5MdftLR2foHAaDsd59i +6qFL/IJW+YV25epJTqSXDD2iIQKBgQDtSXA3Ah/gJNlRb/U4SN750Ykq6s5Gpdww +teOph0lPyl5baDy3mJZ0pgKntMNcn66zCoI09Bg1X6uiguqjPisTfcj2OwwLA+Co +ky1iX1Tt5mcZtJvfDhlSSLvyKVGNKi2ci3/nxleRf3wYEYTV1GxsXOffc68jT5Sc +U3WN+qE6+QKBgQDVSowvCtAB+5KB2p31aZ9EB9ALOgOwJBkfHg8jw1yeBkl96mty +hngTZ9TYU9H/Uy25l5K6U5XKXYbak1f/Jfh3aT0RsXa9wriuImOcG4ye1hJE/qfM +q5Tb8tVDTLZXgq4HysSgYFpX6k4Jcet9cwaNy2uoQyr9j7QZ2zavnt1sIQKBgQCK +Ai2PAOBTOHtg/zHrs45kVDdoS7r/ohYICrJH9pRwIO5yUZUG32uamrdunRcHNySf +o5wJenLeEC++TFB184GQS5dnhv5BJdczlK5PycyWtWv/qmkB+axGjGErvlZdOUvg +Ac65mkVyLWiagw30ZCFPgVWnRBx3+CAiL/RuSHgf2QKBgQDu05Q0yiF+WzqPib0P +iFCVKUC7QuZPlDolguVu9dP50/lPnnKOrfsl0Crg61PEf0GIFTH/u6pAaOtfiNf8 +7KvZFw9TfdEfOIrIAV6r+cjoOjh5ojQOqM6dbfkj49pVAP87QOiRPcCrnB06iIFU +LL3gYh24K1luBJy+Yk5UerWdbQ== +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIID8jCCAtoCEEEnv06bQUtWpwcz2aCf3oswDQYJKoZIhvcNAQEFBQAwgagxCzAJ +BgNVBAYTAlRSMQ8wDQYDVQQIDAbDh29ydW0xFDASBgNVBAcMC0JhxZ9tYWvDp8Sx +MRIwEAYDVQQDDAlsb2NhbGhvc3QxFTATBgNVBAoMDE1vemlsbGEgVGVzdDEhMB8G +A1UECwwYQXV0b3B1c2ggVGVzdCBzZXJ2ZXIucGVtMSQwIgYJKoZIhvcNAQkBFhVv +dHRvLnB1c2hAZXhhbXBsZS5jb20wIBcNMTYwOTIxMjEzNjA3WhgPMjExNjA4Mjgy +MTM2MDdaMIGpMQswCQYDVQQGEwJUUjEPMA0GA1UECAwGw4dvcnVtMRQwEgYDVQQH +DAtCYcWfbWFrw6fEsTESMBAGA1UEAwwJbG9jYWxob3N0MRUwEwYDVQQKDAxNb3pp +bGxhIFRlc3QxIjAgBgNVBAsMGUF1dG9wdXNoIFRlc3QgY2xpZW50Mi5wZW0xJDAi +BgkqhkiG9w0BCQEWFW90dG8ucHVzaEBleGFtcGxlLmNvbTCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAOCgUDimSKVzJxI/WrwN93w9JNi9psUhOjhC7doN +J7OHDpaegQ9I747YF38YACagvOYxwc91FSxSGntEi5I2d3r6Dq6VfJ/0EVw1yUGZ +0iG9QC9XHLj98GgnSivx42ARNFEePX2PtLS0BS2hIBVbVeeJJL6pfXWQawuqTqoh +SXa/6n2PoZV2TUOQw7/Fq3ap0ak9yV57HPUsPehOqzasTyF0WVslcTkruM3uaJ6P +VycRv4FX303s//dEHCRB0B81jLO55j9Sx8JqJ4Xz3b7Kjt40DyoxnFazzp/siNkB +clo72yCDpgEI/4nQ553zedzjwmroucKw0L1TuXmT9Q9vLBkCAwEAAaMYMBYwFAYD +VR0RBA0wC4IJbG9jYWxob3N0MA0GCSqGSIb3DQEBBQUAA4IBAQAnqnOE8pV/C9y3 +BUYS98MGoeS4Wd3jxSXSYJ0jJVRojfG0MvtJOSfZL5FCUGTmpldmw9E3IHJZNExi +k2hApnLh0xrcWepQan9eDaim+y/nPBZwxAf3kzpwwhI9pA/8pGf4pLhQmPReYacd +RWgz13JnIUAJHkOwG7Vbk6JAW74LJaiJyYe0SlbGn3TevT9wKzq63XELN0SS8tto +t9Hs0q7VbHSLZ6xdd78667LNa0VUXOuIY11C607Rdm1VvlKindazBzp44KVD/AAU +xltE1FyPs2NVzf6NiEqXOLXX47kM7CByrabpo8nfUx1w79FscHP7DfjDZZIM0qT3 +Gmi/Of1Z +-----END CERTIFICATE----- diff --git a/autopush/tests/certs/makecerts.py b/autopush/tests/certs/makecerts.py new file mode 100644 index 00000000..92d0771f --- /dev/null +++ b/autopush/tests/certs/makecerts.py @@ -0,0 +1,71 @@ +# encoding: utf-8 + +import uuid +from inspect import getsource + +from OpenSSL.crypto import ( + FILETYPE_PEM, PKey, TYPE_RSA, X509, X509Extension, + dump_certificate, dump_privatekey) + +SHABREAK = 51 + + +def make_cert(filename, cacert=None, cakey=None): + key = PKey() + key.generate_key(TYPE_RSA, 2048) + + cert = X509() + subject = cert.get_subject() + + subject.C = b"TR" + subject.ST = b"Çorum" + subject.L = b"Başmakçı" + subject.CN = b"localhost" + subject.O = b"Mozilla Test" + subject.OU = b"Autopush Test %s" % filename + subject.emailAddress = b"otto.push@example.com" + subjectAltName = X509Extension(b'subjectAltName', False, b'DNS:localhost') + cert.add_extensions([subjectAltName]) + + cert.set_serial_number(uuid.uuid4().int) + cert.gmtime_adj_notBefore(0) + cert.gmtime_adj_notAfter(60 * 60 * 24 * 365 * 100) + + cert.set_pubkey(key) + + if not cacert: + # self sign + cacert = cert + cakey = key + cert.set_issuer(cacert.get_subject()) + cert.sign(cakey, 'sha1') + + with open(filename, 'wb') as fp: + fp.write(dump_privatekey(FILETYPE_PEM, key)) + fp.write(dump_certificate(FILETYPE_PEM, cert)) + return cert, key + + +def save_sha256(sha256): + import __main__ + source = getsource(__main__) + source = source.splitlines(True)[:-2] + with open('makecerts.py', 'wb') as fp: + fp.writelines(source) + fp.write("CLIENT1_SHA256 = ('%s'\n" % sha256[:SHABREAK]) + fp.write(" '%s')\n" % sha256[SHABREAK:]) + + +def main(): + # self signed + server, serverkey = make_cert("server.pem") + client1, _ = make_cert("client1.pem", cacert=server, cakey=serverkey) + make_cert("client2.pem", cacert=server, cakey=serverkey) + save_sha256(client1.digest('sha256')) + + +if __name__ == '__main__': + main() + +CLIENT1_SHA256 = ('2C:78:32:76:D8:2E:5F:C4:C2:3B:44:57:BA:1B:AF:1F:94:' + '56:52:94:F7:5A:FE:3A:8C:7E:85:4C:6D:54:76:D2') diff --git a/autopush/tests/certs/server.pem b/autopush/tests/certs/server.pem new file mode 100644 index 00000000..80bebbc2 --- /dev/null +++ b/autopush/tests/certs/server.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC9jfov9vZ22Q5g +mDrIlO7NNAXHTwQS0AT4GM2woqI7LvrbkAprmfQzbx/6w56rRY6JG7zxwXb34yCw +ckR9DS0Tl3r9KtEHHOMJlaIIb8jP/MuY04K0CS7777r/l2iLTVM1VRAwXtYvb3BC +clk6RIe4fnNXHKazoPgEX1VDdZ1zTWeTBP3U81KDkcT+1vGKyEPeFFX3cLYwIC50 +quKeI32NP5T+pnkHvtdMymFFVI1qPxGzgEXzXwr1KuiiY+/Xxlzdvr1xAftP05lk +Es6wpv/Spst9/HW85Sv/6l3ArJ/qkg6qFLnQCpCd1yTOIfx5CR/QV06c8PWaH3N6 +0o0rNlFrAgMBAAECggEAP6m+nHtqfbIa5RBbteBnb94IDs+YA6V/9b+G3ctRh2UY +9cMj7M2xOdEZwYQP92AOJjh9tgiPJ2ROJ1TJceKKACmBMiQ+PhBSpHZwDDtXC5vQ +2Yw4OrVW5WC3wn4cq6SVzLS8EjLX5uiboIV9OFep6an2nQxPZMcsZXOLVHPdJMsH +a4LwX8Z/URK26IxmK49syVGYiim95OYnjmkw6kOVeOPyDcpm7VZpAL82CKUoG9wn +HPqDOl+oLGdZMYxXzsR8DvdOFbrA8Kgcbk5hC6LT5wHLo33ZYwarNTUIl2pR7pQY +Wc2CCDF/xfyrx2s9mRNn9+pN0kDfCF/EWdt+/clbGQKBgQDkeNUw2Z0FgbqREdVs ++x04shv4WiXaPRg3937mv4wyhsTr0sbwthIABYmM0c8gHOSU3GqzElVb6F1/03Ir +Fcwv12FaQt2zH2tpX4LpLdpvZcKZ7EY5inFli1oDWSTJYgPF1v7EVKg5VKdGORxW +zum9jDZtV1Vhn8FoAxr6fHoj7QKBgQDUZL75XgYYcS30jzyjoHqfI3m17ZAtNFv8 +/tf+8kCgEvE2LudIHxcJFxO3sMnpFf0wDcOKhJx3cZM0BL23veovlwhv8z8bGrQr +PpPVzzyMZzIoReX06jbaB+6HLUM5slgTR1isdQ0K+9yn1gleY4xihKzAg4hHlfCd +vN/FGQLPtwKBgQCUQXCeDWgeLauB/Z+P43d31ePeiajscB6qGpknZP61vsxD9veP +NaaCixVR4sfUH8uYaVt3rhrj0+nwS6ZXHQLtvIZ4ytEQLgBGVWseotmO1eqsdSyw +pAynhwC3EX6Ui1zFYZjaj9DFuw/8uoyQLB5zGGwtEbHe8orPUsyL5IBhlQKBgQCw +F9c+aPYkzbGMUjW0dBk2V/NRIuWmlQKvf5sVhUSPcyUKB1k6MoymvLtcUPcWiWSh +sses0MXpQgEJ3+Rrssm3K0GTj9mCX2GBeSCY5HkO74BmY42Ewas4mdrpuF1EONlN +IpoiYemucaNAg/TAjUiXZejUtDkJIQedwDrG7zWlgQKBgQDi3ts8QeOE0fMEV5Hx +IY8crHZM6gxraJ/J3qHHsUzhOxqixLoMVAYoAPXFW+NOtIpjTFEuignuQwksfv3Z +SJrC0AZQQmxY3xJok2mhvsdTFmWGsb2YTX0ljBFRSTDJbeKiUCqBo1hFlu28T0V6 +HFHTgewZwoX61ntYAgFQc513aA== +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIID8jCCAtoCEQDVF3GtM5RKqZz48WM6NHXaMA0GCSqGSIb3DQEBBQUAMIGoMQsw +CQYDVQQGEwJUUjEPMA0GA1UECAwGw4dvcnVtMRQwEgYDVQQHDAtCYcWfbWFrw6fE +sTESMBAGA1UEAwwJbG9jYWxob3N0MRUwEwYDVQQKDAxNb3ppbGxhIFRlc3QxITAf +BgNVBAsMGEF1dG9wdXNoIFRlc3Qgc2VydmVyLnBlbTEkMCIGCSqGSIb3DQEJARYV +b3R0by5wdXNoQGV4YW1wbGUuY29tMCAXDTE2MDkyMTIxMzYwNloYDzIxMTYwODI4 +MjEzNjA2WjCBqDELMAkGA1UEBhMCVFIxDzANBgNVBAgMBsOHb3J1bTEUMBIGA1UE +BwwLQmHFn21ha8OnxLExEjAQBgNVBAMMCWxvY2FsaG9zdDEVMBMGA1UECgwMTW96 +aWxsYSBUZXN0MSEwHwYDVQQLDBhBdXRvcHVzaCBUZXN0IHNlcnZlci5wZW0xJDAi +BgkqhkiG9w0BCQEWFW90dG8ucHVzaEBleGFtcGxlLmNvbTCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAL2N+i/29nbZDmCYOsiU7s00BcdPBBLQBPgYzbCi +ojsu+tuQCmuZ9DNvH/rDnqtFjokbvPHBdvfjILByRH0NLROXev0q0Qcc4wmVoghv +yM/8y5jTgrQJLvvvuv+XaItNUzVVEDBe1i9vcEJyWTpEh7h+c1ccprOg+ARfVUN1 +nXNNZ5ME/dTzUoORxP7W8YrIQ94UVfdwtjAgLnSq4p4jfY0/lP6meQe+10zKYUVU +jWo/EbOARfNfCvUq6KJj79fGXN2+vXEB+0/TmWQSzrCm/9Kmy338dbzlK//qXcCs +n+qSDqoUudAKkJ3XJM4h/HkJH9BXTpzw9Zofc3rSjSs2UWsCAwEAAaMYMBYwFAYD +VR0RBA0wC4IJbG9jYWxob3N0MA0GCSqGSIb3DQEBBQUAA4IBAQBXgtvlI3KJ083B +6RhCIKQwJ45pqoBkBYvUEhz/wybfCftqh0Y934hXb9zIv+bxwFTTg9tUT/NVis86 ++mYqr/HsyRkqHLqH8oH2JHhDvf23E3ZR6uZvGGZHzeFsKvALMVllj96cFyoq9GpE +4GwWggwuVyOBwk2o9llTYjgz95bLQFOQ0Zb7bLzI5VGRm8j1thUo6t9zrlq4bxHn +NJJmKEytW74MQsvYDsw6gm4nPnRdKhba4fjIjHa0LSSeBO/2dkJXXreVLxTWknhN +Idy8uB5hAHRaCPc12j+Hd6lvElYJ0C2FJDJ58DRXN7GiQFoDNvzVu3W5HvD2e5ri +0+jyjlwT +-----END CERTIFICATE----- diff --git a/autopush/tests/test_integration.py b/autopush/tests/test_integration.py index d1fc031f..f408ceb1 100644 --- a/autopush/tests/test_integration.py +++ b/autopush/tests/test_integration.py @@ -97,7 +97,7 @@ def _get_vapid(key=None, payload=None): class Client(object): """Test Client""" - def __init__(self, url, use_webpush=False): + def __init__(self, url, use_webpush=False, sslcontext=None): self.url = url self.uaid = None self.ws = None @@ -107,6 +107,7 @@ def __init__(self, url, use_webpush=False): self._crypto_key = """\ keyid="http://example.org/bob/keys/123;salt="XZwpw6o37R-6qoZjw6KwAw"\ """ + self.sslcontext = sslcontext def __getattribute__(self, name): # Python fun to turn all functions into deferToThread functions @@ -176,7 +177,7 @@ def delete_notification(self, channel, message=None, status=204): url = urlparse.urlparse(message) http = None if url.scheme == "https": # pragma: nocover - http = httplib.HTTPSConnection(url.netloc) + http = httplib.HTTPSConnection(url.netloc, context=self.sslcontext) else: http = httplib.HTTPConnection(url.netloc) @@ -195,7 +196,7 @@ def send_notification(self, channel=None, version=None, data=None, url = urlparse.urlparse(endpoint) http = None if url.scheme == "https": # pragma: nocover - http = httplib.HTTPSConnection(url.netloc) + http = httplib.HTTPSConnection(url.netloc, context=self.sslcontext) else: http = httplib.HTTPConnection(url.netloc) @@ -310,7 +311,8 @@ def setUp(self): import cyclone.web from autobahn.twisted.websocket import WebSocketServerFactory from autobahn.twisted.resource import WebSocketResource - from autopush.main import skip_request_logging + from autopush.log_check import LogCheckHandler + from autopush.main import mount_health_handlers, skip_request_logging from autopush.endpoint import ( EndpointHandler, MessageHandler, @@ -332,14 +334,21 @@ def setUp(self): storage_table = os.environ.get("STORAGE_TABLE", "storage_int_test") message_table = os.environ.get("MESSAGE_TABLE", "message_int_test") + client_certs = self.make_client_certs() + is_https = client_certs is not None + + endpoint_port = 9020 + router_port = 9030 settings = AutopushSettings( hostname="localhost", statsd_host=None, - endpoint_port="9020", - router_port="9030", + endpoint_port=str(endpoint_port), + router_port=str(router_port), router_tablename=router_table, storage_tablename=storage_table, message_tablename=message_table, + client_certs=client_certs, + endpoint_scheme='https' if is_https else 'http', ) # Websocket server @@ -367,7 +376,7 @@ def setUp(self): log_function=skip_request_logging, debug=False, ) - self.ws_website = reactor.listenTCP(9030, ws_site) + self.ws_website = reactor.listenTCP(router_port, ws_site) # Endpoint HTTP router site = cyclone.web.Application([ @@ -381,19 +390,33 @@ def setUp(self): # PUT /register/ => connect info # GET /register/uaid => chid + endpoint (r"/register(?:/(.+))?", RegistrationHandler, h_kwargs), + (r"/v1/err(?:/([^\/]+))?", LogCheckHandler, h_kwargs), ], default_host=settings.hostname, log_function=skip_request_logging, debug=False, + client_certs=settings.client_certs, ) - self.website = reactor.listenTCP(9020, site) + mount_health_handlers(site, settings) self._settings = settings + if is_https: + endpoint = reactor.listenSSL(endpoint_port, site, + self.endpoint_SSLCF()) + else: + endpoint = reactor.listenTCP(endpoint_port, site) + self.website = endpoint def _make_v0_endpoint(self, uaid, chid): return self._settings.endpoint_url + '/push/' + \ self._settings.fernet.encrypt( (uaid + ":" + chid).encode('utf-8')) + def make_client_certs(self): + return None + + def endpoint_SSLCF(self): + raise NotImplementedError # pragma: nocover + @inlineCallbacks def tearDown(self): dones = [self.websocket.stopListening(), self.website.stopListening(), @@ -405,8 +428,10 @@ def tearDown(self): yield self._settings.agent._pool.closeCachedConnections() @inlineCallbacks - def quick_register(self, use_webpush=False): - client = Client("ws://localhost:9010/", use_webpush=use_webpush) + def quick_register(self, use_webpush=False, sslcontext=None): + client = Client("ws://localhost:9010/", + use_webpush=use_webpush, + sslcontext=sslcontext) yield client.connect() yield client.hello() yield client.register() @@ -1318,19 +1343,158 @@ def test_with_key(self): yield self.shut_down(client) -class TestHealth(IntegrationBase): +class TestClientCerts(IntegrationBase): + + def setUp(self): + certs = os.path.join(os.path.dirname(__file__), "certs") + self.servercert = os.path.join(certs, "server.pem") + self.auth_client = os.path.join(certs, "client1.pem") + self.unauth_client = os.path.join(certs, "client2.pem") + IntegrationBase.setUp(self) + + def make_client_certs(self): + from autopush.tests.certs.makecerts import CLIENT1_SHA256 + return {CLIENT1_SHA256: 'partner1'} + + def endpoint_SSLCF(self): + """Return an SSLContextFactory for the endpoint. + + Configured with the self-signed test server.pem. server.pem is + additionally the signer of the client certs. + + """ + from autopush.ssl import AutopushSSLContextFactory + return AutopushSSLContextFactory( + self.servercert, + self.servercert, + require_peer_certs=self._settings.enable_tls_auth) + + def _create_unauth_SSLCF(self): + """Return an IPolicyForHTTPS for the unauthorized client""" + from twisted.internet.ssl import ( + Certificate, PrivateCertificate, optionsForClientTLS) + from twisted.web.iweb import IPolicyForHTTPS + from zope.interface import implementer + + with open(self.servercert) as fp: + servercert = Certificate.loadPEM(fp.read()) + with open(self.unauth_client) as fp: + unauth_client = PrivateCertificate.loadPEM(fp.read()) + + @implementer(IPolicyForHTTPS) + class UnauthClientPolicyForHTTPS(object): + def creatorForNetloc(self, hostname, port): + return optionsForClientTLS(hostname.decode('ascii'), + trustRoot=servercert, + clientCertificate=unauth_client) + return UnauthClientPolicyForHTTPS() + + def _create_context(self, certfile): + """Return a client SSLContext""" + import ssl + context = ssl.create_default_context() + context.load_cert_chain(certfile) + context.load_verify_locations(self.servercert) + return context + @inlineCallbacks - def test_status(self): - agent = Agent(reactor) - response = yield agent.request( - "GET", - b"http://localhost:9010/status" - ) + def test_client_cert_simple(self): + client = yield self.quick_register( + sslcontext=self._create_context(self.auth_client)) + yield client.disconnect() + self.assertTrue(client.channels) + chan = client.channels.keys()[0] + yield client.send_notification(status=202) + yield client.connect() + yield client.hello() + result = yield client.get_notification() + self.assertTrue(result != {}) + self.assertTrue(len(result["updates"]) == 1) + eq_(result["updates"][0]["channelID"], chan) + yield self.shut_down(client) + + @inlineCallbacks + def test_client_cert_webpush(self): + client = yield self.quick_register( + use_webpush=True, + sslcontext=self._create_context(self.auth_client)) + yield client.disconnect() + self.assertTrue(client.channels) + chan = client.channels.keys()[0] + + yield client.send_notification() + yield client.delete_notification(chan) + result = yield client.get_notification() + eq_(result, None) + + yield self.shut_down(client) - proto = AccumulatingProtocol() - proto.closedDeferred = Deferred() - response.deliverBody(proto) - yield proto.closedDeferred + @inlineCallbacks + def test_client_cert_unauth(self): + client = yield self.quick_register( + sslcontext=self._create_context(self.unauth_client)) + yield client.disconnect() + yield client.send_notification(status=401) - payload = json.loads(proto.data) - eq_(payload, {"status": "OK", "version": __version__}) + response, body = yield _agent( + 'DELETE', + "https://localhost:9020/m/foo", + contextFactory=self._create_unauth_SSLCF()) + eq_(response.code, 401) + wwwauth = response.headers.getRawHeaders('www-authenticate') + assert wwwauth == ['Transport mode="tls-client-certificate"'] + + @inlineCallbacks + def test_log_check_skips_auth(self): + response, body = yield _agent( + 'GET', + "https://localhost:9020/v1/err", + contextFactory=self._create_unauth_SSLCF()) + eq_(response.code, 418) + payload = json.loads(body) + eq_(payload['error'], "Test Error") + + @inlineCallbacks + def test_status_skips_auth(self): + response, body = yield _agent( + 'GET', + "https://localhost:9020/status", + contextFactory=self._create_unauth_SSLCF()) + eq_(response.code, 200) + payload = json.loads(body) + eq_(payload, dict(status="OK", version=__version__)) + + @inlineCallbacks + def test_health_skips_auth(self): + response, body = yield _agent( + 'GET', + "https://localhost:9020/health", + contextFactory=self._create_unauth_SSLCF()) + eq_(response.code, 200) + payload = json.loads(body) + eq_(payload['version'], __version__) + + +class TestHealth(IntegrationBase): + @inlineCallbacks + def test_status(self): + response, body = yield _agent('GET', "http://localhost:9010/status") + eq_(response.code, 200) + payload = json.loads(body) + eq_(payload, dict(status="OK", version=__version__)) + + +@inlineCallbacks +def _agent(method, url, contextFactory=None): + kwargs = {} + if contextFactory: + kwargs['contextFactory'] = contextFactory + agent = Agent(reactor, **kwargs) + response = yield agent.request(method, url) + + proto = AccumulatingProtocol() + proto.closedDeferred = Deferred() + response.deliverBody(proto) + yield proto.closedDeferred + + returnValue((response, proto.data)) diff --git a/autopush/tests/test_main.py b/autopush/tests/test_main.py index a88bdf30..bc8f0f92 100644 --- a/autopush/tests/test_main.py +++ b/autopush/tests/test_main.py @@ -245,6 +245,9 @@ class TestArg: fcm_collapsekey = "collapse" fcm_senderid = '12345' fcm_auth = 'abcde' + ssl_key = "keys/server.crt" + ssl_cert = "keys/server.key" + client_certs = '{"partner1":["ABCD"],"partner2":["efgh", "IJKL"]}' def setUp(self): patchers = [ @@ -284,6 +287,40 @@ def test_bad_apnsconf(self): "--apns_creds='[Invalid'" ], False) + def test_client_certs(self): + endpoint_main([ + "--ssl_cert=keys/server.crt", + "--ssl_key=keys/server.key", + """--client_certs={"foo": "bar"}""" + ], False) + + @patch('hyper.tls', spec=hyper.tls) + def test_client_certs_parse(self, mock): + ap = make_settings(self.TestArg) + eq_(ap.client_certs, dict(ABCD="partner1", + EFGH="partner2", + IJKL="partner2")) + + def test_bad_client_certs(self): + assert endpoint_main([ + "--ssl_cert=keys/server.crt", + "--ssl_key=keys/server.key", + "--client_certs='[Invalid'" + ], False) + assert endpoint_main([ + "--ssl_cert=keys/server.crt", + "--ssl_key=keys/server.key", + """--client_certs={"": ["bar"]}""" + ], False) + assert endpoint_main([ + "--ssl_cert=keys/server.crt", + "--ssl_key=keys/server.key", + """--client_certs={"foo": "bar"}""" + ], False) + assert endpoint_main([ + """--client_certs={"foo": ["bar"]}""" + ], False) + @patch('autopush.router.apns2.HTTP20Connection', spec=hyper.HTTP20Connection) @patch('hyper.tls', spec=hyper.tls) diff --git a/autopush/web/base.py b/autopush/web/base.py index a19118c4..5affbc51 100644 --- a/autopush/web/base.py +++ b/autopush/web/base.py @@ -49,6 +49,8 @@ def initialize(self, ap_settings): def prepare(self): """Common request preparation""" + if self.ap_settings.enable_tls_auth: + self.authenticate_peer_cert() if self.ap_settings.cors: self.set_header("Access-Control-Allow-Origin", "*") self.set_header("Access-Control-Allow-Methods", diff --git a/configs/autopush_endpoint.ini.sample b/configs/autopush_endpoint.ini.sample index a09bf57b..cdc799bc 100644 --- a/configs/autopush_endpoint.ini.sample +++ b/configs/autopush_endpoint.ini.sample @@ -30,3 +30,13 @@ port = 8082 ; autokey generator as the crypto_key argument, and sorted [newest, oldest] #auth_key = [HJVPy4ZwF4Yz_JdvXTL8hRcwIhv742vC60Tg5Ycrvw8=] +; Require specific client TLS certificates for endpoint node +; connections. Specified as a JSON object formatted as follows: +; +; {: } +; +; where each signature is encoded in hex with colons between each byte +; (every second digit) +; e.g.: +; {"client1": ["2C:78:31.."], "client2": ["3F:D0:E0..", "E2:19:B1.."]} +#client_certs =