-
Notifications
You must be signed in to change notification settings - Fork 9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Lyft host certificate lambda Fork #43
base: master
Are you sure you want to change the base?
Changes from 11 commits
de49946
d3630df
4891a4f
1d6579a
17540f5
0949028
4f03151
ff6a0cf
f962ec8
50c5027
f314d04
181a406
8a8c071
ecd6c7e
2950fef
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,303 @@ | ||
""" | ||
.. module: bless.aws_lambda.bless_lambda | ||
:copyright: (c) 2016 by Netflix Inc., see AUTHORS for more | ||
:license: Apache, see LICENSE for more details. | ||
""" | ||
import base64 | ||
import logging | ||
import time | ||
|
||
import boto3 | ||
import botocore | ||
import os | ||
import kmsauth | ||
|
||
from bless.config.bless_config import BLESS_OPTIONS_SECTION, \ | ||
ENTROPY_MINIMUM_BITS_OPTION, RANDOM_SEED_BYTES_OPTION, \ | ||
BLESS_CA_SECTION, CA_PRIVATE_KEY_FILE_OPTION, LOGGING_LEVEL_OPTION, \ | ||
KMSAUTH_KEY_ID_OPTION | ||
from bless.config.bless_lyft_config import CERTIFICATE_VALIDITY_WINDOW_SEC_OPTION, CERTIFICATE_TYPE_OPTION,\ | ||
KMSAUTH_CONTEXT_OPTION, CROSS_ACCOUNT_ROLE_ARN_OPTION | ||
from bless.config.bless_lyft_config import BlessLyftConfig | ||
from bless.request.bless_request_lyft_host import BlessLyftHostSchema | ||
from bless.request.bless_request_user import BlessUserSchema | ||
from bless.ssh.certificate_authorities.ssh_certificate_authority_factory import \ | ||
get_ssh_certificate_authority | ||
from bless.ssh.certificates.ssh_certificate_builder import SSHCertificateType | ||
from bless.ssh.certificates.ssh_certificate_builder_factory import get_ssh_certificate_builder | ||
|
||
logger = logging.getLogger() | ||
|
||
REGIONS = { | ||
'iad': 'us-east-1', | ||
'sfo': 'us-west-1' | ||
} | ||
|
||
|
||
def get_role_name_from_request(request): | ||
if request.onebox_name: | ||
return 'onebox-production-iad' | ||
else: | ||
return '{}-{}-{}'.format( | ||
request.service_name, | ||
request.service_instance, | ||
request.service_region) | ||
|
||
|
||
def get_role_name(instance_id, cross_account_role_arn, aws_region='us-east-1'): | ||
sts_client = boto3.client('sts') | ||
assumed_role_object = sts_client.assume_role( | ||
RoleArn=cross_account_role_arn, | ||
RoleSessionName='AssumedRoleSession' | ||
) | ||
credentials = assumed_role_object['Credentials'] | ||
ec2_resource = boto3.resource( | ||
'ec2', | ||
region_name=aws_region, | ||
api_version='2016-11-15', | ||
aws_access_key_id=credentials['AccessKeyId'], | ||
aws_secret_access_key=credentials['SecretAccessKey'], | ||
aws_session_token=credentials['SessionToken'] | ||
) | ||
|
||
instance = ec2_resource.Instance(instance_id) | ||
try: | ||
role = instance.iam_instance_profile['Arn'].split('/')[1] | ||
except botocore.exceptions.ClientError: | ||
logger.exception('Could not find instance {0}.'.format(instance_id)) | ||
role = None | ||
except IndexError: | ||
logger.error( | ||
'Could not find the role associated with {0}.'.format(instance_id) | ||
) | ||
role = None | ||
except Exception: | ||
logger.exception( | ||
'Failed to lookup role for instance id {0}.'.format(instance_id) | ||
) | ||
role = None | ||
return role | ||
|
||
|
||
def validate_instance_id(instance_id, request, cross_account_role_arn): | ||
aws_region = REGIONS.get(request.service_region, 'us-east-1') | ||
role = get_role_name(instance_id, cross_account_role_arn, aws_region) | ||
try: | ||
role_split = role.split('-') | ||
role_service_name = role_split[0] | ||
role_service_instance = role_split[1] | ||
role_service_region = role_split[2] | ||
except IndexError: | ||
logger.error( | ||
'Role is not a valid format {0}.'.format(role) | ||
) | ||
return False | ||
if (role_service_name in request.service_name | ||
and role_service_instance == request.service_instance | ||
and role_service_region == request.service_region): | ||
return True | ||
else: | ||
return False | ||
|
||
|
||
def get_hostnames(service_name, service_instance, service_region, instance_id, | ||
availability_zone, onebox_name, is_canary): | ||
cluster_name = '{0}-{1}-{2}'.format( | ||
service_name, service_instance, service_region) | ||
az_split = availability_zone.split('-') | ||
az_shortened = az_split[2][-1] # last letter of 3rd block of az | ||
|
||
hostname_prefixes = [] | ||
if instance_id: | ||
# strip 'i' in 'i-12345' | ||
instance_id_stripped = instance_id.split('-')[1] | ||
hostname_prefixes.append(instance_id) | ||
hostname_prefixes.append(instance_id_stripped) | ||
hostname_prefixes.append(cluster_name) | ||
hostname_prefixes.append(service_name) | ||
hostname_prefixes.append('{service_name}-{az_letter}'.format( | ||
service_name=service_name, | ||
az_letter=az_shortened)) | ||
hostname_prefixes.append('{service_name}-{service_region}'.format( | ||
service_name=service_name, | ||
service_region=service_region)) | ||
hostname_prefixes.append('{service_name}-{service_instance}'.format( | ||
service_name=service_name, | ||
service_instance=service_instance)) | ||
if is_canary: | ||
hostname_prefixes.append('{service_name}-canary'.format( | ||
service_name=service_name)) | ||
hostname_prefixes.append('{cluster_name}-canary'.format( | ||
cluster_name=cluster_name)) | ||
if onebox_name: | ||
hostname_prefixes.append('{onebox_name}.onebox'.format( | ||
onebox_name=onebox_name)) | ||
|
||
hostname_suffixes = ['.lyft.net', '.ln'] | ||
if service_name == 'gateway': | ||
hostname_suffixes.append('lyft.com') | ||
hostnames = [] | ||
for prefix in hostname_prefixes: | ||
for suffix in hostname_suffixes: | ||
hostnames.append('{prefix}{suffix}'.format( | ||
prefix=prefix, suffix=suffix)) | ||
|
||
return hostnames | ||
|
||
|
||
def get_certificate_type(certificate_type_option): | ||
if certificate_type_option == 'user': | ||
return 1 | ||
elif certificate_type_option == 'host': | ||
return 2 | ||
else: | ||
raise ValueError('Invalid certificate type option: {}'.format(certificate_type_option)) | ||
|
||
|
||
def lambda_lyft_host_handler( | ||
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. | ||
""" | ||
# AWS Region determines configs related to KMS | ||
region = os.environ['AWS_REGION'] | ||
|
||
# Load the deployment config values | ||
if config_file is None: | ||
config_file = os.path.join(os.getcwd(), 'bless_deploy.cfg') | ||
config = BlessLyftConfig(region, config_file=config_file) | ||
|
||
certificate_type = get_certificate_type(config.get(BLESS_OPTIONS_SECTION, CERTIFICATE_TYPE_OPTION)) | ||
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.setLevel(numeric_level) | ||
|
||
certificate_validity_window_seconds = config.getint(BLESS_OPTIONS_SECTION, | ||
CERTIFICATE_VALIDITY_WINDOW_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) | ||
cross_account_role_arn = config.get(BLESS_OPTIONS_SECTION, CROSS_ACCOUNT_ROLE_ARN_OPTION) | ||
ca_private_key_file = config.get(BLESS_CA_SECTION, CA_PRIVATE_KEY_FILE_OPTION) | ||
password_ciphertext_b64 = config.getpassword() | ||
kmsauth_key_id = config.get(BLESS_CA_SECTION, KMSAUTH_KEY_ID_OPTION) | ||
kmsauth_context = config.get(BLESS_CA_SECTION, KMSAUTH_CONTEXT_OPTION) | ||
|
||
# read the private key .pem | ||
with open(os.path.join(os.getcwd(), ca_private_key_file), 'r') as f: | ||
ca_private_key = bytes(f.read(), 'ascii') | ||
|
||
# decrypt ca private key password | ||
if ca_private_key_password is None: | ||
kms_client = boto3.client('kms', region_name=region) | ||
ca_password = kms_client.decrypt( | ||
CiphertextBlob=base64.b64decode(password_ciphertext_b64)) | ||
ca_private_key_password = ca_password['Plaintext'] | ||
|
||
# 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)) | ||
response = kms_client.generate_random( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I noticed if This seems to be the same as the old branch and seems to be operating okay. But maybe a quick additional line:
|
||
NumberOfBytes=random_seed_bytes) | ||
random_seed = response['Plaintext'] | ||
with open('/dev/urandom', 'w') as urandom: | ||
urandom.write(random_seed) | ||
|
||
# Process cert request | ||
if certificate_type == SSHCertificateType.HOST: | ||
schema = BlessLyftHostSchema(strict=True) | ||
else: | ||
schema = BlessUserSchema(strict=True) | ||
request = schema.load(event).data | ||
|
||
# cert values determined only by lambda and its configs | ||
current_time = int(time.time()) | ||
valid_before = current_time + certificate_validity_window_seconds | ||
valid_after = current_time - certificate_validity_window_seconds | ||
|
||
# Authenticate the host with KMS, if key is setup | ||
if kmsauth_key_id: | ||
if request.kmsauth_token: | ||
validator = kmsauth.KMSTokenValidator( | ||
kmsauth_key_id, | ||
kmsauth_key_id, | ||
kmsauth_context, | ||
region | ||
) | ||
# decrypt_token will raise a TokenValidationError if token doesn't match | ||
role_name = get_role_name_from_request(request) | ||
validator.decrypt_token('2/service/{}'.format(role_name), request.kmsauth_token) | ||
else: | ||
raise ValueError('Invalid request, missing kmsauth token') | ||
|
||
# Build the cert | ||
ca = get_ssh_certificate_authority(ca_private_key, ca_private_key_password) | ||
cert_builder = get_ssh_certificate_builder(ca, certificate_type, | ||
request.public_key_to_sign) | ||
if certificate_type == SSHCertificateType.USER: | ||
cert_builder.add_valid_principal(request.remote_username) | ||
# cert_builder is needed to obtain the SSH public key's fingerprint | ||
key_id = 'request[{}] for[{}] from[{}] command[{}] ssh_key:[{}] ca:[{}] valid_to[{}]'.format( | ||
context.aws_request_id, request.bastion_user, request.bastion_user_ip, request.command, | ||
cert_builder.ssh_public_key.fingerprint, context.invoked_function_arn, | ||
time.strftime("%Y/%m/%d %H:%M:%S", time.gmtime(valid_before))) | ||
cert_builder.set_critical_option_source_address(request.bastion_ip) | ||
elif certificate_type == SSHCertificateType.HOST: | ||
if not validate_instance_id(request.instance_id, request, cross_account_role_arn): | ||
request.instance_id = None | ||
remote_hostnames = get_hostnames(request.service_name, | ||
request.service_instance, | ||
request.service_region, | ||
request.instance_id, | ||
request.instance_availability_zone, | ||
request.onebox_name, | ||
request.is_canary) | ||
for remote_hostname in remote_hostnames: | ||
cert_builder.add_valid_principal(remote_hostname) | ||
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))) | ||
else: | ||
raise ValueError("Unknown certificate type") | ||
|
||
cert_builder.set_valid_before(valid_before) | ||
cert_builder.set_valid_after(valid_after) | ||
|
||
cert_builder.set_key_id(key_id) | ||
cert = cert_builder.get_cert_file() | ||
|
||
if certificate_type == SSHCertificateType.HOST: | ||
remote_name = ', '.join(remote_hostnames) | ||
bastion_ip = None | ||
else: | ||
remote_name = request.remote_username | ||
bastion_ip = request.bastion_ip | ||
|
||
logger.info( | ||
'Issued a cert to bastion_ip[{}] for the remote_username of [{}] with the key_id[{}] and ' | ||
'valid_from[{}])'.format( | ||
bastion_ip, remote_name, key_id, | ||
time.strftime("%Y/%m/%d %H:%M:%S", time.gmtime(valid_after)))) | ||
return cert |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
""" | ||
.. module: bless.config.bless_config | ||
:copyright: (c) 2016 by Netflix Inc., see AUTHORS for more | ||
:license: Apache, see LICENSE for more details. | ||
""" | ||
import configparser | ||
|
||
from bless.config.bless_config import BLESS_OPTIONS_SECTION | ||
|
||
CERTIFICATE_VALIDITY_WINDOW_SEC_OPTION = 'certificate_validity_seconds' | ||
CERTIFICATE_VALIDITY_SEC_DEFAULT = 60 * 2 | ||
|
||
ENTROPY_MINIMUM_BITS_OPTION = 'entropy_minimum_bits' | ||
ENTROPY_MINIMUM_BITS_DEFAULT = 2048 | ||
|
||
RANDOM_SEED_BYTES_OPTION = 'random_seed_bytes' | ||
RANDOM_SEED_BYTES_DEFAULT = 256 | ||
|
||
CROSS_ACCOUNT_ROLE_ARN_OPTION = 'cross_account_role_arn' | ||
CROSS_ACCOUNT_ROLE_ARN_DEFAULT = None | ||
|
||
LOGGING_LEVEL_OPTION = 'logging_level' | ||
LOGGING_LEVEL_DEFAULT = 'INFO' | ||
|
||
CERTIFICATE_TYPE_OPTION = 'certificate_type' | ||
CERTIFICATE_TYPE_DEFAULT = 'user' | ||
|
||
BLESS_CA_SECTION = 'Bless CA' | ||
CA_PRIVATE_KEY_FILE_OPTION = 'ca_private_key_file' | ||
KMS_KEY_ID_OPTION = 'kms_key_id' | ||
|
||
KMSAUTH_KEY_ID_OPTION = 'kmsauth_key_id' | ||
KMSAUTH_KEY_ID_DEFAULT = None | ||
|
||
KMSAUTH_CONTEXT_OPTION = 'kmsauth_context' | ||
KMSAUTH_CONTEXT_DEFAULT = None | ||
|
||
REGION_PASSWORD_OPTION_SUFFIX = '_password' | ||
|
||
|
||
class BlessLyftConfig(configparser.RawConfigParser, object): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unfortunatley, Netfllix have altered the way configs work significantly in the past 4 years. This is just mostly lifted from the old files and modernized, but we should eventually refactor to take advantage of the netflix commits |
||
def __init__(self, aws_region, config_file): | ||
""" | ||
Parses the BLESS config file, and provides some reasonable default values if they are | ||
absent from the config file. | ||
The [Bless Options] section is entirely optional, and has defaults. | ||
The [Bless CA] section is required. | ||
:param aws_region: The AWS Region BLESS is deployed to. | ||
:param config_file: Path to the connfig file. | ||
""" | ||
self.aws_region = aws_region | ||
defaults = {CERTIFICATE_VALIDITY_WINDOW_SEC_OPTION: CERTIFICATE_VALIDITY_SEC_DEFAULT, | ||
ENTROPY_MINIMUM_BITS_OPTION: ENTROPY_MINIMUM_BITS_DEFAULT, | ||
RANDOM_SEED_BYTES_OPTION: RANDOM_SEED_BYTES_DEFAULT, | ||
CROSS_ACCOUNT_ROLE_ARN_OPTION: CROSS_ACCOUNT_ROLE_ARN_DEFAULT, | ||
LOGGING_LEVEL_OPTION: LOGGING_LEVEL_DEFAULT, | ||
CERTIFICATE_TYPE_OPTION: CERTIFICATE_TYPE_DEFAULT, | ||
KMSAUTH_KEY_ID_OPTION: KMSAUTH_KEY_ID_DEFAULT, | ||
KMSAUTH_CONTEXT_OPTION: KMSAUTH_CONTEXT_DEFAULT} | ||
configparser.RawConfigParser.__init__(self, defaults=defaults) | ||
self.read(config_file) | ||
|
||
if not self.has_section(BLESS_OPTIONS_SECTION): | ||
self.add_section(BLESS_OPTIONS_SECTION) | ||
|
||
if not self.has_section(BLESS_CA_SECTION): | ||
raise ValueError("Can't read config file at: " + config_file) | ||
|
||
if not self.has_option(BLESS_CA_SECTION, self.aws_region + REGION_PASSWORD_OPTION_SUFFIX): | ||
raise ValueError("No Region Specific Password Provided.") | ||
|
||
def getpassword(self): | ||
""" | ||
Returns the correct encrypted password based off of the aws_region. | ||
:return: A Base64 encoded KMS CiphertextBlob. | ||
""" | ||
return self.get(BLESS_CA_SECTION, self.aws_region + REGION_PASSWORD_OPTION_SUFFIX) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is copied almost entirely from the old forked version that was being used for hosts (about 4 years old). Its been modernized for Python3 and a few tweaks caused by changes to interfaces in the bless package, but not refactored otherwise