From 58e0cbbd3ebd83cff149d59281a81123a01ac428 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 | 32 ++++ autopush/endpoint.py | 2 + autopush/log_check.py | 4 + autopush/main.py | 60 +++++-- autopush/settings.py | 3 + autopush/ssl.py | 23 +++ autopush/tests/certs/client1.pem | 52 ++++++ autopush/tests/certs/client1_sha256.txt | 1 + autopush/tests/certs/client2.pem | 53 ++++++ autopush/tests/certs/makecerts.py | 63 +++++++ autopush/tests/certs/server.pem | 52 ++++++ autopush/tests/test_integration.py | 211 +++++++++++++++++++++--- autopush/tests/test_main.py | 39 +++++ autopush/utils.py | 7 + autopush/web/base.py | 2 + configs/autopush_endpoint.ini.sample | 10 ++ 17 files changed, 580 insertions(+), 38 deletions(-) create mode 100644 autopush/tests/certs/client1.pem create mode 100644 autopush/tests/certs/client1_sha256.txt 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..ad494332 100644 --- a/autopush/base.py +++ b/autopush/base.py @@ -45,3 +45,35 @@ 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() + # VERIFY_FAIL_IF_NO_PEER_CERT ensures this never fails + # otherwise something is very broken + 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 65cddaf1..6144a0c7 100644 --- a/autopush/endpoint.py +++ b/autopush/endpoint.py @@ -136,6 +136,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..df3ea262 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,34 @@ 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 or not utils.CLIENT_SHA256_RE.match(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 +414,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 +504,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 +589,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/client1.pem b/autopush/tests/certs/client1.pem new file mode 100644 index 00000000..5415b48b --- /dev/null +++ b/autopush/tests/certs/client1.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD04ukuoV6TrmLb ++3j9pm9bXHto65RfJ+SIFNRiIcrYlVfhlutYMi4pWZxHR0lFmt8nVxnj0V/ftMTV +L9bmuoLrsasoIcHjCxWqn/wcb9E2tyVCiN0KubJu/1+pSEJ6VKBr7sVK36a/h/xo +E3HW06HplCBGepqwSn5V+PmXA5liSxrIPDT3WclcdJvH5ZbzUVcGRu2oHiGFOVNK +b3I+yovIXCXCn47mgAkitGPREfXWp714ySkOoQgkeTeWl7tV9bGDA7kpuJibfPb+ +TM8/fbGk++SzB86Gqrp2ue+pYEL/tNcg3BRrB0dQbwIaH9VjmSfmtWJOGBTEW1xx +x5JsgHidAgMBAAECggEBAIdrJ4mawOMnzxFZCboynGfIR5Jom77XH6BE7IFrsHF+ +fH+KZpB6B17kZ/Besl0kXHyzxORfdwYNP7+oWc1znExcDor9x+sWyR92owLSrr30 +H02gw6NXtx18aNkC1YgyXhfxjPZvoRVPTLv87LnghCvXttVinUIZn61JJjRlUB+y +1aSwJWtxh59vnu7kzkoQDp5mP18L02+8XuFv3L8V61v23n/tfY/zc5YZpMtJ3tOs +Uls2HhYurfuVE6QELkmiSPfJnm8QMr2J81ZSL0o/h4kxI7jDB3nrbiBYa3KD8nZ5 +5lmS4w7XLtZ7Rkv3RmLNO62dvhQXZxQnvCisLFU6sf0CgYEA/hm/ZNH8Pv18rAIr +zf3mmYwr8dUaAgzD8BgtKgN9V09cUTd7qSFmawyqk8nktB41ZD64uu+8gwT837O2 +JyRyP9/ozecWcghayHfrS+z4+i9bJIqxI6/QmiJ65EI7Ljti4JoEOm5olGIwA1lk +jZP5Tm2a7fiyGPLRZ4dqUXWesT8CgYEA9reH3gBCbdj1gQlHZjNFvCq8u5OlKRoE +P7rwSoN9OMEQFn/3H6I2NUTpiByaje9v/v8w4Rbeq4UkCXEczME3P870BiW+tgr3 +ZaRbKIVZ2Qr5O5AxT+oCgoSPF9761SIKWBiRcvERoS5/cKCGB+sQICXNw8DZj4wf +v/nwCjslgyMCgYBEEHuPMxxhdx81KCO5uwBRMxX2YoHj+K1nm+JFNcgWYiC1dKpA +RL0dgbgTfGoxwUHGB3MOR/d0FRrzhT0OwRmFeKHwvazqgMhomI7DuMd8pMDCShBn +Ico7726BxCf8G1ZCGZ92U2raDG8WBpUDw5ZtZriwdASo4CotlD1rcpk+mwKBgBQP +rmOV39Dw0F2ytHSR/LylOP2Dru1dqTTJbZqRgJAp2rYJp72RwhioxtiDgunBq3iv +pXjYFDkcNWbzJKVdnLF6kYsibJR+5ckFCUiNN1YXt1ZpjijyXUvhnYUSY5ELGI47 +STBwe7+AeWpeEyf3rDMA/+9H8iji+v3wQ92BG7bDAoGBAOxNWpwMc36PYJmHrgHf +FIv9GFCc4DjeZlDTF+bd5tJYjqrAK0qogHI79/8UwDv68GMOrBcpt7kxZFu/gUHp +GMi1jbx0PqHxbyXVS+GfF2q69/qPX48j8z4ASVmZ5XsnT/5xkE9x/yEsPdPZ4vkk +ATD9T6QLQvQ2ubEpm+ZJix2/ +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIEHDCCAwQCEA8o3mpIRk6WlCo4LCgxAOwwDQYJKoZIhvcNAQEFBQAwgb0xCzAJ +BgNVBAYTAlRSMQ8wDQYDVQQIDAbDh29ydW0xFDASBgNVBAcMC0JhxZ9tYWvDp8Sx +MRIwEAYDVQQDDAlsb2NhbGhvc3QxFTATBgNVBAoMDE1vemlsbGEgVGVzdDE2MDQG +A1UECwwtQXV0b3B1c2ggVGVzdCBhdXRvcHVzaC90ZXN0cy9jZXJ0cy9zZXJ2ZXIu +cGVtMSQwIgYJKoZIhvcNAQkBFhVvdHRvLnB1c2hAZXhhbXBsZS5jb20wIBcNMTYw +OTI3MTYzMzM1WhgPMjExNjA5MDMxNjMzMzVaMIG+MQswCQYDVQQGEwJUUjEPMA0G +A1UECAwGw4dvcnVtMRQwEgYDVQQHDAtCYcWfbWFrw6fEsTESMBAGA1UEAwwJbG9j +YWxob3N0MRUwEwYDVQQKDAxNb3ppbGxhIFRlc3QxNzA1BgNVBAsMLkF1dG9wdXNo +IFRlc3QgYXV0b3B1c2gvdGVzdHMvY2VydHMvY2xpZW50MS5wZW0xJDAiBgkqhkiG +9w0BCQEWFW90dG8ucHVzaEBleGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBAPTi6S6hXpOuYtv7eP2mb1tce2jrlF8n5IgU1GIhytiVV+GW +61gyLilZnEdHSUWa3ydXGePRX9+0xNUv1ua6guuxqyghweMLFaqf/Bxv0Ta3JUKI +3Qq5sm7/X6lIQnpUoGvuxUrfpr+H/GgTcdbToemUIEZ6mrBKflX4+ZcDmWJLGsg8 +NPdZyVx0m8fllvNRVwZG7ageIYU5U0pvcj7Ki8hcJcKfjuaACSK0Y9ER9danvXjJ +KQ6hCCR5N5aXu1X1sYMDuSm4mJt89v5Mzz99saT75LMHzoaquna576lgQv+01yDc +FGsHR1BvAhof1WOZJ+a1Yk4YFMRbXHHHkmyAeJ0CAwEAAaMYMBYwFAYDVR0RBA0w +C4IJbG9jYWxob3N0MA0GCSqGSIb3DQEBBQUAA4IBAQBbp23xGfgNBTcMKSA/Yku6 +YAddlJGKuszFwMztQr6/9Uz+Qf6+/rrej9OkpwPE7sE7lixgeyHLysPSOZvYW610 +rx3eW/E+u84+SM0FyMlM1eEL6Xr0UyZNz0odNzvygKV2jfZ35YhuME8nvJq23MFk +fI9ghZdLFk+mPgZQxmf2uavAToO6vq3afp80F4U5wTNrxQGKS9f4M202IFE6Lpkn +td5nHta5z5FuDcdPOGsZW4OhdDXbNzSxb+GAdgOlpwBliMo9Cw7lbDDi3QvjpqXM +w+ecbugf8ixAjj/30ROeHXW1Zamm1tRlX1O6wvAq2MpLEvZTVaGKdTaxQoxQPzr1 +-----END CERTIFICATE----- diff --git a/autopush/tests/certs/client1_sha256.txt b/autopush/tests/certs/client1_sha256.txt new file mode 100644 index 00000000..688b608a --- /dev/null +++ b/autopush/tests/certs/client1_sha256.txt @@ -0,0 +1 @@ +6C:DC:75:4D:0E:D1:25:B8:F4:46:E0:FC:66:2E:03:49:EB:37:33:82:19:94:2B:CE:CF:4F:E3:E8:AA:5E:81:1A \ No newline at end of file diff --git a/autopush/tests/certs/client2.pem b/autopush/tests/certs/client2.pem new file mode 100644 index 00000000..7f1ae686 --- /dev/null +++ b/autopush/tests/certs/client2.pem @@ -0,0 +1,53 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDQ25kt31KETmsU +ux1rc974Yo0OpWsj1Es6TMZ94TmNSk+eZvctB+wS4a2wh2QHuR6XYyuRgTvIapIf +zb/qyNzfcZ+TuJKnsXxu7gBXWqSMxpaZd1pc0cRllwh2GlwIC0ARl8d3O//iP+36 +uIqhM1iTrd9833WDSV6it+8xDgBGgb3B+ZeenLqXJUqqhBeWh2ERZHhe3APad5UA +1vF9nSQTiRtAK8dMS+HvBugC37Yhvcgp+sFu64iDTjYi+DkOjZNVeLw9WH2XxMqR +sx34VkAZw4F8xkay5sgjydzaoP+7HxNvclQDMaq/u7pUpP9lsZmPvbsfqKGv2D5l +bkCbzWWVAgMBAAECggEBAKyrwPph1XC4/GKJSAtcIo0rvP7M18UpcIBklP3hRJmB +RE3rRpMeJ5h8qAJ4DMUt0RLL1GtZcrmBEgnlKrPLGIBLCekxAV5OqFd1wSZ3M++H +B18dg8GVU0/CDCbIKComUvO4jhoPqr+8pt1P0JzxPFvrtgchH6BI+kqA0um1b5jZ +jwkVyUc/IdCR8jz0pYrdWn5MSq3ZK+Lr7XG2ODJimmEz0dNfnJa3i3gwee7OWPPn +IpdPPDdxxTQd13y+cD20aaUcan0HEhQ6kUsHXASyAuepunOFOq4UZDU9vHnaROyR +D4DDyzgJVLN5N4LXh+o9wRvqB9k/P+hDF/A0GU34fwkCgYEA+yQTk9f103ZKM/yz +sA3ichQ9wMTUFiVwI3RY74/HWnzA9qxaSj7NX3FhdK6qiXylROAwjEW9ILNGctGT +04Xv5cRQbKe8p4i6yXG61HdXm/D1YLsJKvQd7whNLP1AXF/SMofV6E5uQbnX9PQM +MORK7aT20/vnGyTRs3jKUAJWN3sCgYEA1OYW/ZAMz+cNtNojXEM974Kfo7gQBzOq +G5i9k12QVDwj1laYyAsfJxEeFXzvLp5i12DgSy9LlTpj9JhSS8EfJBr/vG2LXDfv +HO13y6dGZ1Vp9hhiRw5iGdH4HCZpAb69Jjf27ew/vE7quKX9degjl3g7inOeexoU +LXOxwe1qwi8CgYB8RvMFM1ZryVqY9VE6KvTG/Ss97GkDeI1Qji/AhMbjCV838jxQ +B1n8BBB0/EZZ+PuT5NlBYPVhbDXNddaQUvRPIGGoEy1xPmEodIY+w7vv6EKVFplH +zzvM4K/INp6V17kd1khNSBqZncy3Y9lwjFhj10Fpz3si3IqFJJ4BD9b4ZwKBgCEC +H5RmriXZy/07SPo4DrVAymGG2y1SrFAlCVd8zTDSNjg4Ku3xE35qIADy4t6Wffqo +sX3WsmBLsk2tBC1snthpOzdKwK2mmnMguk8f+0FwM8KNG0erCji4nkA3EFbN7OOt +D6Lp2yPmFGxWiAqs2D/Wy1x2+p5Zd8FoS6omlkPPAoGALMb+pgatVrGLIDyu9NU/ +BJm67Y5OJD3rY8i2ilNSIqtbccXrReI37ECqUqqF6xCbcKNia64xLStBO2GjnxKw +GGFFMhqCINSuEPPMd2WRvI7H1ikP50N9MRpIagGBc1ijL00aRL9iagXErhOyNE6L +QydIFD/VYHcQyBo+aPZpvMQ= +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIEHTCCAwUCEQCWoHEjhedCvp2PN2Sw1xZ1MA0GCSqGSIb3DQEBBQUAMIG9MQsw +CQYDVQQGEwJUUjEPMA0GA1UECAwGw4dvcnVtMRQwEgYDVQQHDAtCYcWfbWFrw6fE +sTESMBAGA1UEAwwJbG9jYWxob3N0MRUwEwYDVQQKDAxNb3ppbGxhIFRlc3QxNjA0 +BgNVBAsMLUF1dG9wdXNoIFRlc3QgYXV0b3B1c2gvdGVzdHMvY2VydHMvc2VydmVy +LnBlbTEkMCIGCSqGSIb3DQEJARYVb3R0by5wdXNoQGV4YW1wbGUuY29tMCAXDTE2 +MDkyNzE2MzMzNloYDzIxMTYwOTAzMTYzMzM2WjCBvjELMAkGA1UEBhMCVFIxDzAN +BgNVBAgMBsOHb3J1bTEUMBIGA1UEBwwLQmHFn21ha8OnxLExEjAQBgNVBAMMCWxv +Y2FsaG9zdDEVMBMGA1UECgwMTW96aWxsYSBUZXN0MTcwNQYDVQQLDC5BdXRvcHVz +aCBUZXN0IGF1dG9wdXNoL3Rlc3RzL2NlcnRzL2NsaWVudDIucGVtMSQwIgYJKoZI +hvcNAQkBFhVvdHRvLnB1c2hAZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUA +A4IBDwAwggEKAoIBAQDQ25kt31KETmsUux1rc974Yo0OpWsj1Es6TMZ94TmNSk+e +ZvctB+wS4a2wh2QHuR6XYyuRgTvIapIfzb/qyNzfcZ+TuJKnsXxu7gBXWqSMxpaZ +d1pc0cRllwh2GlwIC0ARl8d3O//iP+36uIqhM1iTrd9833WDSV6it+8xDgBGgb3B ++ZeenLqXJUqqhBeWh2ERZHhe3APad5UA1vF9nSQTiRtAK8dMS+HvBugC37Yhvcgp ++sFu64iDTjYi+DkOjZNVeLw9WH2XxMqRsx34VkAZw4F8xkay5sgjydzaoP+7HxNv +clQDMaq/u7pUpP9lsZmPvbsfqKGv2D5lbkCbzWWVAgMBAAGjGDAWMBQGA1UdEQQN +MAuCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQUFAAOCAQEAWHoQ2+ChM9QylCLoOrLA +r2r4NT/C1RErRt+h4hfIJXPc+zrBs232N7gWqUfpwnsqGu4f+98ro5o63l7m/Fi3 +dAUW8WjDzeMyQ8ygWHQFD9MZYyUHrvwPiw0DNyMiZ329SWsvAOB/wNCr2fRrd2KO +zdGnV0yHZD9GdffDwvxyKVAoKRFtiIZjBqgoWmSDPeN3evYDNcQvJXPadcId8426 +aOZJtEsGZG9CtocrwyV0VufWt41mvUcS6LzPJYeKWbOslguv7CQ10gIFSogS79s9 +nE+TleQFff3H0Qt2CBlAA9txLwma8ha9SS9xL/UGWZlbzwJlbdEbJATgaHvrD9Vn +Vw== +-----END CERTIFICATE----- diff --git a/autopush/tests/certs/makecerts.py b/autopush/tests/certs/makecerts.py new file mode 100644 index 00000000..86fe8efc --- /dev/null +++ b/autopush/tests/certs/makecerts.py @@ -0,0 +1,63 @@ +# encoding: utf-8 + +import os +import uuid + +from OpenSSL.crypto import ( + FILETYPE_PEM, PKey, TYPE_RSA, X509, X509Extension, + dump_certificate, dump_privatekey) + + +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 main(): + certsdir = os.path.dirname(__file__) + + # server.pem is self signed + server, serverkey = make_cert(os.path.join(certsdir, "server.pem")) + + client1, _ = make_cert(os.path.join(certsdir, "client1.pem"), + cacert=server, cakey=serverkey) + make_cert(os.path.join(certsdir, "client2.pem"), + cacert=server, cakey=serverkey) + + with open(os.path.join(certsdir, "client1_sha256.txt"), 'w') as fp: + fp.write(client1.digest('sha256')) + + +if __name__ == '__main__': + main() diff --git a/autopush/tests/certs/server.pem b/autopush/tests/certs/server.pem new file mode 100644 index 00000000..35a06ec2 --- /dev/null +++ b/autopush/tests/certs/server.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC6DtMDXH3s6L2d +zfsn+BaHCx8gsU0L0JUaHnbNzqkbGx0FNYPcZgbU8foPDjWxGbim26sV2R/XnGFq +vfB9IBnSWTFaELC9wBZlCZ2q22gDLLHLlTdbwTKJ9LJvXhNNyxcLGyEc+qlirQ4i +rvVRvzABC4auaHKxQayWlDDikYmaLvm8JlzXM3wcHmkOlbr2ucMyz+QH/apvDNi7 +P34K+7UkYKEWbga1RAu8dMJjr2pXVr+X+EyzVu0f52TDrLQHXAA3cUZQFzUNYlea +no2CeCQLPc8D5AXbeoz/mtJLXgUqWJMMdRmDe9BQP0apiRnVyAUN6/cCx8KK4s2O +o6Z6kZBdAgMBAAECggEALCnApJebvFQyTfbKmt4kWsGlDdmH9Dn6aky43nkjYq+4 +37eoKPR+wqT9Of2hePwl/FU/8tuq1z7jULbtEoZAGtHZCQvVJ/UkW69AoYGa2sYN +Hcm5bioZmO1gPVcTNe/y9EvoPDyzYBy7sjfdOx4qgtT9jwBz2OdB1CwwvlbVVqdQ +O1JMsJ19o24478YeV5/Xd1OMKwWw+ClTsKcnvqyf1q3o2ergzZp2S9i8vIcXA+tm +1v1cHWYEusdfkIi5Z3xWkVzDICIB8ittfZKfTblIKbr2ivBEteOG1fa5WCYE+gnC +0ksb7sHagsojSOSjP82lq2gaVaUl2s7uSlFsKi+eAQKBgQDcPG3OKPXBu7GuIK/R +rLuStP79f4/dNnR0a3yd3HalSUeln0o+W4l4dGmJWcgOolPRdTmayu36faJnQw3T +NY+cSvxSI5R49urU05Pm1nlH8yWX0feCWmkZqZcR9soL7f84BGFRqM461bbJd5fZ +K4yF7+ZBRNYD6cnPuNsz/GqGIQKBgQDYRY3mxkw9F+LRNjD8Fjea8mlMMWAcHeXO +dRqdu4O0PI7it6eF240XOddXfuFw/EMQ2lUOz0t8/Qr1V2iKDnZBVi9/pVmPDzQL +SR0Ald4+lkx6Pbn27b5/OeGIZXCsBSQnSnbx4wI3Vf0ZjKchIHFAsjwetO9AX9m2 +yZwa4kdKvQKBgQCllMVVv9PtoWlYKnkl4oFwLqab/uEyBzQNJ5cctNl7MZotepJ+ +SaIUryl9u8O+xOrRyxnROIstznFgw7hMOLPNZU9Jjjidrb8m3iAP6OZsYvG7sIAv +QDxOsAPF3M9RotFE0347v/e1omJ4HHNNMwHG3XQ3VEK31HkHtMsRzdBlIQKBgCBl +Ytkz0Q3Buzctj+7jARdTwpQoPUZY8CiaAA+qnBLuk1TAv/ZcKelVv70ag3iiQQWQ +PveF87/YQ8D2g/FSM0KcP2c2hJDabShXnmGNEYp8hx40itvDRyrVp8P/tf3+kyjT +jbe0EovpdI1UNWDP9EcFq93JqqEQ+pLX5mtcg+NdAoGBAIWzz8NoYahPhWKaQrdt +efabafh/bC2cxPVQerMU7pOmw/uFvQJJYiGo0b5UkwThsnAKM1EZzlJqHtcno/qS +jFLrBMYrJjzsTAEYD3j8l8DqLtAqoZdz5wQuCWooXamePlMF+wyYkJ9PtzBOyXhv +w8r0apcNJPOOujNgrGcPVhOi +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIEGzCCAwMCEAUeXyEbgkLCm91P9NnynYwwDQYJKoZIhvcNAQEFBQAwgb0xCzAJ +BgNVBAYTAlRSMQ8wDQYDVQQIDAbDh29ydW0xFDASBgNVBAcMC0JhxZ9tYWvDp8Sx +MRIwEAYDVQQDDAlsb2NhbGhvc3QxFTATBgNVBAoMDE1vemlsbGEgVGVzdDE2MDQG +A1UECwwtQXV0b3B1c2ggVGVzdCBhdXRvcHVzaC90ZXN0cy9jZXJ0cy9zZXJ2ZXIu +cGVtMSQwIgYJKoZIhvcNAQkBFhVvdHRvLnB1c2hAZXhhbXBsZS5jb20wIBcNMTYw +OTI3MTYzMzM1WhgPMjExNjA5MDMxNjMzMzVaMIG9MQswCQYDVQQGEwJUUjEPMA0G +A1UECAwGw4dvcnVtMRQwEgYDVQQHDAtCYcWfbWFrw6fEsTESMBAGA1UEAwwJbG9j +YWxob3N0MRUwEwYDVQQKDAxNb3ppbGxhIFRlc3QxNjA0BgNVBAsMLUF1dG9wdXNo +IFRlc3QgYXV0b3B1c2gvdGVzdHMvY2VydHMvc2VydmVyLnBlbTEkMCIGCSqGSIb3 +DQEJARYVb3R0by5wdXNoQGV4YW1wbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEAug7TA1x97Oi9nc37J/gWhwsfILFNC9CVGh52zc6pGxsdBTWD +3GYG1PH6Dw41sRm4pturFdkf15xhar3wfSAZ0lkxWhCwvcAWZQmdqttoAyyxy5U3 +W8EyifSyb14TTcsXCxshHPqpYq0OIq71Ub8wAQuGrmhysUGslpQw4pGJmi75vCZc +1zN8HB5pDpW69rnDMs/kB/2qbwzYuz9+Cvu1JGChFm4GtUQLvHTCY69qV1a/l/hM +s1btH+dkw6y0B1wAN3FGUBc1DWJXmp6NgngkCz3PA+QF23qM/5rSS14FKliTDHUZ +g3vQUD9GqYkZ1cgFDev3AsfCiuLNjqOmepGQXQIDAQABoxgwFjAUBgNVHREEDTAL +gglsb2NhbGhvc3QwDQYJKoZIhvcNAQEFBQADggEBAGwi2gAr6FOmcrZNJFv1NgPN +Ku/LBHzojWcCDln1rrM+2qhH4oOPHWG57lGyz1T/qD0NCCELuk7IpyKW6F7xfL7S +c+ssA2XvZIcSwa9994F0Pmmou/6pHSPvEqQWe/7a+ucNDYKJkQ+eNcORq95smVEm +Yd2tnY7uKuYy0v3CZCXZHgVGIERMzfmV4V1nm6vZpKy2lbhKromW/ENl8xmygoBQ ++9nqRxX20t8CEcMlmaVFd5L4a8LQzwIpihL9CMfdUYRZ7WBRs1MzGaNUZeRDfp+M +gPGNcwKdiWBYkVZrCIDBpQxXbo7MepSaZsBzTQcEDIcBP3illlJb+alETQX6lZY= +-----END CERTIFICATE----- diff --git a/autopush/tests/test_integration.py b/autopush/tests/test_integration.py index 59137bb8..15a4dca9 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) @@ -196,7 +197,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) @@ -313,7 +314,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, @@ -335,14 +337,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 @@ -370,7 +379,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([ @@ -384,19 +393,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(), @@ -408,8 +431,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() @@ -1351,19 +1376,159 @@ def test_with_key(self): yield self.shut_down(client) -class TestHealth(IntegrationBase): +class TestClientCerts(IntegrationBase): + + def setUp(self): + self.certs = 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): + with open(os.path.join(self.certs, "client1_sha256.txt")) as fp: + client1_sha256 = fp.read().strip() + 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() + ok_(client.channels) + chan = client.channels.keys()[0] + yield client.send_notification(status=202) + yield client.connect() + yield client.hello() + result = yield client.get_notification() + ok_(result != {}) + eq_(len(result["updates"]), 1) + eq_(result["updates"][0]["channelID"], chan) + yield self.shut_down(client) - proto = AccumulatingProtocol() - proto.closedDeferred = Deferred() - response.deliverBody(proto) - yield proto.closedDeferred + @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() + ok_(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) + + @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') + eq_(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..98d3314a 100644 --- a/autopush/tests/test_main.py +++ b/autopush/tests/test_main.py @@ -245,6 +245,12 @@ class TestArg: fcm_collapsekey = "collapse" fcm_senderid = '12345' fcm_auth = 'abcde' + ssl_key = "keys/server.crt" + ssl_cert = "keys/server.key" + _client_certs = dict(partner1=["1A:"*31 + "F9"], + partner2=["2B:"*31 + "E8", + "3C:"*31 + "D7"]) + client_certs = json.dumps(_client_certs) def setUp(self): patchers = [ @@ -284,6 +290,39 @@ def test_bad_apnsconf(self): "--apns_creds='[Invalid'" ], False) + def test_client_certs(self): + cert = self.TestArg._client_certs['partner1'][0] + returncode = endpoint_main([ + "--ssl_cert=keys/server.crt", + "--ssl_key=keys/server.key", + '--client_certs={"foo": ["%s"]}' % cert + ], False) + ok_(not returncode) + + @patch('hyper.tls', spec=hyper.tls) + def test_client_certs_parse(self, mock): + ap = make_settings(self.TestArg) + eq_(ap.client_certs["1A:"*31 + "F9"], 'partner1') + eq_(ap.client_certs["2B:"*31 + "E8"], 'partner2') + eq_(ap.client_certs["3C:"*31 + "D7"], 'partner2') + + def test_bad_client_certs(self): + cert = self.TestArg._client_certs['partner1'][0] + ssl_opts = ["--ssl_cert=keys/server.crt", "--ssl_key=keys/server.key"] + eq_(endpoint_main(ssl_opts + ["--client_certs='[Invalid'"], False), + 1) + eq_(endpoint_main( + ssl_opts + ['--client_certs={"": ["%s"]}' % cert], False), + 1) + eq_(endpoint_main( + ssl_opts + ['--client_certs={"quux": [""]}'], False), + 1) + eq_(endpoint_main( + ssl_opts + ['--client_certs={"foo": "%s"}' % cert], False), + 1) + eq_(endpoint_main(['--client_certs={"foo": ["%s"]}' % cert], False), + 1) + @patch('autopush.router.apns2.HTTP20Connection', spec=hyper.HTTP20Connection) @patch('hyper.tls', spec=hyper.tls) diff --git a/autopush/utils.py b/autopush/utils.py index a33ba67f..bce8b6bf 100644 --- a/autopush/utils.py +++ b/autopush/utils.py @@ -42,6 +42,13 @@ "https": 443, } +CLIENT_SHA256_RE = re.compile("""\ +^ +([0-9A-F]{2}:){31} + [0-9A-F]{2} +$ +""", re.VERBOSE) + def normalize_id(ident): if (len(ident) == 36 and diff --git a/autopush/web/base.py b/autopush/web/base.py index 1256c0d6..0eb7cf0f 100644 --- a/autopush/web/base.py +++ b/autopush/web/base.py @@ -52,6 +52,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 =