Skip to content

Commit

Permalink
Add host cert issue hanlder
Browse files Browse the repository at this point in the history
  • Loading branch information
pecigonzalo committed Sep 20, 2018
1 parent 0b97ba2 commit 242a586
Show file tree
Hide file tree
Showing 5 changed files with 286 additions and 82 deletions.
184 changes: 147 additions & 37 deletions bless/aws_lambda/bless_lambda.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import boto3
import os
import time
from datetime import timedelta
from bless.cache.bless_lambda_cache import BlessLambdaCache

from bless.config.bless_config import BLESS_OPTIONS_SECTION, \
Expand All @@ -28,7 +29,7 @@
REMOTE_USERNAMES_VALIDATION_OPTION, \
IAM_GROUP_NAME_VALIDATION_FORMAT_OPTION, \
REMOTE_USERNAMES_BLACKLIST_OPTION
from bless.request.bless_request import BlessSchema
from bless.request.bless_request import BlessHostSchema, BlessUserSchema
from bless.ssh.certificate_authorities.ssh_certificate_authority_factory import \
get_ssh_certificate_authority
from bless.ssh.certificates.ssh_certificate_builder import SSHCertificateType
Expand All @@ -39,7 +40,17 @@
global_bless_cache = None


def lambda_handler(event, context=None, ca_private_key_password=None, entropy_check=True, config_file=None):
def lambda_handler(*args, **kwargs):
"""
Wrapper around lambda_handler_user for backwards compatibility
"""
return lambda_handler_user(*args, **kwargs)


def lambda_handler_user(
event, context=None, ca_private_key_password=None,
entropy_check=True,
config_file=None):
"""
This is the function that will be called when the lambda function starts.
:param event: Dictionary of the json request.
Expand All @@ -52,41 +63,25 @@ def lambda_handler(event, context=None, ca_private_key_password=None, entropy_ch
:param config_file: The config file to load the SSH CA private key from, and additional settings.
:return: the SSH Certificate that can be written to id_rsa-cert.pub or similar file.
"""
# For testing, ignore the static bless_cache, otherwise fill the cache one time.
global global_bless_cache
if ca_private_key_password is not None or config_file is not None:
bless_cache = BlessLambdaCache(ca_private_key_password, config_file)
elif global_bless_cache is None:
global_bless_cache = BlessLambdaCache(config_file=os.path.join(os.path.dirname(__file__), 'bless_deploy.cfg'))
bless_cache = global_bless_cache
else:
bless_cache = global_bless_cache
bless_cache = setup_lambda_cache(ca_private_key_password, config_file)

# AWS Region determines configs related to KMS
region = bless_cache.region

# Load the deployment config values
config = bless_cache.config

logging_level = config.get(BLESS_OPTIONS_SECTION, LOGGING_LEVEL_OPTION)
numeric_level = getattr(logging, logging_level.upper(), None)
if not isinstance(numeric_level, int):
raise ValueError('Invalid log level: {}'.format(logging_level))

logger = logging.getLogger()
logger.setLevel(numeric_level)
logger = set_logger(config)

certificate_validity_before_seconds = config.getint(BLESS_OPTIONS_SECTION,
CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION)
CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION)
certificate_validity_after_seconds = config.getint(BLESS_OPTIONS_SECTION,
CERTIFICATE_VALIDITY_AFTER_SEC_OPTION)
entropy_minimum_bits = config.getint(BLESS_OPTIONS_SECTION, ENTROPY_MINIMUM_BITS_OPTION)
random_seed_bytes = config.getint(BLESS_OPTIONS_SECTION, RANDOM_SEED_BYTES_OPTION)
CERTIFICATE_VALIDITY_AFTER_SEC_OPTION)
ca_private_key = config.getprivatekey()
certificate_extensions = config.get(BLESS_OPTIONS_SECTION, CERTIFICATE_EXTENSIONS_OPTION)

