test(poc): Secure Control API using cross-pinning mTLS

Note that we are leveraging nix-shell to provide portable dependency
handling.

Co-authored-by: Roberto Abdelkader Martínez Pérez <robertomartinezp@gmail.com>
This commit is contained in:
pancho horrillo
2021-03-12 16:57:30 +01:00
parent 175f174b8c
commit b7b55d2f3b
2 changed files with 247 additions and 76 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ at any moment.
_,-._ _,-._
; ___ : ,------------------------------. ; ___ : ,------------------------------.
,--' (. .) '--.__ | | ,--' (. .) '--.__ | |
_; ||| \ | Arrr!! Be ye warned! | _; ||| \ | Arrr!! Ye be warned! |
'._,-----''';=.____," | | '._,-----''';=.____," | |
/// < o> |##| | | /// < o> |##| | |
(o \`--' //`-----------------------------' (o \`--' //`-----------------------------'
+246 -75
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env python #! /usr/bin/env nix-shell
#! nix-shell -i python3.7 -p python37 python37Packages.aiohttp python37Packages.requests python37Packages.click
#
# TODO: maybe add an option (cli) to supply the external address
# #
# Copyright 2019 Banco Bilbao Vizcaya Argentaria, S.A. # Copyright 2019 Banco Bilbao Vizcaya Argentaria, S.A.
@@ -20,16 +23,28 @@ from collections import namedtuple
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import uuid4 from uuid import uuid4
import asyncio import asyncio
import binascii
import contextlib
import datetime
import io import io
import ipaddress
import json import json
import logging import logging
import os import os
import shlex import shlex
import ssl import ssl
import sys import sys
import tempfile
import uuid
from aiohttp import web, StreamReader from aiohttp import web, StreamReader
from aiohttp.web_urldispatcher import UrlDispatcher from aiohttp.web_urldispatcher import UrlDispatcher
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import serialization
from cryptography import x509
from cryptography.x509.oid import NameOID
import click import click
import requests import requests
@@ -38,6 +53,79 @@ log = logging.getLogger('kapow')
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
KAPOW_CONTROL_URL="https://localhost:8081"
KAPOW_DATA_URL="http://localhost:8082"
########################################################################
# HTTPS Management #
########################################################################
def generate_ssl_cert(name, alt=None):
# Generate our key
key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
# Various details about who we are. For a self-signed certificate the
# subject and issuer are always the same.
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, name),
])
cert = x509.CertificateBuilder().subject_name(
subject
).issuer_name(
issuer
).public_key(
key.public_key()
).serial_number(
x509.random_serial_number()
).not_valid_before(
datetime.datetime.utcnow()
).not_valid_after(
datetime.datetime.utcnow() + datetime.timedelta(days=3650)
)
if alt is not None:
try:
ip = ipaddress.ip_address(alt)
except:
cert = cert.add_extension(
x509.SubjectAlternativeName([x509.DNSName(alt)]),
critical=True,
)
else:
cert = cert.add_extension(
x509.SubjectAlternativeName([x509.IPAddress(ip)]),
critical=True,
)
finally:
cert = cert.add_extension(
x509.ExtendedKeyUsage(
[x509.oid.ExtendedKeyUsageOID.SERVER_AUTH],
),
critical=True
)
else:
cert=cert.add_extension(
x509.ExtendedKeyUsage(
[x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH],
),
critical=True
)
cert = cert.sign(key, hashes.SHA256())
key_bytes = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
)
crt_bytes = cert.public_bytes(serialization.Encoding.PEM)
return (key_bytes, crt_bytes)
######################################################################## ########################################################################
# Resource Management # # Resource Management #
######################################################################## ########################################################################
@@ -255,8 +343,7 @@ def handle_route(entrypoint, command):
shell_task = await asyncio.create_subprocess_shell( shell_task = await asyncio.create_subprocess_shell(
args, args,
env={**os.environ, env={**os.environ,
"KAPOW_DATA_URL": "http://localhost:8081", "KAPOW_DATA_URL": KAPOW_DATA_URL,
"KAPOW_CONTROL_URL": "http://localhost:8081",
"KAPOW_HANDLER_ID": id "KAPOW_HANDLER_ID": id
}, },
stdin=asyncio.subprocess.DEVNULL) stdin=asyncio.subprocess.DEVNULL)
@@ -279,7 +366,7 @@ def handle_route(entrypoint, command):
def error_body(reason): def error_body(reason):
return {"reason": reason, "foo": "bar"} return {"reason": reason}
def get_routes(app): def get_routes(app):
async def _get_routes(request): async def _get_routes(request):
@@ -399,41 +486,31 @@ def delete_route(app):
# aiohttp webapp # # aiohttp webapp #
######################################################################## ########################################################################
async def report_result(proc):
await proc.communicate()
print(f"Process exited with code {proc.returncode}")
async def run_init_script(app, scripts, interactive): async def run_init_script(app, scripts, interactive):
""" """
Run the init script if given, then wait for the shell to finish. Run the init script if given, then wait for the shell to finish.
""" """
if not scripts: for script in scripts:
# No script given try:
if not interactive: result = await asyncio.create_subprocess_exec(
return script,
env={**os.environ,
"KAPOW_CONTROL_CLIENT_CERT": app["client_cert"],
"KAPOW_CONTROL_CLIENT_KEY": app["client_key"],
"KAPOW_CONTROL_SERVER_CERT": app["server_cert"],
"KAPOW_CONTROL_URL": KAPOW_CONTROL_URL,
})
except Exception as exc:
print(exc)
else: else:
cmd = "/bin/bash" asyncio.create_task(report_result(result))
else:
def build_filenames():
for filename in scripts:
yield shlex.quote(filename)
yield "<(echo)"
filenames = " ".join(build_filenames())
if interactive:
cmd = f"/bin/bash --init-file <(cat {filenames})"
else:
cmd = f"/bin/bash <(cat {filenames})"
shell_task = await asyncio.create_subprocess_shell(
cmd,
executable="/bin/bash",
env={**os.environ,
"KAPOW_DATA_URL": "http://localhost:8081",
"KAPOW_CONTROL_URL": "http://localhost:8081"
})
await shell_task.wait()
if interactive:
await app.cleanup()
os._exit(shell_task.returncode)
class InvalidRouteError(Exception): class InvalidRouteError(Exception):
@@ -481,7 +558,28 @@ async def start_background_tasks(app):
app["debug_tasks"] = loop.create_task(run_init_script(app, app["scripts"], app["interactive"])) app["debug_tasks"] = loop.create_task(run_init_script(app, app["scripts"], app["interactive"]))
async def start_kapow_server(bind, scripts, certfile=None, interactive=False, keyfile=None): def reduce_addr(addr):
"""Drop the port part from an `addr:port` string (IPv6 aware)"""
addr, *_ = addr.rsplit(':', 1)
if addr.startswith('[') and addr.endswith(']'):
return addr[1:-1]
else:
return addr
async def start_kapow_server(user_bind,
control_bind,
data_bind,
scripts,
certfile=None,
interactive=False,
keyfile=None,
control_reachable_addr="localhost:8081"):
global KAPOW_CONTROL_URL
KAPOW_CONTROL_URL=f"https://{control_reachable_addr}"
#
# USER
#
user_app = DynamicApplication(client_max_size=1024**3) user_app = DynamicApplication(client_max_size=1024**3)
user_app["user_routes"] = list() # [KapowRoute] user_app["user_routes"] = list() # [KapowRoute]
user_runner = web.AppRunner(user_app) user_runner = web.AppRunner(user_app)
@@ -492,32 +590,73 @@ async def start_kapow_server(bind, scripts, certfile=None, interactive=False, ke
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ssl_context.load_cert_chain(certfile, keyfile) ssl_context.load_cert_chain(certfile, keyfile)
ip, port = bind.split(':') user_ip, user_port = user_bind.rsplit(':', 2)
user_site = web.TCPSite(user_runner, ip, int(port), ssl_context=ssl_context) user_site = web.TCPSite(user_runner, user_ip, int(user_port),
ssl_context=ssl_context)
await user_site.start() await user_site.start()
#
# CONTROL
#
alternate_name = reduce_addr(control_reachable_addr)
srv_key_bytes, srv_crt_bytes = generate_ssl_cert("control", alternate_name)
cli_key_bytes, cli_crt_bytes = generate_ssl_cert("control")
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
with tempfile.NamedTemporaryFile(suffix=".pem", delete=True) as pem_file, \
tempfile.NamedTemporaryFile(suffix=".key", delete=True) as key_file, \
tempfile.NamedTemporaryFile(suffix=".pem", delete=True) as cli_crt_file:
pem_file.write(srv_crt_bytes)
pem_file.flush()
key_file.write(srv_key_bytes)
key_file.flush()
cli_crt_file.write(cli_crt_bytes)
cli_crt_file.flush()
context.verify_mode = ssl.CERT_REQUIRED
context.load_cert_chain(pem_file.name, key_file.name)
context.load_verify_locations(cafile=cli_crt_file.name)
control_app = web.Application(client_max_size=1024**3) control_app = web.Application(client_max_size=1024**3)
control_app.add_routes([ control_app.add_routes([
# Control API
web.get('/routes', get_routes(user_app)), web.get('/routes', get_routes(user_app)),
web.get('/routes/{id}', get_route(user_app)), web.get('/routes/{id}', get_route(user_app)),
web.post('/routes', append_route(user_app)), web.post('/routes', append_route(user_app)),
web.put('/routes', insert_route(user_app)), web.put('/routes', insert_route(user_app)),
web.delete('/routes/{id}', delete_route(user_app)), web.delete('/routes/{id}', delete_route(user_app)),
# Data API
web.get('/handlers/{id}/{field:.*}', get_field),
web.put('/handlers/{id}/{field:.*}', set_field),
]) ])
control_app["scripts"] = scripts control_app["scripts"] = scripts
control_app["client_cert"] = cli_crt_bytes
control_app["client_key"] = cli_key_bytes
control_app["server_cert"] = srv_crt_bytes
control_app["interactive"] = interactive control_app["interactive"] = interactive
control_app.on_startup.append(start_background_tasks) control_app.on_startup.append(start_background_tasks)
control_runner = web.AppRunner(control_app) control_runner = web.AppRunner(control_app)
await control_runner.setup() await control_runner.setup()
control_site = web.TCPSite(control_runner, '127.0.0.1', 8081)
control_ip, control_port = control_bind.rsplit(':', 2)
control_site = web.TCPSite(control_runner, control_ip,
int(control_port), ssl_context=context)
await control_site.start() await control_site.start()
#
# DATA
#
data_app = web.Application(client_max_size=1024**3)
data_app.add_routes([
# Data API
web.get('/handlers/{id}/{field:.*}', get_field),
web.put('/handlers/{id}/{field:.*}', set_field),
])
data_runner = web.AppRunner(data_app)
await data_runner.setup()
data_ip, data_port = data_bind.rsplit(':', 2)
data_site = web.TCPSite(data_runner, data_ip, int(data_port))
await data_site.start()
######################################################################## ########################################################################
@@ -536,13 +675,25 @@ def kapow(ctx):
@click.option("--certfile", default=None) @click.option("--certfile", default=None)
@click.option("--keyfile", default=None) @click.option("--keyfile", default=None)
@click.option("--bind", default="0.0.0.0:8080") @click.option("--bind", default="0.0.0.0:8080")
@click.option("--control-bind", default="0.0.0.0:8081")
@click.option("--data-bind", default="0.0.0.0:8082")
@click.option("--control-reachable-addr", default="localhost:8081")
@click.option("-i", "--interactive", is_flag=True) @click.option("-i", "--interactive", is_flag=True)
@click.argument("scripts", nargs=-1) @click.argument("scripts", nargs=-1)
def server(certfile, keyfile, bind, interactive, scripts): def server(certfile, keyfile, bind, interactive, scripts,
control_reachable_addr, control_bind, data_bind):
if bool(certfile) ^ bool(keyfile): if bool(certfile) ^ bool(keyfile):
print("For SSL both 'certfile' and 'keyfile' should be provided.") print("For SSL both 'certfile' and 'keyfile' should be provided.")
sys.exit(1) sys.exit(1)
loop.run_until_complete(start_kapow_server(bind, scripts, certfile, interactive, keyfile)) loop.run_until_complete(
start_kapow_server(bind,
control_bind,
data_bind,
scripts,
certfile,
interactive,
keyfile,
control_reachable_addr))
loop.run_forever() loop.run_forever()
@kapow.group(help="Manage current server HTTP routes") @kapow.group(help="Manage current server HTTP routes")
@@ -550,59 +701,79 @@ def route():
pass pass
@contextlib.contextmanager
def kapow_control_certs():
with tempfile.NamedTemporaryFile(suffix='.crt', encoding='utf-8', mode='w') as srv_cert, \
tempfile.NamedTemporaryFile(suffix='.crt', encoding='utf-8', mode='w') as cli_cert, \
tempfile.NamedTemporaryFile(suffix='.key', encoding='utf-8', mode='w') as cli_key:
srv_cert.write(os.environ["KAPOW_CONTROL_SERVER_CERT"])
srv_cert.file.flush()
cli_cert.write(os.environ["KAPOW_CONTROL_CLIENT_CERT"])
cli_cert.file.flush()
cli_key.write(os.environ["KAPOW_CONTROL_CLIENT_KEY"])
cli_key.file.flush()
session=requests.Session()
session.verify=srv_cert.name
session.cert=(cli_cert.name, cli_key.name)
yield session
@route.command("add") @route.command("add")
@click.option("-c", "--command", nargs=1) @click.option("-c", "--command", nargs=1)
@click.option("-e", "--entrypoint", default="/bin/sh -c") @click.option("-e", "--entrypoint", default="/bin/sh -c")
@click.option("-X", "--method", default="GET") @click.option("-X", "--method", default="GET")
@click.option("--url", envvar='KAPOW_CONTROL_URL') @click.option("--url", envvar='KAPOW_CONTROL_URL', default=KAPOW_CONTROL_URL)
@click.argument("url_pattern", nargs=1) @click.argument("url_pattern", nargs=1)
@click.argument("command_file", required=False) @click.argument("command_file", required=False)
def route_add(url_pattern, entrypoint, command, method, url, command_file): def route_add(url_pattern, entrypoint, command, method, url, command_file):
if command: with kapow_control_certs() as requests:
# Command is given inline if command:
source = command # Command is given inline
elif command_file is None: source = command
# No command elif command_file is None:
source = "" # No command
elif command_file == '-': source = ""
# Read commands from stdin elif command_file == '-':
source = sys.stdin.read() # Read commands from stdin
else: source = sys.stdin.read()
# Read commands from a file else:
with open(command_file, 'r', encoding='utf-8') as handler: # Read commands from a file
source = handler.read() with open(command_file, 'r', encoding='utf-8') as handler:
source = handler.read()
response = requests.post(f"{url}/routes", response = requests.post(f"{url}/routes",
json={"method": method, json={"method": method,
"url_pattern": url_pattern, "url_pattern": url_pattern,
"entrypoint": entrypoint, "entrypoint": entrypoint,
"command": source}) "command": source})
response.raise_for_status() response.raise_for_status()
print(json.dumps(response.json(), indent=2)) print(json.dumps(response.json(), indent=2))
@route.command("remove") @route.command("remove")
@click.option("--url", envvar='KAPOW_CONTROL_URL') @click.option("--url", envvar='KAPOW_CONTROL_URL', default=KAPOW_CONTROL_URL)
@click.argument("route-id") @click.argument("route-id")
def route_remove(route_id, url): def route_remove(route_id, url):
response = requests.delete(f"{url}/routes/{route_id}") with kapow_control_certs() as requests:
response.raise_for_status() response = requests.delete(f"{url}/routes/{route_id}")
response.raise_for_status()
@route.command("list") @route.command("list")
@click.option("--url", envvar='KAPOW_CONTROL_URL') @click.option("--url", envvar='KAPOW_CONTROL_URL', default=KAPOW_CONTROL_URL)
@click.argument("route-id", nargs=1, required=False, default=None) @click.argument("route-id", nargs=1, required=False, default=None)
def route_list(route_id, url): def route_list(route_id, url):
if route_id is None: with kapow_control_certs() as requests:
response = requests.get(f"{url}/routes") if route_id is None:
else: response = requests.get(f"{url}/routes")
response = requests.get(f"{url}/routes/{route_id}") else:
response.raise_for_status() response = requests.get(f"{url}/routes/{route_id}")
print(json.dumps(response.json(), indent=2)) response.raise_for_status()
print(json.dumps(response.json(), indent=2))
@kapow.command("set", help="Set data from the current context") @kapow.command("set", help="Set data from the current context")
@click.option("--url", envvar='KAPOW_DATA_URL') @click.option("--url", envvar='KAPOW_DATA_URL', default=KAPOW_DATA_URL)
@click.option("--handler-id", envvar='KAPOW_HANDLER_ID') @click.option("--handler-id", envvar='KAPOW_HANDLER_ID')
@click.argument("path", nargs=1) @click.argument("path", nargs=1)
@click.argument("value", required=False) @click.argument("value", required=False)
@@ -622,7 +793,7 @@ def kapow_set(url, handler_id, path, value):
@kapow.command("get", help="Get data from the current context") @kapow.command("get", help="Get data from the current context")
@click.option("--url", envvar='KAPOW_DATA_URL') @click.option("--url", envvar='KAPOW_DATA_URL', default=KAPOW_DATA_URL)
@click.option("--handler-id", envvar='KAPOW_HANDLER_ID') @click.option("--handler-id", envvar='KAPOW_HANDLER_ID')
@click.argument("path", nargs=1) @click.argument("path", nargs=1)
def kapow_get(url, handler_id, path): def kapow_get(url, handler_id, path):