def validate_remote_usernames_against_iam_groups(config: BlessConfig, request: BlessUserRequest): requested_remotes = request.remote_usernames.split(',') if config.getboolean(bless_config.BLESS_OPTIONS_SECTION, bless_config.REMOTE_USERNAMES_AGAINST_IAM_GROUPS_OPTION): iam = boto3.client('iam') user_groups = iam.list_groups_for_user(UserName=request.bastion_user) iam_group_with_full_access = config.get(bless_config.BLESS_OPTIONS_SECTION, bless_config.FULL_ACCESS_IAM_GROUP_NAME_OPTION) # if full access iam group is set then check if the user belongs to the group if iam_group_with_full_access != bless_config.FULL_ACCESS_IAM_GROUP_NAME_DEFAULT: for group in user_groups['Groups']: if group['GroupName'] == iam_group_with_full_access: return None group_name_template = config.get(bless_config.BLESS_OPTIONS_SECTION, bless_config.IAM_GROUP_NAME_VALIDATION_FORMAT_OPTION) for requested_remote in requested_remotes: required_group_name = group_name_template.format(requested_remote) user_is_in_group = any( group for group in user_groups['Groups'] if group['GroupName'] == required_group_name ) if not user_is_in_group: return error_response('ValidationError', 'user {} is not in the {} iam group'.format( request.bastion_user, required_group_name)) return None
def __init__(self, ca_private_key_password=None, config_file=None): """ :param ca_private_key_password: For local testing, if the password is provided, skip the KMS decrypt. :param config_file: The config file to load the SSH CA private key from, and additional settings. """ # AWS Region determines configs related to KMS if 'AWS_REGION' in os.environ: self.region = os.environ['AWS_REGION'] else: self.region = 'us-west-2' # Load the deployment config values self.config = BlessConfig(self.region, config_file=config_file) password_ciphertext_b64 = self.config.getpassword() # decrypt ca private key password if ca_private_key_password is None: kms_client = boto3.client('kms', region_name=self.region) try: ca_password = kms_client.decrypt( CiphertextBlob=base64.b64decode(password_ciphertext_b64)) self.ca_private_key_password = ca_password['Plaintext'] except ClientError as e: self.ca_private_key_password_error = str(e) else: self.ca_private_key_password = ca_private_key_password
def test_config_no_password(): with pytest.raises(ValueError) as e: BlessConfig('bogus-region', config_file=os.path.join(os.path.dirname(__file__), 'full.cfg')) assert 'No Region Specific And No Default Password Provided.' == e.value.message config = BlessConfig('bogus-region', config_file=os.path.join(os.path.dirname(__file__), 'full-with-default.cfg')) assert '<INSERT_DEFAULT_KMS_ENCRYPTED_BASE64_ENCODED_PEM_PASSWORD_HERE>' == config.getpassword()
def test_config_no_password(): with pytest.raises(ValueError) as e: BlessConfig( "bogus-region", config_file=os.path.join(os.path.dirname(__file__), "full.cfg"), ) assert "No Region Specific And No Default Password Provided." == str( e.value) config = BlessConfig( "bogus-region", config_file=os.path.join(os.path.dirname(__file__), "full-with-default.cfg"), ) assert ("<INSERT_DEFAULT_KMS_ENCRYPTED_BASE64_ENCODED_PEM_PASSWORD_HERE>" == config.getpassword())
def test_zlib_compression_env_with_uncompressed_key(monkeypatch): extra_environment_variables = { 'bless_ca_default_password': '******', 'bless_ca_ca_private_key_compression': 'zlib', 'bless_ca_ca_private_key': base64.b64encode(b'<INSERT_YOUR_ENCRYPTED_PEM_FILE_CONTENT>'), } for k, v in extra_environment_variables.items(): monkeypatch.setenv(k, v) # Create an empty config, everything is set in the environment config = BlessConfig('us-east-1', config_file='') with pytest.raises(zlib.error) as e: config.getprivatekey()
def test_none_compression_env_key(monkeypatch): extra_environment_variables = { 'bless_ca_default_password': '******', 'bless_ca_ca_private_key_compression': 'none', 'bless_ca_ca_private_key': str(base64.b64encode(b'<INSERT_YOUR_ENCRYPTED_PEM_FILE_CONTENT>'), encoding='ascii') } for k, v in extra_environment_variables.items(): monkeypatch.setenv(k, v) # Create an empty config, everything is set in the environment config = BlessConfig('us-east-1', config_file='') assert b'<INSERT_YOUR_ENCRYPTED_PEM_FILE_CONTENT>' == config.getprivatekey( )
def test_configs(config, region, expected_cert_valid, expected_entropy_min, expected_rand_seed, expected_log_level, expected_password, expected_username_validation, expected_key_compression): config = BlessConfig(region, config_file=config) assert expected_cert_valid == config.getint( BLESS_OPTIONS_SECTION, CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION) assert expected_cert_valid == config.getint( BLESS_OPTIONS_SECTION, CERTIFICATE_VALIDITY_AFTER_SEC_OPTION) assert expected_entropy_min == config.getint(BLESS_OPTIONS_SECTION, ENTROPY_MINIMUM_BITS_OPTION) assert expected_rand_seed == config.getint(BLESS_OPTIONS_SECTION, RANDOM_SEED_BYTES_OPTION) assert expected_log_level == config.get(BLESS_OPTIONS_SECTION, LOGGING_LEVEL_OPTION) assert expected_password == config.getpassword() assert expected_username_validation == config.get( BLESS_OPTIONS_SECTION, USERNAME_VALIDATION_OPTION) assert expected_key_compression == config.get( BLESS_CA_SECTION, CA_PRIVATE_KEY_COMPRESSION_OPTION)
def test_wrong_compression_env_key(monkeypatch): extra_environment_variables = { 'bless_ca_default_password': '******', 'bless_ca_ca_private_key_compression': 'lzh', 'bless_ca_ca_private_key': str(base64.b64encode(b'<INSERT_YOUR_ENCRYPTED_PEM_FILE_CONTENT>'), encoding='ascii') } for k, v in extra_environment_variables.items(): monkeypatch.setenv(k, v) # Create an empty config, everything is set in the environment config = BlessConfig('us-east-1', config_file='') with pytest.raises(ValueError) as e: config.getprivatekey() assert "Compression lzh is not supported." == str(e.value)
def test_none_compression_env_key(monkeypatch): extra_environment_variables = { "bless_ca_default_password": "******", "bless_ca_ca_private_key_compression": "none", "bless_ca_ca_private_key": str( base64.b64encode(b"<INSERT_YOUR_ENCRYPTED_PEM_FILE_CONTENT>"), encoding="ascii", ), } for k, v in extra_environment_variables.items(): monkeypatch.setenv(k, v) # Create an empty config, everything is set in the environment config = BlessConfig("us-east-1", config_file="") assert b"<INSERT_YOUR_ENCRYPTED_PEM_FILE_CONTENT>" == config.getprivatekey( )
def test_configs(config, region, expected_cert_valid, expected_entropy_min, expected_rand_seed, expected_log_level, expected_password): config = BlessConfig(region, config_file=config) assert expected_cert_valid == config.getint(BLESS_OPTIONS_SECTION, CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION) assert expected_cert_valid == config.getint(BLESS_OPTIONS_SECTION, CERTIFICATE_VALIDITY_AFTER_SEC_OPTION) assert expected_entropy_min == config.getint(BLESS_OPTIONS_SECTION, ENTROPY_MINIMUM_BITS_OPTION) assert expected_rand_seed == config.getint(BLESS_OPTIONS_SECTION, RANDOM_SEED_BYTES_OPTION) assert expected_log_level == config.get(BLESS_OPTIONS_SECTION, LOGGING_LEVEL_OPTION) assert expected_password == config.getpassword()
def test_configs(config, region, expected_cert_valid, expected_entropy_min, expected_rand_seed, expected_log_level, expected_password, expected_username_validation): config = BlessConfig(region, config_file=config) assert expected_cert_valid == config.getint(BLESS_OPTIONS_SECTION, CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION) assert expected_cert_valid == config.getint(BLESS_OPTIONS_SECTION, CERTIFICATE_VALIDITY_AFTER_SEC_OPTION) assert expected_entropy_min == config.getint(BLESS_OPTIONS_SECTION, ENTROPY_MINIMUM_BITS_OPTION) assert expected_rand_seed == config.getint(BLESS_OPTIONS_SECTION, RANDOM_SEED_BYTES_OPTION) assert expected_log_level == config.get(BLESS_OPTIONS_SECTION, LOGGING_LEVEL_OPTION) assert expected_password == config.getpassword() assert expected_username_validation == config.get(BLESS_OPTIONS_SECTION, USERNAME_VALIDATION_OPTION)
def test_kms_config_opts(monkeypatch): # Default option config = BlessConfig("us-east-1", config_file=os.path.join(os.path.dirname(__file__), 'full.cfg')) assert config.getboolean(KMSAUTH_SECTION, KMSAUTH_USEKMSAUTH_OPTION) is False # Config file value config = BlessConfig("us-east-1", config_file=os.path.join(os.path.dirname(__file__), 'full-with-kmsauth.cfg')) assert config.getboolean(KMSAUTH_SECTION, KMSAUTH_USEKMSAUTH_OPTION) is True assert config.getboolean( KMSAUTH_SECTION, VALIDATE_REMOTE_USERNAMES_AGAINST_IAM_GROUPS_OPTION) is False
def test_empty_config(): with pytest.raises(ValueError): BlessConfig('us-west-2', config_file='')
def test_empty_config(): with pytest.raises(ValueError): BlessConfig("us-west-2", config_file="")
def test_configs(config, region, expected_cert_valid, expected_entropy_min, expected_rand_seed, expected_host_cert_before_valid, expected_host_cert_after_valid, expected_log_level, expected_password, expected_username_validation, expected_hostname_validation, expected_iam_groups_validation, expected_iam_groups_validation_format, expected_key_compression): config = BlessConfig(region, config_file=config) assert expected_cert_valid == config.getint( BLESS_OPTIONS_SECTION, CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION) assert expected_cert_valid == config.getint( BLESS_OPTIONS_SECTION, CERTIFICATE_VALIDITY_AFTER_SEC_OPTION) assert expected_entropy_min == config.getint(BLESS_OPTIONS_SECTION, ENTROPY_MINIMUM_BITS_OPTION) assert expected_rand_seed == config.getint(BLESS_OPTIONS_SECTION, RANDOM_SEED_BYTES_OPTION) assert expected_host_cert_before_valid == config.getint( BLESS_OPTIONS_SECTION, SERVER_CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION) assert expected_host_cert_after_valid == config.getint( BLESS_OPTIONS_SECTION, SERVER_CERTIFICATE_VALIDITY_AFTER_SEC_OPTION) assert expected_log_level == config.get(BLESS_OPTIONS_SECTION, LOGGING_LEVEL_OPTION) assert expected_password == config.getpassword() assert expected_username_validation == config.get( BLESS_OPTIONS_SECTION, USERNAME_VALIDATION_OPTION) assert expected_hostname_validation == config.get( BLESS_OPTIONS_SECTION, HOSTNAME_VALIDATION_OPTION) assert expected_iam_groups_validation == config.get( BLESS_OPTIONS_SECTION, REMOTE_USERNAMES_AGAINST_IAM_GROUPS_OPTION) assert expected_iam_groups_validation_format == config.get( BLESS_OPTIONS_SECTION, IAM_GROUP_NAME_VALIDATION_FORMAT_OPTION) assert expected_key_compression == config.get( BLESS_CA_SECTION, CA_PRIVATE_KEY_COMPRESSION_OPTION)
def test_config_environment_override(monkeypatch): extra_environment_variables = { "bless_options_certificate_validity_after_seconds": "1", "bless_options_certificate_validity_before_seconds": "1", "bless_options_server_certificate_validity_after_seconds": "1", "bless_options_server_certificate_validity_before_seconds": "1", "bless_options_hostname_validation": "disabled", "bless_options_entropy_minimum_bits": "2", "bless_options_random_seed_bytes": "3", "bless_options_logging_level": "DEBUG", "bless_options_certificate_extensions": "permit-X11-forwarding", "bless_options_username_validation": "debian", "bless_options_remote_usernames_validation": "useradd", "bless_ca_us_east_1_password": "******", "bless_ca_default_password": "******", "bless_ca_ca_private_key_file": "<INSERT_YOUR_ENCRYPTED_PEM_FILE_NAME>", "bless_ca_ca_private_key": str( base64.b64encode(b"<INSERT_YOUR_ENCRYPTED_PEM_FILE_CONTENT>"), encoding="ascii", ), "kms_auth_use_kmsauth": "True", "kms_auth_kmsauth_key_id": "<INSERT_ARN>", "kms_auth_kmsauth_serviceid": "bless-test", } for k, v in extra_environment_variables.items(): monkeypatch.setenv(k, v) # Create an empty config, everything is set in the environment config = BlessConfig("us-east-1", config_file="") assert 1 == config.getint(BLESS_OPTIONS_SECTION, CERTIFICATE_VALIDITY_AFTER_SEC_OPTION) assert 1 == config.getint(BLESS_OPTIONS_SECTION, CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION) assert 1 == config.getint(BLESS_OPTIONS_SECTION, SERVER_CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION) assert 1 == config.getint(BLESS_OPTIONS_SECTION, SERVER_CERTIFICATE_VALIDITY_AFTER_SEC_OPTION) assert 2 == config.getint(BLESS_OPTIONS_SECTION, ENTROPY_MINIMUM_BITS_OPTION) assert 3 == config.getint(BLESS_OPTIONS_SECTION, RANDOM_SEED_BYTES_OPTION) assert "DEBUG" == config.get(BLESS_OPTIONS_SECTION, LOGGING_LEVEL_OPTION) assert "permit-X11-forwarding" == config.get( BLESS_OPTIONS_SECTION, CERTIFICATE_EXTENSIONS_OPTION) assert "debian" == config.get(BLESS_OPTIONS_SECTION, USERNAME_VALIDATION_OPTION) assert "disabled" == config.get(BLESS_OPTIONS_SECTION, HOSTNAME_VALIDATION_OPTION) assert "useradd" == config.get(BLESS_OPTIONS_SECTION, REMOTE_USERNAMES_VALIDATION_OPTION) assert ("<INSERT_US-EAST-1_KMS_ENCRYPTED_BASE64_ENCODED_PEM_PASSWORD_HERE>" == config.getpassword()) assert "<INSERT_YOUR_ENCRYPTED_PEM_FILE_NAME>" == config.get( BLESS_CA_SECTION, CA_PRIVATE_KEY_FILE_OPTION) assert b"<INSERT_YOUR_ENCRYPTED_PEM_FILE_CONTENT>" == config.getprivatekey( ) assert config.getboolean(KMSAUTH_SECTION, KMSAUTH_USEKMSAUTH_OPTION) assert "<INSERT_ARN>" == config.get(KMSAUTH_SECTION, KMSAUTH_KEY_ID_OPTION) assert "bless-test" == config.get(KMSAUTH_SECTION, KMSAUTH_SERVICE_ID_OPTION) config.aws_region = "invalid" assert ("<INSERT_DEFAULT_KMS_ENCRYPTED_BASE64_ENCODED_PEM_PASSWORD_HERE>" == config.getpassword())
def lambda_handler(event, context=None, ca_private_key_password=None, entropy_check=True, config_file=os.path.join(os.path.dirname(__file__), 'bless_deploy.cfg')): """ 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 config = BlessConfig(region, config_file=config_file) 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) 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) ca_private_key_file = config.get(BLESS_CA_SECTION, CA_PRIVATE_KEY_FILE_OPTION) password_ciphertext_b64 = config.getpassword() # read the private key .pem with open(os.path.join(os.path.dirname(__file__), ca_private_key_file), 'r') as f: ca_private_key = f.read() # 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( NumberOfBytes=random_seed_bytes) random_seed = response['Plaintext'] with open('/dev/urandom', 'w') as urandom: urandom.write(random_seed) # Process cert request schema = BlessSchema(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 # Build the cert ca = get_ssh_certificate_authority(ca_private_key, ca_private_key_password) cert_builder = get_ssh_certificate_builder(ca, SSHCertificateType.USER, request.public_key_to_sign) cert_builder.add_valid_principal(request.remote_username) 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[{}] 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) cert_builder.set_key_id(key_id) cert = cert_builder.get_cert_file() logger.info( 'Issued a cert to bastion_ip[{}] for the remote_username of [{}] with the key_id[{}] and ' 'valid_from[{}])'.format( request.bastion_ip, request.remote_username, key_id, time.strftime("%Y/%m/%d %H:%M:%S", time.gmtime(valid_after)))) return cert
def lambda_handler(event, context=None, ca_private_key_password=None, entropy_check=True, config_file=os.path.join(os.path.dirname(__file__), 'bless_deploy.cfg')): """ 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 config = BlessConfig(region, config_file=config_file) 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) certificate_validity_before_seconds = config.getint(BLESS_OPTIONS_SECTION, 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) ca_private_key = config.getprivatekey() password_ciphertext_b64 = config.getpassword() certificate_extensions = config.get(BLESS_OPTIONS_SECTION, CERTIFICATE_EXTENSIONS_OPTION) # Process cert request schema = BlessSchema(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) try: request = schema.load(event).data except ValidationError as e: return error_response('InputValidationError', str(e)) logger.info('Bless lambda invoked by [user: {0}, bastion_ips:{1}, public_key: {2}, kmsauth_token:{3}]'.format( request.bastion_user, request.bastion_user_ip, request.public_key_to_sign, request.kmsauth_token)) # decrypt ca private key password if ca_private_key_password is None: kms_client = boto3.client('kms', region_name=region) try: ca_password = kms_client.decrypt( CiphertextBlob=base64.b64decode(password_ciphertext_b64)) ca_private_key_password = ca_password['Plaintext'] except ClientError as e: return error_response('ClientError', str(e)) # 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( NumberOfBytes=random_seed_bytes) random_seed = response['Plaintext'] with open('/dev/urandom', 'w') as urandom: urandom.write(random_seed) # cert values determined only by lambda and its configs current_time = int(time.time()) test_user = config.get(BLESS_OPTIONS_SECTION, TEST_USER_OPTION) if test_user and (request.bastion_user == test_user or request.remote_usernames == test_user): # This is a test call, the lambda will issue an invalid # certificate where valid_before < valid_after valid_before = current_time valid_after = current_time + 1 bypass_time_validity_check = True else: valid_before = current_time + certificate_validity_after_seconds valid_after = current_time - certificate_validity_before_seconds bypass_time_validity_check = False # Authenticate the user with KMS, if key is setup if config.get(KMSAUTH_SECTION, KMSAUTH_USEKMSAUTH_OPTION): if request.kmsauth_token: # Allow bless to sign the cert for a different remote user than the name of the user who signed it allowed_remotes = config.get(KMSAUTH_SECTION, KMSAUTH_REMOTE_USERNAMES_ALLOWED_OPTION) if allowed_remotes: allowed_users = allowed_remotes.split(',') requested_remotes = request.remote_usernames.split(',') if allowed_users != ['*'] and not all([u in allowed_users for u in requested_remotes]): return error_response('KMSAuthValidationError', 'unallowed remote_usernames [{}]'.format(request.remote_usernames)) elif request.remote_usernames != request.bastion_user: return error_response('KMSAuthValidationError', 'remote_usernames must be the same as bastion_user') try: validator = KMSTokenValidator( None, config.getkmsauthkeyids(), config.get(KMSAUTH_SECTION, KMSAUTH_SERVICE_ID_OPTION), region ) # decrypt_token will raise a TokenValidationError if token doesn't match validator.decrypt_token( "2/user/{}".format(request.bastion_user), request.kmsauth_token ) except TokenValidationError as e: return error_response('KMSAuthValidationError', str(e)) else: return error_response('InputValidationError', '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, SSHCertificateType.USER, request.public_key_to_sign) for username in request.remote_usernames.split(','): cert_builder.add_valid_principal(username) cert_builder.set_valid_before(valid_before) cert_builder.set_valid_after(valid_after) if certificate_extensions: for e in certificate_extensions.split(','): if e: cert_builder.add_extension(e) else: cert_builder.clear_extensions() # 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_addresses(request.bastion_ips) cert_builder.set_key_id(key_id) cert = cert_builder.get_cert_file(bypass_time_validity_check) logger.info( 'Issued a cert to bastion_ips[{}] for remote_usernames[{}] with key_id[{}] and ' 'valid_from[{}])'.format( request.bastion_ips, request.remote_usernames, key_id, time.strftime("%Y/%m/%d %H:%M:%S", time.gmtime(valid_after)))) return success_response(cert)
def lambda_handler(event, context=None, ca_private_key_password=None, okta_api_token=None, entropy_check=True, config_file=os.path.join(os.path.dirname(__file__), 'bless_deploy.cfg')): """ 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 config = BlessConfig(region, config_file=config_file) logger = logging.getLogger(__name__) 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_before_seconds = config.getint(BLESS_OPTIONS_SECTION, CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION) certificate_validity_after_seconds = config.getint(BLESS_OPTIONS_SECTION, CERTIFICATE_VALIDITY_AFTER_SEC_OPTION) certificate_breakglass_validity_before_seconds = config.getint( BLESS_OPTIONS_SECTION, CERTIFICATE_BREAKGLASS_BEFORE_SEC_OPTION ) certificate_breakglass_validity_after_seconds = config.getint( BLESS_OPTIONS_SECTION, CERTIFICATE_BREAKGLASS_AFTER_SEC_OPTION ) breakglass_user = config.get(BLESS_OPTIONS_SECTION, BREAKGLASS_USER_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) ca_private_key_file = config.get(BLESS_CA_SECTION, CA_PRIVATE_KEY_FILE_OPTION) password_ciphertext_b64 = config.getpassword() certificate_extensions = config.get(BLESS_OPTIONS_SECTION, CERTIFICATE_EXTENSIONS_OPTION) encrypted_okta_api_token = config.get(OKTA_OPTIONS_SECTION, ENCRYPTED_OKTA_API_TOKEN_OPTION) okta_base_url = config.get(OKTA_OPTIONS_SECTION, OKTA_BASE_URL_OPTION) okta_allowed_groups = config.get(OKTA_OPTIONS_SECTION, OKTA_ALLOWED_GROUPS_OPTION) if okta_allowed_groups: okta_allowed_groups = set(okta_allowed_groups.split(',')) logger.debug("Allowed okta groups: {}".format(okta_allowed_groups)) # Process cert request schema = BlessSchema(strict=True) try: request = schema.load(event).data except ValidationError as e: return error_response('InputValidationError', str(e)) logger.info('Bless lambda invoked by okta user: {}@{}, bastion user: {}, kmsauth_token: {}]'.format( request.okta_user, request.bastion_user_ip, request.bastion_user, request.kmsauth_token) ) # read the private key .pem with open(os.path.join(os.path.dirname(__file__), ca_private_key_file), 'r') as f: ca_private_key = f.read() kms_client = boto3.client('kms', region_name=region) # decrypt ca private key password if ca_private_key_password is None: try: ca_password = kms_client.decrypt( CiphertextBlob=base64.b64decode(password_ciphertext_b64)) ca_private_key_password = ca_password['Plaintext'] except ClientError as e: return error_response('ClientError', str(e)) # decrypt okta api token if okta_api_token is None: try: decrypted_okta_api_token = kms_client.decrypt( CiphertextBlob=base64.b64decode(encrypted_okta_api_token)) okta_api_token = decrypted_okta_api_token['Plaintext'] except ClientError as e: return error_response('ClientError', str(e)) ''' Fetch user context from Okta * Username * Groups * SSH key ''' # add extra property to Okta user profile schema OktaUserProfile.types['public_key'] = str okta_users_client = okta.UsersClient(okta_base_url, okta_api_token) user = okta_users_client.get_user(request.okta_user) groups_info = okta_users_client.get_user_groups(request.okta_user) groups = set() for group_info in groups_info: # Encode unicode response from okta json api as a byte string to ease comparisons groups.add(group_info.profile.name.encode('utf-8')) logger.debug('User groups: {}'.format(groups)) allowed_groups = okta_allowed_groups logger.debug('Whitelist groups: {}'.format(allowed_groups)) logger.debug('Matched groups: {}'.format(groups.intersection(allowed_groups))) # If the len is greater than 0, breakglass will be false. breakglass = not len(groups.intersection(allowed_groups)) public_key_to_sign = user.profile.public_key if public_key_to_sign.startswith(VALID_SSH_RSA_PUBLIC_KEY_HEADER): pass else: raise ValidationError('Invalid SSH Public Key.') # 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( NumberOfBytes=random_seed_bytes) random_seed = response['Plaintext'] with open('/dev/urandom', 'w') as urandom: urandom.write(random_seed) # cert values determined only by lambda and its configs current_time = int(time.time()) test_user = config.get(BLESS_OPTIONS_SECTION, TEST_USER_OPTION) if test_user and (request.bastion_user == test_user or request.remote_usernames == test_user): # This is a test call, the lambda will issue an invalid # certificate where valid_before < valid_after valid_before = current_time valid_after = current_time + 1 bypass_time_validity_check = True else: # Normal user flow bypass_time_validity_check = False valid_before = current_time + certificate_validity_after_seconds valid_after = current_time - certificate_validity_before_seconds # Authenticate the user with KMS, if key is setup if config.get(KMSAUTH_SECTION, KMSAUTH_USEKMSAUTH_OPTION): if request.kmsauth_token: try: validator = KMSTokenValidator( None, config.getkmsauthkeyids(), config.get(KMSAUTH_SECTION, KMSAUTH_SERVICE_ID_OPTION), region ) # decrypt_token will raise a TokenValidationError if token doesn't match validator.decrypt_token( "2/user/{}".format(request.remote_usernames.split(',')[0]), request.kmsauth_token ) except TokenValidationError as e: return error_response('KMSAuthValidationError', str(e)) else: return error_response('InputValidationError', '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, SSHCertificateType.USER, public_key_to_sign) # Handle breakglass certificate modifications if breakglass: logger.critical('Breakglass access for okta user: {}@{}, bastion user: {}'.format( request.okta_user, request.bastion_user_ip, request.bastion_user) ) valid_before = current_time + certificate_breakglass_validity_before_seconds valid_after = current_time - certificate_breakglass_validity_after_seconds cert_builder.add_valid_principal(breakglass_user) for username in request.remote_usernames.split(','): cert_builder.add_valid_principal(username) cert_builder.set_valid_before(valid_before) cert_builder.set_valid_after(valid_after) if certificate_extensions: for e in certificate_extensions.split(','): if e: cert_builder.add_extension(e) else: cert_builder.clear_extensions() # cert_builder is needed to obtain the SSH public key's fingerprint key_id = 'AWS Request ID: {}, User: {}, Source IP: {}, Command: {}, ' \ 'Fingerprint: {}, Bless CA: {}, Expires: {}'\ .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_addresses(request.bastion_ips) cert_builder.set_key_id(key_id) cert = cert_builder.get_cert_file(bypass_time_validity_check) logger.info( 'Issued a cert to bastion_ips[{}] for the remote_usernames of [{}] with the key_id[{}] and ' 'valid_from[{}])'.format( request.bastion_ips, request.remote_usernames, key_id, time.strftime("%Y/%m/%d %H:%M:%S", time.gmtime(valid_after)))) return success_response(cert)
def test_config_environment_override(monkeypatch): extra_environment_variables = { 'bless_options_certificate_validity_after_seconds': '1', 'bless_options_certificate_validity_before_seconds': '1', 'bless_options_entropy_minimum_bits': '2', 'bless_options_random_seed_bytes': '3', 'bless_options_logging_level': 'DEBUG', 'bless_options_certificate_extensions': 'permit-X11-forwarding', 'bless_ca_us_east_1_password': '******', 'bless_ca_default_password': '******', 'bless_ca_ca_private_key_file': '<INSERT_YOUR_ENCRYPTED_PEM_FILE_NAME>', 'bless_ca_ca_private_key': base64.b64encode('<INSERT_YOUR_ENCRYPTED_PEM_FILE_CONTENT>'), 'kms_auth_use_kmsauth': 'True', 'kms_auth_kmsauth_key_id': '<INSERT_ARN>', 'kms_auth_kmsauth_serviceid': 'bless-test', } for k,v in extra_environment_variables.items(): monkeypatch.setenv(k, v) # Create an empty config, everything is set in the environment config = BlessConfig('us-east-1', config_file='') assert 1 == config.getint(BLESS_OPTIONS_SECTION, CERTIFICATE_VALIDITY_AFTER_SEC_OPTION) assert 1 == config.getint(BLESS_OPTIONS_SECTION, CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION) assert 2 == config.getint(BLESS_OPTIONS_SECTION, ENTROPY_MINIMUM_BITS_OPTION) assert 3 == config.getint(BLESS_OPTIONS_SECTION, RANDOM_SEED_BYTES_OPTION) assert 'DEBUG' == config.get(BLESS_OPTIONS_SECTION, LOGGING_LEVEL_OPTION) assert 'permit-X11-forwarding' == config.get(BLESS_OPTIONS_SECTION, CERTIFICATE_EXTENSIONS_OPTION) assert '<INSERT_US-EAST-1_KMS_ENCRYPTED_BASE64_ENCODED_PEM_PASSWORD_HERE>' == config.getpassword() assert '<INSERT_YOUR_ENCRYPTED_PEM_FILE_NAME>' == config.get(BLESS_CA_SECTION, CA_PRIVATE_KEY_FILE_OPTION) assert '<INSERT_YOUR_ENCRYPTED_PEM_FILE_CONTENT>' == config.getprivatekey() assert config.getboolean(KMSAUTH_SECTION, KMSAUTH_USEKMSAUTH_OPTION) assert '<INSERT_ARN>' == config.get(KMSAUTH_SECTION, KMSAUTH_KEY_ID_OPTION) assert 'bless-test' == config.get(KMSAUTH_SECTION, KMSAUTH_SERVICE_ID_OPTION) config.aws_region = 'invalid' assert '<INSERT_DEFAULT_KMS_ENCRYPTED_BASE64_ENCODED_PEM_PASSWORD_HERE>' == config.getpassword()
def test_config_no_password(): with pytest.raises(ValueError) as e: BlessConfig('bogus-region', config_file=os.path.join(os.path.dirname(__file__), 'full.cfg')) assert 'No Region Specific Password Provided.' == e.value.message
def lambda_handler(event, context=None, ca_private_key_password=None, entropy_check=True, config_file=os.path.join(os.path.dirname(__file__), 'bless_deploy.cfg')): """ 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 config = BlessConfig(region, config_file=config_file) 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) certificate_validity_before_seconds = config.getint( BLESS_OPTIONS_SECTION, 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) ca_private_key = config.getprivatekey() password_ciphertext_b64 = config.getpassword() certificate_extensions = config.get(BLESS_OPTIONS_SECTION, CERTIFICATE_EXTENSIONS_OPTION) # Process cert request schema = BlessSchema(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) try: request = schema.load(event).data except ValidationError as e: return error_response('InputValidationError', str(e)) logger.info( 'Bless lambda invoked by [user: {0}, bastion_ips:{1}, public_key: {2}, kmsauth_token:{3}]' .format(request.bastion_user, request.bastion_user_ip, request.public_key_to_sign, request.kmsauth_token)) # decrypt ca private key password if ca_private_key_password is None: kms_client = boto3.client('kms', region_name=region) try: ca_password = kms_client.decrypt( CiphertextBlob=base64.b64decode(password_ciphertext_b64)) ca_private_key_password = ca_password['Plaintext'] except ClientError as e: return error_response('ClientError', str(e)) # 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( NumberOfBytes=random_seed_bytes) random_seed = response['Plaintext'] with open('/dev/urandom', 'w') as urandom: urandom.write(random_seed) # cert values determined only by lambda and its configs current_time = int(time.time()) test_user = config.get(BLESS_OPTIONS_SECTION, TEST_USER_OPTION) if test_user and (request.bastion_user == test_user or request.remote_usernames == test_user): # This is a test call, the lambda will issue an invalid # certificate where valid_before < valid_after valid_before = current_time valid_after = current_time + 1 bypass_time_validity_check = True else: valid_before = current_time + certificate_validity_after_seconds valid_after = current_time - certificate_validity_before_seconds bypass_time_validity_check = False # Authenticate the user with KMS, if key is setup if config.get(KMSAUTH_SECTION, KMSAUTH_USEKMSAUTH_OPTION): if request.kmsauth_token: try: validator = KMSTokenValidator( None, config.getkmsauthkeyids(), config.get(KMSAUTH_SECTION, KMSAUTH_SERVICE_ID_OPTION), region) # decrypt_token will raise a TokenValidationError if token doesn't match validator.decrypt_token( "2/user/{}".format(request.remote_usernames), request.kmsauth_token) except TokenValidationError as e: return error_response('KMSAuthValidationError', str(e)) else: return error_response('InputValidationError', '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, SSHCertificateType.USER, request.public_key_to_sign) for username in request.remote_usernames.split(','): cert_builder.add_valid_principal(username) cert_builder.set_valid_before(valid_before) cert_builder.set_valid_after(valid_after) if certificate_extensions: for e in certificate_extensions.split(','): if e: cert_builder.add_extension(e) else: cert_builder.clear_extensions() # 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_addresses(request.bastion_ips) cert_builder.set_key_id(key_id) cert = cert_builder.get_cert_file(bypass_time_validity_check) logger.info( 'Issued a cert to bastion_ips[{}] for remote_usernames[{}] with key_id[{}] and ' 'valid_from[{}])'.format( request.bastion_ips, request.remote_usernames, key_id, time.strftime("%Y/%m/%d %H:%M:%S", time.gmtime(valid_after)))) return success_response(cert)
def lambda_handler(event, context=None, ca_private_key_password=None, entropy_check=True, config_file=os.path.join(os.path.dirname(__file__), 'bless_deploy.cfg')): """ 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 config = BlessConfig(region, config_file=config_file) 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) 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) ca_private_key_file = config.get(BLESS_CA_SECTION, CA_PRIVATE_KEY_FILE_OPTION) password_ciphertext_b64 = config.getpassword() # read the private key .pem with open(os.path.join(os.path.dirname(__file__), ca_private_key_file), 'r') as f: ca_private_key = f.read() # 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( NumberOfBytes=random_seed_bytes) random_seed = response['Plaintext'] with open('/dev/urandom', 'w') as urandom: urandom.write(random_seed) # Process cert request schema = BlessSchema(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 # Build the cert ca = get_ssh_certificate_authority(ca_private_key, ca_private_key_password) cert_builder = get_ssh_certificate_builder(ca, SSHCertificateType.USER, request.public_key_to_sign) cert_builder.add_valid_principal(request.remote_username) 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[{}] 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) cert_builder.set_key_id(key_id) cert = cert_builder.get_cert_file() logger.info( 'Issued a cert to bastion_ip[{}] for the remote_username of [{}] with the key_id[{}] and ' 'valid_from[{}])'.format( request.bastion_ip, request.remote_username, key_id, time.strftime("%Y/%m/%d %H:%M:%S", time.gmtime(valid_after)))) return cert