# Process cert request
schema = BlessSchema(strict=True)
schema = BlessUserSchema(strict=True)
schema.context[USERNAME_VALIDATION_OPTION] = config.get(BLESS_OPTIONS_SECTION, USERNAME_VALIDATION_OPTION)
schema.context[REMOTE_USERNAMES_VALIDATION_OPTION] = config.get(BLESS_OPTIONS_SECTION,
REMOTE_USERNAMES_VALIDATION_OPTION)
Expand All @@ -112,20 +107,7 @@ def lambda_handler(event, context=None, ca_private_key_password=None, entropy_ch

# if running as a Lambda, we can check the entropy pool and seed it with KMS if desired
if entropy_check:
with open('/proc/sys/kernel/random/entropy_avail', 'r') as f:
entropy = int(f.read())
logger.debug(entropy)
if entropy < entropy_minimum_bits:
logger.info(
'System entropy was {}, which is lower than the entropy_'
'minimum {}. Using KMS to seed /dev/urandom'.format(
entropy, entropy_minimum_bits))
kms_client = boto3.client('kms', region_name=bless_cache.region)
response = kms_client.generate_random(
NumberOfBytes=random_seed_bytes)
random_seed = response['Plaintext']
with open('/dev/urandom', 'w') as urandom:
urandom.write(random_seed)
check_entropy(config, logger)

# cert values determined only by lambda and its configs
current_time = int(time.time())
Expand Down Expand Up @@ -227,6 +209,86 @@ def lambda_handler(event, context=None, ca_private_key_password=None, entropy_ch
return success_response(cert)


def lambda_handler_host(
event, context=None, ca_private_key_password=None,
entropy_check=True,
config_file=None):
"""
This is the function that will be called when the lambda function starts.
:param event: Dictionary of the json request.
:param context: AWS LambdaContext Object
http://docs.aws.amazon.com/lambda/latest/dg/python-context-object.html
:param ca_private_key_password: For local testing, if the password is provided, skip the KMS
decrypt.
:param entropy_check: For local testing, if set to false, it will skip checking entropy and
won't try to fetch additional random from KMS.
:param config_file: The config file to load the SSH CA private key from, and additional settings.
:return: the SSH Certificate that can be written to id_rsa-cert.pub or similar file.
"""
bless_cache = setup_lambda_cache(ca_private_key_password, config_file)

# Load the deployment config values
config = bless_cache.config

logger = set_logger(config)

ca_private_key = config.getprivatekey()

# Process cert request
schema = BlessHostSchema(strict=True)

try:
request = schema.load(event).data
except ValidationError as e:
return error_response('InputValidationError', str(e))

# todo: kmsauth of hostnames? Other server to hostnames validation?
logger.info('Bless lambda invoked by [public_key: {}]'.format(request.public_key_to_sign))

# Make sure we have the ca private key password
if bless_cache.ca_private_key_password is None:
return error_response('ClientError', bless_cache.ca_private_key_password_error)
else:
ca_private_key_password = bless_cache.ca_private_key_password

# if running as a Lambda, we can check the entropy pool and seed it with KMS if desired
if entropy_check:
check_entropy(config, logger)

# cert values determined only by lambda and its configs
current_time = int(time.time())
# todo: config server cert validity range
valid_before = current_time + int(timedelta(days=365).total_seconds()) # Host certificate is valid for at least 1 year
valid_after = current_time

# Build the cert
ca = get_ssh_certificate_authority(ca_private_key, ca_private_key_password)
cert_builder = get_ssh_certificate_builder(ca, SSHCertificateType.HOST,
request.public_key_to_sign)

cert_builder.set_valid_before(valid_before)
cert_builder.set_valid_after(valid_after)

# cert_builder is needed to obtain the SSH public key's fingerprint
key_id = 'request[{}] ssh_key[{}] ca[{}] valid_to[{}]'.format(
context.aws_request_id, cert_builder.ssh_public_key.fingerprint, context.invoked_function_arn,
time.strftime("%Y/%m/%d %H:%M:%S", time.gmtime(valid_before))
)

for hostname in request.hostnames.split(','):
cert_builder.add_valid_principal(hostname)

cert_builder.set_key_id(key_id)
cert = cert_builder.get_cert_file()

logger.info(
'Issued a server cert to hostnames[{}] with key_id[{}] and '
'valid_from[{}])'.format(
request.hostnames, key_id,
time.strftime("%Y/%m/%d %H:%M:%S", time.gmtime(valid_after))))
return success_response(cert)


def success_response(cert):
return {
'certificate': cert
Expand All @@ -238,3 +300,51 @@ def error_response(error_type, error_message):
'errorType': error_type,
'errorMessage': error_message
}


def set_logger(config):
logging_level = config.get(BLESS_OPTIONS_SECTION, LOGGING_LEVEL_OPTION)
numeric_level = getattr(logging, logging_level.upper(), None)
if not isinstance(numeric_level, int):
raise ValueError('Invalid log level: {}'.format(logging_level))

logger = logging.getLogger()
logger.setLevel(numeric_level)
return logger


def check_entropy(config, logger):
"""
Check the entropy pool and seed it with KMS if desired
"""
region = os.environ['AWS_REGION']
kms_client = boto3.client('kms', region_name=region)
entropy_minimum_bits = config.getint(BLESS_OPTIONS_SECTION, ENTROPY_MINIMUM_BITS_OPTION)
random_seed_bytes = config.getint(BLESS_OPTIONS_SECTION, RANDOM_SEED_BYTES_OPTION)

with open('/proc/sys/kernel/random/entropy_avail', 'r') as f:
entropy = int(f.read())
logger.debug(entropy)
if entropy < entropy_minimum_bits:
logger.info(
'System entropy was {}, which is lower than the entropy_'
'minimum {}. Using KMS to seed /dev/urandom'.format(
entropy, entropy_minimum_bits))
response = kms_client.generate_random(
NumberOfBytes=random_seed_bytes)
random_seed = response['Plaintext']
with open('/dev/urandom', 'w') as urandom:
urandom.write(random_seed)


def setup_lambda_cache(ca_private_key_password, config_file):
# For testing, ignore the static bless_cache, otherwise fill the cache one time.
global global_bless_cache
if ca_private_key_password is not None or config_file is not None:
bless_cache = BlessLambdaCache(ca_private_key_password, config_file)
elif global_bless_cache is None:
global_bless_cache = BlessLambdaCache(config_file=os.path.join(os.path.dirname(__file__), 'bless_deploy.cfg'))
bless_cache = global_bless_cache
else:
bless_cache = global_bless_cache
return bless_cache
35 changes: 32 additions & 3 deletions bless/request/bless_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def validate_ssh_public_key(public_key):
raise ValidationError('Invalid SSH Public Key.')


class BlessSchema(Schema):
class BlessUserSchema(Schema):
bastion_ips = fields.Str(validate=validate_ips, required=True)
bastion_user = fields.Str(required=True)
bastion_user_ip = fields.Str(validate=validate_ips, required=True)
Expand All @@ -109,7 +109,7 @@ def check_unknown_fields(self, data, original_data):

@post_load
def make_bless_request(self, data):
return BlessRequest(**data)
return BlessUserRequest(**data)

@validates('bastion_user')
def validate_bastion_user(self, user):
Expand All @@ -133,7 +133,7 @@ def validate_remote_usernames(self, remote_usernames):
validate_user(remote_username, username_validation, username_blacklist)


class BlessRequest:
class BlessUserRequest:
def __init__(self, bastion_ips, bastion_user, bastion_user_ip, command, public_key_to_sign,
remote_usernames, kmsauth_token=None):
"""
Expand All @@ -159,3 +159,32 @@ def __init__(self, bastion_ips, bastion_user, bastion_user_ip, command, public_k

def __eq__(self, other):
return self.__dict__ == other.__dict__


class BlessHostSchema(Schema):
hostnames = fields.Str(required=True)
public_key_to_sign = fields.Str(validate=validate_ssh_public_key, required=True)

@validates_schema(pass_original=True)
def check_unknown_fields(self, data, original_data):
unknown = set(original_data) - set(self.fields)
if unknown:
raise ValidationError('Unknown field', unknown)

@post_load
def make_bless_request(self, data):
return BlessHostRequest(**data)


class BlessHostRequest:
def __init__(self, hostnames, public_key_to_sign):
"""
A BlessRequest must have the following key value pairs to be valid.
:param hostnames: The hostnames to make valid for this host certificate.
:param public_key_to_sign: The id_rsa.pub that will be used in the SSH request. This is enforced in the issued certificate.
"""
self.hostnames = hostnames
self.public_key_to_sign = public_key_to_sign

def __eq__(self, other):
return self.__dict__ == other.__dict__
56 changes: 56 additions & 0 deletions tests/aws_lambda/test_bless_lambda_host.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import os

import pytest

from bless.aws_lambda.bless_lambda import lambda_handler_host
from tests.ssh.vectors import EXAMPLE_RSA_PUBLIC_KEY, RSA_CA_PRIVATE_KEY_PASSWORD, \
EXAMPLE_ED25519_PUBLIC_KEY, EXAMPLE_ECDSA_PUBLIC_KEY


class Context(object):
aws_request_id = 'bogus aws_request_id'
invoked_function_arn = 'bogus invoked_function_arn'


VALID_TEST_REQUEST = {
"public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY,
"hostnames": "thisthat.com",
}

VALID_TEST_REQUEST_MULTIPLE_HOSTS = {
"public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY,
"hostnames": "thisthat.com, thatthis.com",
}

INVALID_TEST_REQUEST = {
"public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY,
"hostname": "wrongfieldname",
}

os.environ['AWS_REGION'] = 'us-west-2'


def test_basic_local_request():
output = lambda_handler_host(VALID_TEST_REQUEST, context=Context,
ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD,
entropy_check=False,
config_file=os.path.join(os.path.dirname(__file__), 'bless-test.cfg'))
print(output)
assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ')


def test_basic_local_request_with_multiple_hosts():
output = lambda_handler_host(VALID_TEST_REQUEST_MULTIPLE_HOSTS, context=Context,
ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD,
entropy_check=False,
config_file=os.path.join(os.path.dirname(__file__), 'bless-test.cfg'))
print(output)
assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ')


def test_invalid_request():
output = lambda_handler_host(INVALID_TEST_REQUEST, context=Context,
ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD,
entropy_check=False,
config_file=os.path.join(os.path.dirname(__file__), 'bless-test.cfg'))
assert output['errorType'] == 'InputValidationError'
Loading

0 comments on commit 242a586

Please sign in to comment.