From 133b498182c6e2fe421af41fdc715db766689816 Mon Sep 17 00:00:00 2001 From: Patrick Dwyer Date: Thu, 4 Jan 2018 10:18:48 -0500 Subject: [PATCH] Add client certificate support to virtue-security --- control/virtue-security/Dockerfile | 15 +- control/virtue-security/gen_cert.sh | 4 - control/virtue-security/run.sh | 7 + control/virtue-security/tls_cert.conf | 12 - .../virtue-security/tools/get_certificates.py | 315 ++++++++++++++++++ .../tools/get_certificates_requirements.txt | 5 + control/virtue-security/virtue-security | 106 +++--- 7 files changed, 382 insertions(+), 82 deletions(-) delete mode 100755 control/virtue-security/gen_cert.sh create mode 100644 control/virtue-security/run.sh delete mode 100644 control/virtue-security/tls_cert.conf create mode 100755 control/virtue-security/tools/get_certificates.py create mode 100644 control/virtue-security/tools/get_certificates_requirements.txt diff --git a/control/virtue-security/Dockerfile b/control/virtue-security/Dockerfile index 7e09ea1..3a0f913 100644 --- a/control/virtue-security/Dockerfile +++ b/control/virtue-security/Dockerfile @@ -1,14 +1,19 @@ FROM python:3.6 ENV PYTHONUNBUFFERED=0 +# install virtue-security python dependencies WORKDIR /usr/src/app COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt -COPY gen_cert.sh ./ -COPY tls_cert.conf ./ -RUN ./gen_cert.sh - +# copy over our source and tools COPY . . -CMD ["python", "./virtue-security"] \ No newline at end of file +# make sure we can run the bootstrap script +RUN chmod ugo+x /usr/src/app/run.sh + +# install the gen certs modules +RUN pip install -r ./tools/get_certificates_requirements.txt +RUN mkdir /usr/src/app/certs + +CMD ["bash", "./run.sh"] \ No newline at end of file diff --git a/control/virtue-security/gen_cert.sh b/control/virtue-security/gen_cert.sh deleted file mode 100755 index f62ccca..0000000 --- a/control/virtue-security/gen_cert.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -mkdir -p cert -openssl genrsa -out cert/rsa_key 4096 -config tls_cert.conf -openssl rsa -in cert/rsa_key -outform PEM -pubout -out cert/rsa_key.pub \ No newline at end of file diff --git a/control/virtue-security/run.sh b/control/virtue-security/run.sh new file mode 100644 index 0000000..10598f2 --- /dev/null +++ b/control/virtue-security/run.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +echo "Getting Client Certificate" +python /usr/src/app/tools/get_certificates.py --cfssl-host cfssl --hostname api -d /usr/src/app/certs --quiet + +echo "Running virtue-security" +python /usr/src/app/virtue-security --public-key-path /usr/src/app/certs/cert.pem "$@" --ca-key-path /usr/src/app/certs/ca.pem --private-key-path /usr/src/app/certs/cert-key.pem \ No newline at end of file diff --git a/control/virtue-security/tls_cert.conf b/control/virtue-security/tls_cert.conf deleted file mode 100644 index 1762736..0000000 --- a/control/virtue-security/tls_cert.conf +++ /dev/null @@ -1,12 +0,0 @@ -[ req ] -default_bits = 4096 -distinguished_name = sensor_fqdn -prompt = no - -[ req_distinguished_name ] -C = US -ST = Virginia -L = Arlington -O = Two Six Labs -CN = mw-registry -emailAddress = patrick.dwyer@twosixlabs.com \ No newline at end of file diff --git a/control/virtue-security/tools/get_certificates.py b/control/virtue-security/tools/get_certificates.py new file mode 100755 index 0000000..3e6cb5b --- /dev/null +++ b/control/virtue-security/tools/get_certificates.py @@ -0,0 +1,315 @@ +#!/usr/local/python +""" +Directly retrieve certificates from the certificate authority +within Savior. This will only work for systems within the +container network boundary of the Sensing API and CFSSL. + +This tool can be used to just retrieve the CA root public +key, and/or to generate a new public/private key pair signed +by the Savior CA. +""" +import argparse +import base64 +import datetime +import hashlib +import hmac +import json +import os +import requests +import socket +import sys + + +def get_ca_certificate(opts): + """ + Retrieve the CA root public certificate for the Savior network. + + The basic request looks like: + + curl -d '{"label": "primary"}' http://localhost:3030/api/v1/cfssl/info + + :param opts: argparse options + :return: PEM encoded certificate as string + """ + log(opts, " %% Requesting CA public root certificate") + + res = requests.post("http://%s:%d/api/v1/cfssl/info" % (opts.cfssl_host, opts.cfssl_port), json={"label": "primary"}) + + if res.status_code == 200: + log(opts, " * Got response from CA") + payload = res.json() + + if payload["success"]: + log(opts, " + Retrieved CA root key") + ca_key = payload["result"]["certificate"] + log(opts, " = %d bytes in key" % (len(ca_key),)) + return ca_key + else: + print(" - Problem retrieving CA root key") + for err in payload["result"]["errors"]: + print(" ! %s" % (err,)) + else: + print(" ! Got an error from CA") + print(" = status_code(%d)" % (res.status_code,)) + + return None + + +def get_keys(opts): + """ + Retrieve a new public/private key pair from CFSSL. The basic requests + look like: + + # new private key + curl -d '{"key": {"algo": "rsa", "size": 4096}, "hosts":["sensor-001.savior"], "names":[{"C":"US", "ST":"Virginia", "L":"Arlington", "O":"sensor-001.savior"}], "CN": "sensor-001.savior"}' http://localhost:3030/api/v1/cfssl/newkey + + # CSR with HMAC + curl -H "Accept: application/json" -H "Content-Type: application/json" -d '{"request": "...", "token": "dBJlaIkSKqY+IbWFwzeM7pVDTBc6n7If6t9Scr1SBDo="}' http://localhost:3030/api/v1/cfssl/authsign + + To get a key we walk through a few steps: + + 1. Request a new private key + + 2. Build a Certificate Signing Request + + 3. Generate HMAC for the CSR + + 4. Issue the CSR call to CFSSL + + :param opts: argparse options + :return: (success, PEM public key, PEM private key) + """ + + # structure our data for the private key request + private_key_request = { + "key": { + "algo": opts.cert_algo, + "size": opts.cert_size + }, + "hosts": [opts.hostname], + "names": [ + { + "C": "US", + "ST": "Virginia", + "L": "Arlington", + "O": opts.hostname + } + ], + "CN": opts.hostname + } + + priv_key_res = requests.post("http://%s:%d/api/v1/cfssl/newkey" % (opts.cfssl_host, opts.cfssl_port), json=private_key_request) + + # HTTP errors? + if priv_key_res.status_code != 200: + print(" ! Error retrieving new private key, status_code(%d)" % (priv_key_res.status_code,)) + return False, None, None + + # let's pull apart the response JSON + priv_key_json = priv_key_res.json() + + # Content/request errors? + if not priv_key_json["success"]: + print(" ! Private key request returned errors") + for err in priv_key_json["result"]["errors"]: + print(" - %s" % (err,)) + + return False, None, None + + log(opts, " + Private key retrieved") + + # save the private key and CSR for later use + private_key = priv_key_json["result"]["private_key"] + csr = priv_key_json["result"]["certificate_request"] + + # we need a signing request and a stringified version of the signing request + signing_request = { + "hostname": opts.hostname, + "hosts": [opts.hostname], + "certificate_request": csr, + "profile": "default", + "label": "client auth", + "bundle": False + } + + # this is the fun part. I swear I had to brute force every. single. combination. of these fields, + # terms, base64ing's, and orderings, because the CFSSL documentation on this part is actively + # hostile. + signing_request_string = json.dumps(signing_request).encode("utf-8") + + # create an HMAC token for this request + token_hmac = hmac.new(bytearray.fromhex(opts.cfssl_secret), signing_request_string, hashlib.sha256) + token = base64.b64encode(token_hmac.digest()) + + # create the encoded signing request + srs_b64 = base64.b64encode(signing_request_string) + + # setup the final request payload + signing_payload = { + "request": srs_b64.decode(), + "token": token.decode() + } + # print("payload is: %s" % (json.dumps(signing_payload, indent=4))) + # let's post it and see what happens + signing_res = requests.post("http://%s:%d/api/v1/cfssl/authsign" % (opts.cfssl_host, opts.cfssl_port), json=signing_payload) + + if signing_res.status_code != 200: + print(" ! Error retrieving signed key, status_code(%d)" % (signing_res.status_code)) + return False, None, None + + signing_res_json = signing_res.json() + + if not signing_res_json["success"]: + print(" ! Signing request returned errors") + for err in signing_res_json["result"]["errors"]: + print(" - %s" % (err,)) + + return False, None, None + + # ok, we probably have a good response! + public_key = signing_res_json["result"]["certificate"] + log(opts, " + Signed Public key retrieved") + + return True, public_key, private_key + + +def have_shared_secret(opts): + """ + Check for the cfssl shared secret in our CLI options. If it hasn't been + specified, check the environment variable CFSSL_SHARED_SECRET. If that + doesn't exist, we've got a problem. + + :param opts: argparse options + :return: True if we have a shared secret, otherwise false + """ + + if opts.cfssl_secret is None: + + # is it in the ENV? + if "CFSSL_SHARED_SECRET" in os.environ: + opts.cfssl_secret = os.environ["CFSSL_SHARED_SECRET"] + return True + else: + return False + else: + return True + + +def have_hostname(opts): + """ + Check that we have a hostname for generating and using TLS certs. We'll + default to the hostname bound by a local socket if one isn't specified + on the CLI. + + :param opts: argparse options + :return: True if we have a hostname, otherwise False + """ + + if opts.hostname is None: + opts.hostname = socket.gethostname() + if opts.hostname is None: + return False + else: + return True + else: + return True + + +def options(): + """ + Parse out the command line options. + + :return: + """ + parser = argparse.ArgumentParser(description="Retrieve TLS certificates from the Savior CA") + + # top level control + + # communications + parser.add_argument("--cfssl-host", dest="cfssl_host", default="cfssl", help="CFSSL hostname") + parser.add_argument("--cfssl-port", dest="cfssl_port", default=3030, type=int, help="CFSSL port") + + # CA only mode + parser.add_argument("--ca-only", dest="ca_only", default=False, action="store_true", help="Only retrieve the CA public root") + + # host for certificate - we'll check this later and default to the socket hostname + parser.add_argument("--hostname", dest="hostname", default=None, help="Hostname to issue certificate for") + + # directory for certificates + parser.add_argument("-d", "--certificate-directory", dest="cert_dir", default="./certs", help="Directory in which to place certificates") + + # certificate control + # algo + # size + parser.add_argument("--certificate-algorithm", dest="cert_algo", default="rsa", help="Certificate algorithm") + parser.add_argument("--certificate-size", dest="cert_size", default=4096, type=int, help="Certificate bit size") + + # check for our shared key for signing requests. If we don't have this on the CLI, we'll check + # the ENV as well. Barring that, we'll bail. This is all handled in a separate method (see have_shared_secret + # method) that is only run if we're actually requesting new keys + parser.add_argument("--cfssl-shared-secret", dest="cfssl_secret", default=None, help="Shared secret used to generate HMAC verified requests to CFSSL. Will default to environment variable CFSSL_SHARED_SECRET if not specific") + + parser.add_argument("-q", "--quiet", dest="quiet", default=False, action="store_true", help="Run in quiet mode, which reduces the command logging output") + + return parser.parse_args() + + +def log(opts, msg): + if not opts.quiet: + print(msg) + + +if __name__ == "__main__": + + opts = options() + + # make sure our directory exists for key storage + cert_path = os.path.realpath(opts.cert_dir) + if not os.path.exists(cert_path): + log(opts, " @ Certificate directory doesn't yet exist - creating") + os.makedirs(cert_path) + + # track which keys we'll write out + keys_to_write = {} + + # time tracking + st = datetime.datetime.now() + + # retrieve the ca certificate + ca_key = get_ca_certificate(opts) + if ca_key is None: + sys.exit(1) + keys_to_write["ca.pem"] = ca_key + + # retrieve a new pub/priv key pair + if not opts.ca_only: + + # gotta have a shared secret + if not have_shared_secret(opts): + print(" ! No CFSSL shared secret available - cannot generate a CSR") + sys.exit(1) + + if not have_hostname(opts): + print(" ! No hostname found - cannot generate a CSR") + sys.exit(1) + + # let's get us some keys + success, pub_key, priv_key = get_keys(opts) + + if success: + keys_to_write["cert.pem"] = pub_key + keys_to_write["cert-key.pem"] = priv_key + else: + print(" ! There was an error generating a public/private key pair - aborting") + sys.exit(1) + + # write out our keys + for filename, key_data in keys_to_write.items(): + log(opts, " > writing [%s] to [%s]" % (filename, os.path.join(opts.cert_dir, filename))) + with open(os.path.join(cert_path, filename), "w") as keyfile: + keyfile.write(key_data) + log(opts, " % setting permissions to 0x600") + os.chmod(os.path.join(cert_path, filename), 0o600) + + ed = datetime.datetime.now() + log(opts, " ~ completed CA requests in %s" % (str(ed - st),)) \ No newline at end of file diff --git a/control/virtue-security/tools/get_certificates_requirements.txt b/control/virtue-security/tools/get_certificates_requirements.txt new file mode 100644 index 0000000..edb00e0 --- /dev/null +++ b/control/virtue-security/tools/get_certificates_requirements.txt @@ -0,0 +1,5 @@ +certifi==2017.11.5 +chardet==3.0.4 +idna==2.6 +requests==2.18.4 +urllib3==1.22 diff --git a/control/virtue-security/virtue-security b/control/virtue-security/virtue-security index 0551c90..5415932 100644 --- a/control/virtue-security/virtue-security +++ b/control/virtue-security/virtue-security @@ -9,6 +9,7 @@ import base64 from contextlib import closing from Crypto.PublicKey import RSA import datetime +import http import json import kafka import os @@ -2007,52 +2008,6 @@ def construct_api_uri(opts, uri_path, secure=True): return "%s/api/%s%s" % (host, opts.api_version, uri_path) -def get_root_ca_pubkey(opts): - """ - Get the public key of the CA root. During development, this is an unauthenticated - and insecure connection to the Sensing API. - - Returns look like: - - True, "PEM data" - False, "error message" - - The PEM will also be written to the "ca_key_path" directory as ca.pem. - - :param opts: argparse options - :return: success boolean, PEM encoded CA public key or error message - """ - print(" @ Retrieving CA Root public key") - - uri = construct_api_uri(opts, "/ca/root/public", secure=False) - - res = requests.get(uri) - - - if res.status_code == 200: - res_json = res.json() - - if not res_json["error"]: - - # make sure the directory exists for our key - if not os.path.exists(opts.ca_key_path): - print(" + creating certicate directory [%s]" % (opts.ca_key_path,)) - os.makedirs(opts.ca_key_path) - - print(" + got PEM encoded certificate") - - with open(os.path.join(opts.ca_key_path, "ca.pem"), "w") as ca_pem: - ca_pem.write(res_json["certificate"]) - print(" < PEM written to [%s]" % (os.path.join(opts.ca_key_path, "ca.pem"))) - return True, res_json["certificate"] - else: - print(" ! encountered an error retrieving the certificate: %s" % (res_json["message"],)) - return False, res_json["message"] - else: - print(" ! Encountered an HTTP error retrieving the certificate: status_code(%d)" % (res.status_code,)) - return False, "HTTP(%d)" % (res.status_code,) - - def test_generate_unauthenticated_tests(tests): """ Given the existing body of tests, generate a set @@ -4229,6 +4184,29 @@ def extract_jsonl_line(binary): return None, binary +def http_code_to_message(code): + """ + Convert an HTTP response status code into a useful message. + + This is a wrapper around the standard HTTP codes as well as the extended + 4xx CloudFlare TLS/SSL codes used by the Sensing API + + :param code: + :return: + """ + custom_codes = { + 495: "TLS/SSL Certificate Validation Error", + 496: "TLS/SSL Certificate Required" + } + + if code in http.client.responses: + return http.client.responses[code] + elif code in custom_codes: + return custom_codes[code] + else: + return "" + + async def api_inspect(opts): """ Query the inspection endpoint. @@ -4255,15 +4233,15 @@ async def api_inspect(opts): get_params = { "userToken": test_generate_user_token() } - ca_path = os.path.join(opts.ca_key_path, "ca.pem") - res = requests.get(full_uri, params=get_params, verify=ca_path) + # connect with our Savior CA certificate and our instance specific client certificate + client_cert_paths = (os.path.abspath(opts.public_key_path), os.path.abspath(opts.private_key_path)) + res = requests.get(full_uri, params=get_params, verify=opts.ca_key_path, cert=client_cert_paths) if res.status_code == 200: print(json.dumps(res.json(), indent=4)) - async def api_monitor(opts): """ Monitor the Sensing API C2 kafka channel. @@ -4273,12 +4251,10 @@ async def api_monitor(opts): """ full_uri = construct_api_uri(opts, "/control/c2/channel") - ca_path = os.path.join(opts.ca_key_path, "ca.pem") - get_params = { - "userToken": test_generate_user_token() - } - res = requests.get(full_uri, params=get_params, verify=ca_path) + # setup our certificate paths + client_cert_paths = (os.path.abspath(opts.public_key_path), os.path.abspath(opts.private_key_path)) + res = requests.get(full_uri, verify=opts.ca_key_path, cert=client_cert_paths) if res.status_code == 200: conf = res.json() @@ -4289,7 +4265,14 @@ async def api_monitor(opts): print(" bootstraps=[%s]" % (",".join(bootstraps)),) # see http://kafka-python.readthedocs.io/en/master/apidoc/KafkaProducer.html for more on using client certificates - for msg in kafka.KafkaConsumer(channel, bootstrap_servers=bootstraps, value_deserializer=json.loads, ssl_cafile=ca_path, security_protocol="SSL"): + for msg in kafka.KafkaConsumer( + channel, + bootstrap_servers=bootstraps, + value_deserializer=json.loads, + ssl_cafile=opts.ca_key_path, + ssl_certfile=opts.public_key_path, + ssl_keyfile=opts.private_key_path, + security_protocol="SSL"): # what do we do with this information if msg.value["action"] == "heartbeat": @@ -4313,6 +4296,9 @@ async def api_monitor(opts): msg.value["timestamp"], msg.value["action"] )) print(json.dumps(msg.value, indent=4)) + else: + print("%% error encountered trying to connect to Sensing API to retrieve monitor channel") + print(" ! status_code(%d): %s" % (res.status_code, http_code_to_message(res.status_code))) async def api_stream(opts): @@ -4327,7 +4313,6 @@ async def api_stream(opts): since = since_datetime(opts.time_since) follow = opts.log_follow log_level = opts.log_level - ca_path = os.path.join(opts.ca_key_path, "ca.pem") full_uri = construct_api_uri(opts, "/sensor/%s/stream" % (opts.var_sensor,)) @@ -4345,7 +4330,8 @@ async def api_stream(opts): # spin up our request and start streaming ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) - ssl_context.load_verify_locations(cafile=ca_path) + ssl_context.load_verify_locations(cafile=opts.ca_key_path) + ssl_context.load_cert_chain(os.path.abspath(opts.public_key_path), os.path.abspath(opts.private_key_path)) async with ClientSession() as session: async with session.get(full_uri, params=get_params, ssl_context=ssl_context) as response: @@ -4448,7 +4434,8 @@ def options(): # key management parser.add_argument("--public-key-path", dest="public_key_path", default=None, help="Path to the public key to use") - parser.add_argument("--ca-key-path", dest="ca_key_path", default="./cert", help="Where to store the Certificate Authority Root Public Key") + parser.add_argument("--private-key-path", dest="private_key_path", default=None, help="Path to the private key to use") + parser.add_argument("--ca-key-path", dest="ca_key_path", default=None, help="Where to store the Certificate Authority Root Public Key") # communications parser.add_argument("-a", "--api-host", dest="api_host", default="api", help="API host URI") @@ -4469,7 +4456,6 @@ def options(): if __name__ == "__main__": - # print("virtue-security(version=%s)" % (__VERSION__,)) opts = options() @@ -4487,8 +4473,6 @@ if __name__ == "__main__": sys.exit(1) # let's get the root CA key - get_root_ca_pubkey(opts) - loop = asyncio.get_event_loop() loop.run_until_complete(dispatch[opts.mode](opts))