├── bless ├── __init__.py ├── cache │ ├── __init__.py │ └── bless_lambda_cache.py ├── ssh │ ├── __init__.py │ ├── protocol │ │ ├── __init__.py │ │ └── ssh_protocol.py │ ├── public_keys │ │ ├── __init__.py │ │ ├── ssh_public_key.py │ │ ├── ssh_public_key_factory.py │ │ ├── ed25519_public_key.py │ │ └── rsa_public_key.py │ ├── certificates │ │ ├── __init__.py │ │ ├── ssh_certificate_builder_factory.py │ │ ├── rsa_certificate_builder.py │ │ ├── ed25519_certificate_builder.py │ │ └── ssh_certificate_builder.py │ └── certificate_authorities │ │ ├── __init__.py │ │ ├── ssh_certificate_authority_factory.py │ │ ├── ssh_certificate_authority.py │ │ └── rsa_certificate_authority.py ├── aws_lambda │ ├── __init__.py │ ├── bless_lambda.py │ ├── bless_lambda_common.py │ ├── bless_lambda_host.py │ └── bless_lambda_user.py ├── config │ ├── __init__.py │ ├── bless_deploy_example.cfg │ └── bless_config.py ├── request │ ├── __init__.py │ ├── bless_request_common.py │ ├── bless_request_host.py │ └── bless_request_user.py └── __about__.py ├── tests ├── __init__.py ├── ssh │ ├── __init__.py │ ├── test_ssh_public_key_factory.py │ ├── test_ssh_public_key_ed25519.py │ ├── test_ssh_certificate_authority_factory.py │ ├── test_ssh_certificate_builder_factory.py │ ├── test_ssh_public_key_rsa.py │ ├── test_ssh_protocol.py │ └── test_ssh_certificate_rsa.py ├── aws_lambda │ ├── __init__.py │ ├── only-use-for-unit-tests.zlib │ ├── only-use-for-unit-tests.pem.bz2 │ ├── bless-test-broken.cfg │ ├── bless-test.cfg │ ├── bless-test-with-test-user.cfg │ ├── bless-test-with-certificate-extensions-empty.cfg │ ├── bless-test-with-certificate-extensions.cfg │ ├── bless-test-kmsauth.cfg │ ├── bless-test-kmsauth-different-remote.cfg │ ├── bless-test-kmsauth-iam-group-validation.cfg │ ├── test_bless_lambda_host.py │ ├── only-use-for-unit-tests.pem │ └── test_bless_lambda_user.py ├── config │ ├── __init__.py │ ├── minimal.cfg │ ├── full-with-default.cfg │ ├── full-zlib.cfg │ ├── full.cfg │ ├── full-with-kmsauth.cfg │ └── test_bless_config.py └── request │ ├── __init__.py │ ├── test_bless_request_host.py │ └── test_bless_request_user.py ├── bless_client ├── __init__.py ├── bless_client_host.py └── bless_client.py ├── OSSMETADATA ├── AUTHORS ├── bless_logo.png ├── .coveragerc ├── .gitignore ├── lambda_compile.sh ├── setup.cfg ├── requirements.txt ├── NOTICE ├── .travis.yml ├── setup.py ├── Makefile ├── README.md └── LICENSE /bless/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bless/cache/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bless/ssh/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/ssh/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bless/aws_lambda/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bless/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bless/request/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bless_client/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/aws_lambda/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/request/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bless/ssh/protocol/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bless/ssh/public_keys/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /OSSMETADATA: -------------------------------------------------------------------------------- 1 | osslifecycle=active 2 | -------------------------------------------------------------------------------- /bless/ssh/certificates/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bless/ssh/certificate_authorities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | - Russell Lewis 2 | -------------------------------------------------------------------------------- /bless_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/bless/master/bless_logo.png -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | include = 3 | bless/*.py 4 | omit = 5 | bless/__about__.py 6 | -------------------------------------------------------------------------------- /tests/aws_lambda/only-use-for-unit-tests.zlib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/bless/master/tests/aws_lambda/only-use-for-unit-tests.zlib -------------------------------------------------------------------------------- /tests/aws_lambda/only-use-for-unit-tests.pem.bz2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zendesk/bless/master/tests/aws_lambda/only-use-for-unit-tests.pem.bz2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | *.pyc 3 | *.cache/ 4 | .idea/ 5 | BLESS.egg-info/ 6 | htmlcov/ 7 | libs/ 8 | publish/ 9 | venv/ 10 | aws_lambda_libs/ 11 | lambda_configs/ 12 | .pytest_cache/ 13 | -------------------------------------------------------------------------------- /tests/aws_lambda/bless-test-broken.cfg: -------------------------------------------------------------------------------- 1 | [Bless CA] 2 | ca_private_key_file = tests/aws_lambda/not-found.pem 3 | us-east-1_password = bogus-password-for-unit-test 4 | us-west-2_password = bogus-password-for-unit-test 5 | -------------------------------------------------------------------------------- /tests/aws_lambda/bless-test.cfg: -------------------------------------------------------------------------------- 1 | [Bless CA] 2 | ca_private_key_file = tests/aws_lambda/only-use-for-unit-tests.pem 3 | us-east-1_password = bogus-password-for-unit-test 4 | us-west-2_password = bogus-password-for-unit-tests 5 | -------------------------------------------------------------------------------- /tests/config/minimal.cfg: -------------------------------------------------------------------------------- 1 | [Bless CA] 2 | us-west-2_password = 3 | us-west-2_kms_context = {'insert': 'your context for us-west-2'} 4 | ca_private_key_file = -------------------------------------------------------------------------------- /tests/aws_lambda/bless-test-with-test-user.cfg: -------------------------------------------------------------------------------- 1 | [Bless CA] 2 | ca_private_key_file = tests/aws_lambda/only-use-for-unit-tests.pem 3 | us-east-1_password = bogus-password-for-unit-test 4 | us-west-2_password = bogus-password-for-unit-tests 5 | 6 | [Bless Options] 7 | test_user = user -------------------------------------------------------------------------------- /tests/aws_lambda/bless-test-with-certificate-extensions-empty.cfg: -------------------------------------------------------------------------------- 1 | [Bless CA] 2 | ca_private_key_file = tests/aws_lambda/only-use-for-unit-tests.pem 3 | us-east-1_password = bogus-password-for-unit-test 4 | us-west-2_password = bogus-password-for-unit-tests 5 | 6 | [Bless Options] 7 | certificate_extensions = 8 | -------------------------------------------------------------------------------- /lambda_compile.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | yum install -y python37 4 | python3.7 -m venv /tmp/venv 5 | /tmp/venv/bin/pip install --upgrade pip setuptools 6 | /tmp/venv/bin/pip install -e . 7 | cp -r /tmp/venv/lib/python3.7/site-packages/. ./aws_lambda_libs 8 | cp -r /tmp/venv/lib64/python3.7/site-packages/. ./aws_lambda_libs 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | python_files=test*.py 3 | addopts=--tb=native -p no:doctest 4 | norecursedirs=docs htmlcov .* {args} 5 | 6 | [flake8] 7 | ignore = F999,E501,E128,E124,E402,W503,E731,F841 8 | max-line-length = 100 9 | exclude = .tox,.git,docs/* 10 | 11 | [metadata] 12 | description-file = README.md 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | asn1crypto==0.24.0 3 | boto3==1.9.151 4 | botocore==1.12.151 5 | cffi==1.12.3 6 | cryptography==2.6.1 7 | docutils==0.14 8 | ipaddress==1.0.22 9 | jmespath==0.9.4 10 | kmsauth==0.3.0 11 | marshmallow==2.19.2 12 | pycparser==2.19 13 | python-dateutil==2.8.0 14 | s3transfer==0.2.0 15 | six==1.12.0 16 | urllib3==1.24.3 17 | -------------------------------------------------------------------------------- /tests/aws_lambda/bless-test-with-certificate-extensions.cfg: -------------------------------------------------------------------------------- 1 | [Bless CA] 2 | ca_private_key_file = tests/aws_lambda/only-use-for-unit-tests.pem 3 | us-east-1_password = bogus-password-for-unit-test 4 | us-west-2_password = bogus-password-for-unit-tests 5 | 6 | [Bless Options] 7 | # trailing comma in certificate_extensions shouldn't be there, but should be harmless 8 | certificate_extensions = permit-pty,permit-user-rc, -------------------------------------------------------------------------------- /bless/aws_lambda/bless_lambda.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.aws_lambda.bless_lambda 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | from bless.aws_lambda.bless_lambda_user import lambda_handler_user 7 | 8 | 9 | def lambda_handler(*args, **kwargs): 10 | """ 11 | Wrapper around lambda_handler_user for backwards compatibility 12 | """ 13 | return lambda_handler_user(*args, **kwargs) 14 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | BLESS - Bastion's Lambda Ephemeral SSH Service 2 | Copyright 2016 Netflix, Inc. 3 | 4 | Portions of bless.ssh.public_keys module's validate_for_signing logic are based 5 | on letsencrypt's boulder from https://github.com/letsencrypt/boulder 6 | Copyright 2016 ISRG. All rights reserved. 7 | 8 | /* 9 | * This Source Code Form is subject to the terms of the Mozilla Public 10 | * License, v. 2.0. If a copy of the MPL was not distributed with this 11 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 12 | */ 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | addons: 4 | 5 | matrix: 6 | include: 7 | - python: "3.7" 8 | - name: 'SourceClear' 9 | before_script: skip 10 | install: skip 11 | script: skip 12 | addons: 13 | srcclr: true 14 | 15 | install: 16 | - pip install coveralls 17 | - make develop 18 | 19 | before_script: 20 | 21 | script: 22 | - make lint 23 | - make coverage 24 | 25 | after_success: 26 | - coveralls 27 | - coverage report 28 | 29 | notifications: 30 | email: 31 | russelll@netflix.com 32 | -------------------------------------------------------------------------------- /tests/config/full-with-default.cfg: -------------------------------------------------------------------------------- 1 | [Bless Options] 2 | # The default values are sane, these are not. 3 | certificate_validity_after_seconds = 1 4 | certificate_validity_before_seconds = 1 5 | entropy_minimum_bits = 2 6 | random_seed_bytes = 3 7 | logging_level = DEBUG 8 | 9 | [Bless CA] 10 | us-east-1_password = 11 | us-west-2_password = 12 | default_password = 13 | ca_private_key_file = 14 | -------------------------------------------------------------------------------- /bless/__about__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "__title__", "__summary__", "__uri__", "__version__", "__author__", 3 | "__email__", "__license__", "__copyright__", 4 | ] 5 | 6 | __title__ = "BLESS" 7 | __summary__ = ( 8 | "BLESS is an SSH Certificate Authority that runs as a AWS Lambda function and can be used to " 9 | "sign SSH public keys.") 10 | __uri__ = "https://github.com/Netflix/bless" 11 | 12 | __version__ = "0.4.0" 13 | 14 | __author__ = "The BLESS developers" 15 | __email__ = "security@netflix.com" 16 | 17 | __license__ = "Apache License, Version 2.0" 18 | __copyright__ = "Copyright 2016 {0}".format(__author__) 19 | -------------------------------------------------------------------------------- /tests/config/full-zlib.cfg: -------------------------------------------------------------------------------- 1 | [Bless Options] 2 | # The default values are sane, these are not. 3 | certificate_validity_after_seconds = 1 4 | certificate_validity_before_seconds = 1 5 | entropy_minimum_bits = 2 6 | random_seed_bytes = 3 7 | server_certificate_validity_before_seconds = 4 8 | server_certificate_validity_after_seconds = 5 9 | logging_level = DEBUG 10 | username_validation = debian 11 | hostname_validation = disabled 12 | 13 | [Bless CA] 14 | us-east-1_password = 15 | us-west-2_password = 16 | ca_private_key = 17 | ca_private_key_compression = zlib 18 | -------------------------------------------------------------------------------- /tests/config/full.cfg: -------------------------------------------------------------------------------- 1 | [Bless Options] 2 | # The default values are sane, these are not. 3 | certificate_validity_after_seconds = 1 4 | certificate_validity_before_seconds = 1 5 | entropy_minimum_bits = 2 6 | random_seed_bytes = 3 7 | server_certificate_validity_before_seconds = 4 8 | server_certificate_validity_after_seconds = 5 9 | logging_level = DEBUG 10 | username_validation = debian 11 | hostname_validation = disabled 12 | 13 | [Bless CA] 14 | us-east-1_password = 15 | us-west-2_password = 16 | ca_private_key_file = 17 | ca_private_key_compression = zlib 18 | -------------------------------------------------------------------------------- /bless/request/bless_request_common.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.request.bless_request_common 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | from marshmallow import ValidationError 7 | 8 | VALID_SSH_RSA_PUBLIC_KEY_HEADER = "ssh-rsa AAAAB3NzaC1yc2" 9 | VALID_SSH_ED25519_PUBLIC_KEY_HEADER = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5" 10 | 11 | 12 | def validate_ssh_public_key(public_key): 13 | if public_key.startswith(VALID_SSH_RSA_PUBLIC_KEY_HEADER) or public_key.startswith( 14 | VALID_SSH_ED25519_PUBLIC_KEY_HEADER): 15 | pass 16 | # todo other key types 17 | else: 18 | raise ValidationError('Invalid SSH Public Key.') 19 | -------------------------------------------------------------------------------- /bless/ssh/public_keys/ssh_public_key.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.ssh.public_keys.ssh_public_key 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | 7 | 8 | class SSHPublicKeyType(object): 9 | RSA = 'ssh-rsa' 10 | ED25519 = 'ssh-ed25519' 11 | # todo support more key types 12 | 13 | 14 | # todo real abstract classes 15 | class SSHPublicKey(object): 16 | """ 17 | Extracts the useful Public Key information from an SSH Public Key file. 18 | :param ssh_public_key: SSH Public Key file contents. (i.e. 'ssh-XXX AAAA....'). 19 | """ 20 | def __init__(self): 21 | self.type = None 22 | self.key_comment = None 23 | self.fingerprint = None 24 | -------------------------------------------------------------------------------- /tests/aws_lambda/bless-test-kmsauth.cfg: -------------------------------------------------------------------------------- 1 | [Bless CA] 2 | ca_private_key_file = tests/aws_lambda/only-use-for-unit-tests.pem 3 | us-east-1_password = bogus-password-for-unit-test 4 | us-west-2_password = bogus-password-for-unit-test 5 | 6 | [KMS Auth] 7 | use_kmsauth = True 8 | kmsauth_key_id = alias/authnz-iad, alias/authnz-sfo 9 | kmsauth_serviceid = kmsauth-prod 10 | 11 | # todo get from config, with some sane defaults 12 | #[loggers] 13 | #keys=root 14 | # 15 | #[handlers] 16 | #keys=stream_handler 17 | # 18 | #[formatters] 19 | #keys=formatter 20 | # 21 | #[logger_root] 22 | #level=INFO 23 | #handlers=stream_handler 24 | # 25 | #[handler_stream_handler] 26 | #class=StreamHandler 27 | #level=DEBUG 28 | #formatter=formatter 29 | #args=(sys.stderr,) 30 | # 31 | #[formatter_formatter] 32 | #format=%(asctime)s %(name)-12s %(levelname)-8s %(message)s 33 | -------------------------------------------------------------------------------- /tests/config/full-with-kmsauth.cfg: -------------------------------------------------------------------------------- 1 | [Bless Options] 2 | # The default values are sane, these are not. 3 | certificate_validity_after_seconds = 1 4 | certificate_validity_before_seconds = 1 5 | entropy_minimum_bits = 2 6 | random_seed_bytes = 3 7 | logging_level = DEBUG 8 | username_validation = debian 9 | 10 | [Bless CA] 11 | us-east-1_password = 12 | us-west-2_password = 13 | ca_private_key_file = 14 | ca_private_key_compression = zlib 15 | 16 | [KMS Auth] 17 | use_kmsauth = True 18 | kmsauth_key_id = alias/authnz-iad, alias/authnz-sfo 19 | kmsauth_serviceid = kmsauth-prod 20 | kmsauth_remote_usernames_allowed = ubuntu,alloweduser 21 | kmsauth_validate_remote_usernames_against_iam_groups = False 22 | kmsauth_iam_group_name_format = ssh-{} -------------------------------------------------------------------------------- /tests/aws_lambda/bless-test-kmsauth-different-remote.cfg: -------------------------------------------------------------------------------- 1 | [Bless CA] 2 | ca_private_key_file = tests/aws_lambda/only-use-for-unit-tests.pem 3 | us-east-1_password = bogus-password-for-unit-test 4 | us-west-2_password = bogus-password-for-unit-test 5 | 6 | [KMS Auth] 7 | use_kmsauth = True 8 | kmsauth_key_id = alias/authnz-iad, alias/authnz-sfo 9 | kmsauth_serviceid = kmsauth-prod 10 | kmsauth_remote_usernames_allowed = ubuntu,alloweduser 11 | 12 | # todo get from config, with some sane defaults 13 | #[loggers] 14 | #keys=root 15 | # 16 | #[handlers] 17 | #keys=stream_handler 18 | # 19 | #[formatters] 20 | #keys=formatter 21 | # 22 | #[logger_root] 23 | #level=INFO 24 | #handlers=stream_handler 25 | # 26 | #[handler_stream_handler] 27 | #class=StreamHandler 28 | #level=DEBUG 29 | #formatter=formatter 30 | #args=(sys.stderr,) 31 | # 32 | #[formatter_formatter] 33 | #format=%(asctime)s %(name)-12s %(levelname)-8s %(message)s 34 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import setup, find_packages 4 | 5 | ROOT = os.path.realpath(os.path.join(os.path.dirname(__file__))) 6 | 7 | about = {} 8 | with open(os.path.join(ROOT, "bless", "__about__.py")) as f: 9 | exec(f.read(), about) 10 | 11 | setup( 12 | name=about["__title__"], 13 | version=about["__version__"], 14 | author=about["__author__"], 15 | author_email=about["__email__"], 16 | url=about["__uri__"], 17 | description=about["__summary__"], 18 | license=about["__license__"], 19 | packages=find_packages(exclude=["test*"]), 20 | install_requires=[ 21 | 'boto3', 22 | 'cryptography', 23 | 'ipaddress', 24 | 'marshmallow<3', 25 | 'kmsauth' 26 | ], 27 | extras_require={ 28 | 'tests': [ 29 | 'coverage', 30 | 'flake8', 31 | 'pyflakes', 32 | 'pytest', 33 | 'pytest-mock' 34 | ] 35 | } 36 | ) 37 | -------------------------------------------------------------------------------- /tests/aws_lambda/bless-test-kmsauth-iam-group-validation.cfg: -------------------------------------------------------------------------------- 1 | [Bless CA] 2 | ca_private_key_file = tests/aws_lambda/only-use-for-unit-tests.pem 3 | us-east-1_password = bogus-password-for-unit-test 4 | us-west-2_password = bogus-password-for-unit-test 5 | 6 | [KMS Auth] 7 | use_kmsauth = True 8 | kmsauth_key_id = alias/authnz-iad, alias/authnz-sfo 9 | kmsauth_serviceid = kmsauth-prod 10 | kmsauth_remote_usernames_allowed = ubuntu,alloweduser 11 | kmsauth_validate_remote_usernames_against_iam_groups = True 12 | kmsauth_iam_group_name_format = ssh-{} 13 | 14 | # todo get from config, with some sane defaults 15 | #[loggers] 16 | #keys=root 17 | # 18 | #[handlers] 19 | #keys=stream_handler 20 | # 21 | #[formatters] 22 | #keys=formatter 23 | # 24 | #[logger_root] 25 | #level=INFO 26 | #handlers=stream_handler 27 | # 28 | #[handler_stream_handler] 29 | #class=StreamHandler 30 | #level=DEBUG 31 | #formatter=formatter 32 | #args=(sys.stderr,) 33 | # 34 | #[formatter_formatter] 35 | #format=%(asctime)s %(name)-12s %(levelname)-8s %(message)s 36 | -------------------------------------------------------------------------------- /tests/ssh/test_ssh_public_key_factory.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from bless.ssh.public_keys.ssh_public_key_factory import get_ssh_public_key 4 | from tests.ssh.vectors import EXAMPLE_RSA_PUBLIC_KEY, EXAMPLE_ED25519_PUBLIC_KEY, \ 5 | EXAMPLE_ECDSA_PUBLIC_KEY, EXAMPLE_RSA_PUBLIC_KEY_N, EXAMPLE_RSA_PUBLIC_KEY_E, \ 6 | EXAMPLE_ED25519_PUBLIC_KEY_A 7 | 8 | 9 | def test_valid_rsa(): 10 | pub_key = get_ssh_public_key(EXAMPLE_RSA_PUBLIC_KEY) 11 | assert 'Test RSA User Key' == pub_key.key_comment 12 | assert EXAMPLE_RSA_PUBLIC_KEY_N == pub_key.n 13 | assert EXAMPLE_RSA_PUBLIC_KEY_E == pub_key.e 14 | assert 'RSA 57:3d:48:4c:65:90:30:8e:39:ba:d8:fa:d0:20:2e:6c' == pub_key.fingerprint 15 | 16 | 17 | def test_valid_ed25519(): 18 | pub_key = get_ssh_public_key(EXAMPLE_ED25519_PUBLIC_KEY) 19 | assert 'Test ED25519 User Key' == pub_key.key_comment 20 | assert EXAMPLE_ED25519_PUBLIC_KEY_A == pub_key.a 21 | assert 'ED25519 fb:80:ca:21:7d:c8:9d:38:35:c0:f6:ba:fb:6d:82:e8' == pub_key.fingerprint 22 | 23 | 24 | def test_invalid_key(): 25 | with pytest.raises(TypeError): 26 | get_ssh_public_key(EXAMPLE_ECDSA_PUBLIC_KEY) 27 | -------------------------------------------------------------------------------- /tests/ssh/test_ssh_public_key_ed25519.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from bless.ssh.public_keys.ed25519_public_key import ED25519PublicKey 4 | from tests.ssh.vectors import EXAMPLE_ED25519_PUBLIC_KEY, EXAMPLE_ED25519_PUBLIC_KEY_A, \ 5 | EXAMPLE_ECDSA_PUBLIC_KEY, \ 6 | EXAMPLE_ED25519_PUBLIC_KEY_NO_DESCRIPTION 7 | 8 | 9 | def test_valid_key(): 10 | pub_key = ED25519PublicKey(EXAMPLE_ED25519_PUBLIC_KEY) 11 | assert 'Test ED25519 User Key' == pub_key.key_comment 12 | assert EXAMPLE_ED25519_PUBLIC_KEY_A == pub_key.a 13 | assert 'ED25519 fb:80:ca:21:7d:c8:9d:38:35:c0:f6:ba:fb:6d:82:e8' == pub_key.fingerprint 14 | 15 | 16 | def test_valid_key_no_description(): 17 | pub_key = ED25519PublicKey(EXAMPLE_ED25519_PUBLIC_KEY_NO_DESCRIPTION) 18 | assert '' == pub_key.key_comment 19 | assert EXAMPLE_ED25519_PUBLIC_KEY_A == pub_key.a 20 | assert 'ED25519 fb:80:ca:21:7d:c8:9d:38:35:c0:f6:ba:fb:6d:82:e8' == pub_key.fingerprint 21 | 22 | 23 | def test_invalid_keys(): 24 | with pytest.raises(TypeError): 25 | ED25519PublicKey(EXAMPLE_ECDSA_PUBLIC_KEY) 26 | 27 | with pytest.raises(ValueError): 28 | ED25519PublicKey('bogus') 29 | 30 | -------------------------------------------------------------------------------- /bless/ssh/public_keys/ssh_public_key_factory.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.ssh.public_keys.ssh_public_key_factory 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | from bless.ssh.public_keys.rsa_public_key import RSAPublicKey 7 | from bless.ssh.public_keys.ed25519_public_key import ED25519PublicKey 8 | from bless.ssh.public_keys.ssh_public_key import SSHPublicKeyType 9 | 10 | 11 | def get_ssh_public_key(ssh_public_key): 12 | """ 13 | Returns the proper SSHPublicKey instance based off of the SSH Public Key file. 14 | :param ssh_public_key: SSH Public Key file contents. (i.e. 'ssh-XXX AAAA....'). 15 | :return: An SSHPublicKey instance. 16 | """ 17 | if ssh_public_key.startswith(SSHPublicKeyType.RSA): 18 | rsa_public_key = RSAPublicKey(ssh_public_key) 19 | rsa_public_key.validate_for_signing() 20 | return rsa_public_key 21 | elif ssh_public_key.startswith(SSHPublicKeyType.ED25519): 22 | ed25519_public_key = ED25519PublicKey(ssh_public_key) 23 | return ed25519_public_key 24 | else: 25 | raise TypeError("Unsupported Public Key Type") 26 | -------------------------------------------------------------------------------- /bless/ssh/certificate_authorities/ssh_certificate_authority_factory.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.ssh.certificate_authorities.ssh_certificate_authority_factory 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | from bless.ssh.certificate_authorities.rsa_certificate_authority import \ 7 | RSACertificateAuthority 8 | from bless.ssh.certificate_authorities.ssh_certificate_authority import \ 9 | SSHCertificateAuthorityPrivateKeyType 10 | 11 | 12 | def get_ssh_certificate_authority(private_key, password=None): 13 | """ 14 | Returns the proper SSHCertificateAuthority instance based off the private_key type. 15 | :param private_key: ASCII bytes of an SSH compatible Private Key (e.g., PEM or SSH Protocol 2 Private Key). 16 | It should be encrypted with a password, but that is not required. 17 | :param password: ASCII bytes of the Password to decrypt the Private Key, if it is encrypted. Which it should be. 18 | :return: An SSHCertificateAuthority instance. 19 | """ 20 | if private_key.decode('ascii').startswith(SSHCertificateAuthorityPrivateKeyType.RSA): 21 | return RSACertificateAuthority(private_key, password) 22 | else: 23 | raise TypeError("Unsupported CA Private Key Type") 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: lint 2 | @echo "--> Running Python tests" 3 | py.test tests || exit 1 4 | @echo "" 5 | 6 | develop: 7 | @echo "--> Installing dependencies" 8 | pip install --upgrade pip setuptools 9 | pip install -r requirements.txt 10 | pip install "file://`pwd`#egg=bless[tests]" 11 | @echo "" 12 | 13 | dev-docs: 14 | # todo the docs, so typical, right? 15 | 16 | clean: 17 | @echo "--> Cleaning pyc files" 18 | find . -name "*.pyc" -delete 19 | rm -rf ./publish ./htmlcov 20 | @echo "" 21 | 22 | lint: 23 | @echo "--> Linting Python files" 24 | PYFLAKES_NODOCTEST=1 flake8 bless 25 | @echo "" 26 | 27 | coverage: 28 | @echo "--> Running Python tests with coverage" 29 | coverage run --branch --source=bless -m py.test tests || exit 1 30 | coverage html 31 | @echo "" 32 | 33 | publish: 34 | rm -rf ./publish/bless_lambda/ 35 | mkdir -p ./publish/bless_lambda 36 | cp -r ./bless ./publish/bless_lambda/ 37 | cp ./publish/bless_lambda/bless/aws_lambda/bless* ./publish/bless_lambda/ 38 | cp -r ./aws_lambda_libs/. ./publish/bless_lambda/ 39 | if [ -d ./lambda_configs/ ]; then cp -r ./lambda_configs/. ./publish/bless_lambda/; fi 40 | cd ./publish/bless_lambda && zip -FSr ../bless_lambda.zip . 41 | 42 | compile: 43 | ./lambda_compile.sh 44 | 45 | lambda-deps: 46 | @echo "--> Compiling lambda dependencies" 47 | docker run --rm -v ${CURDIR}:/src -w /src amazonlinux:2 ./lambda_compile.sh 48 | 49 | .PHONY: develop dev-docs clean test lint coverage publish 50 | -------------------------------------------------------------------------------- /tests/request/test_bless_request_host.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from bless.request.bless_request_host import HOSTNAME_VALIDATION_OPTIONS, BlessHostSchema, validate_hostname 3 | from marshmallow import ValidationError 4 | 5 | 6 | @pytest.mark.parametrize("test_input", [ 7 | 'thisthat', 8 | 'this.that', 9 | '10.1.1.1' 10 | ]) 11 | def test_validate_hostnames(test_input): 12 | validate_hostname(test_input, HOSTNAME_VALIDATION_OPTIONS.url) 13 | 14 | 15 | @pytest.mark.parametrize("test_input", [ 16 | 'this..that', 17 | ['thisthat'], 18 | 'this!that.com' 19 | ]) 20 | def test_invalid_hostnames(test_input): 21 | with pytest.raises(ValidationError) as e: 22 | validate_hostname(test_input, HOSTNAME_VALIDATION_OPTIONS.url) 23 | assert str(e.value) == 'Invalid hostname "ssh://{}".'.format(test_input) 24 | 25 | 26 | @pytest.mark.parametrize("test_input", [ 27 | 'this..that', 28 | ['thisthat'], 29 | 'this!that.com', 30 | 'this,that' 31 | ]) 32 | def test_invalid_hostnames_with_disabled(test_input): 33 | validate_hostname(test_input, HOSTNAME_VALIDATION_OPTIONS.disabled) 34 | 35 | 36 | @pytest.mark.parametrize("test_input", [ 37 | 'thisthat,this.that,10.1.1.1', 38 | 'this.that,thishostname' 39 | ]) 40 | def test_valid_multiple_hostnames(test_input): 41 | BlessHostSchema().validate_hostnames(test_input) 42 | 43 | 44 | @pytest.mark.parametrize("test_input", [ 45 | 'thisthat, this.that', 46 | ]) 47 | def test_invalid_multiple_hostnames(test_input): 48 | with pytest.raises(ValidationError) as e: 49 | BlessHostSchema().validate_hostnames(test_input) 50 | assert str(e.value) == 'Invalid hostname "ssh:// this.that".' 51 | -------------------------------------------------------------------------------- /bless/ssh/certificates/ssh_certificate_builder_factory.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.ssh.certificates.ssh_certificate_builder_factory 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | from bless.ssh.certificates.rsa_certificate_builder \ 7 | import RSACertificateBuilder 8 | from bless.ssh.certificates.ed25519_certificate_builder \ 9 | import ED25519CertificateBuilder 10 | from bless.ssh.public_keys.ssh_public_key import SSHPublicKeyType 11 | from bless.ssh.public_keys.ssh_public_key_factory import get_ssh_public_key 12 | 13 | 14 | def get_ssh_certificate_builder(ca, cert_type, public_key_to_sign): 15 | """ 16 | Returns the proper SSHCertificateBuilder instance for the type of public key to be signed. 17 | :param ca: The SSHCertificateAuthority that will sign the certificate. The 18 | SSHCertificateAuthority type does not need to be the same type as the SSHCertificateBuilder. 19 | :param cert_type: The SSHCertificateType. Is this a User or Host certificate? 20 | :param public_key_to_sign: The SSHPublicKey to issue a certificate for. 21 | :return: An SSHCertificateBuilder instance. 22 | """ 23 | # Determine the type of public key we have, to decide the right cert type 24 | ssh_public_key = get_ssh_public_key(public_key_to_sign) 25 | 26 | if ssh_public_key.type is SSHPublicKeyType.RSA: 27 | return RSACertificateBuilder(ca, cert_type, ssh_public_key) 28 | elif ssh_public_key.type is SSHPublicKeyType.ED25519: 29 | return ED25519CertificateBuilder(ca, cert_type, ssh_public_key) 30 | else: 31 | raise TypeError("Unsupported Public Key Type") 32 | -------------------------------------------------------------------------------- /bless/ssh/certificate_authorities/ssh_certificate_authority.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.ssh.certificate_authorities.ssh_certificate_authority 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | from bless.ssh.protocol.ssh_protocol import pack_ssh_string 7 | 8 | 9 | class SSHCertificateAuthorityPrivateKeyType(object): 10 | RSA = '-----BEGIN RSA PRIVATE KEY-----\n' 11 | # todo support other CA Private Key Types 12 | 13 | 14 | class SSHCertificateAuthority(object): 15 | def __init__(self): 16 | self.public_key_type = None 17 | 18 | # todo real abstract classes 19 | def sign(self, body): 20 | """ 21 | Sign the certificate body with the CA private key. Signatures are computed and 22 | encoded per RFC4253 section 6.6 23 | :param body: All other fields of the SSH Certificate, from the initial string to the 24 | signature key. 25 | :return: SSH Signature. 26 | """ 27 | raise NotImplementedError("Child classes should override this") 28 | 29 | # todo real abstract classes 30 | def get_signature_key(self): 31 | """ 32 | Get the SSH Public Key associated with this CA. 33 | Packed per RFC4253 section 6.6 34 | :return: SSH Certificate formatted Public Key. 35 | """ 36 | raise NotImplementedError("Child classes should override this") 37 | 38 | def _serialize_signature(self, signature): 39 | # pack signature block 40 | sig_inner = pack_ssh_string(self.public_key_type) 41 | sig_inner += pack_ssh_string(signature) 42 | 43 | return pack_ssh_string(sig_inner) 44 | -------------------------------------------------------------------------------- /tests/ssh/test_ssh_certificate_authority_factory.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from bless.ssh.certificate_authorities.rsa_certificate_authority import RSACertificateAuthority 4 | from bless.ssh.certificate_authorities.ssh_certificate_authority_factory import \ 5 | get_ssh_certificate_authority 6 | from bless.ssh.public_keys.ssh_public_key import SSHPublicKeyType 7 | from tests.ssh.vectors import RSA_CA_PRIVATE_KEY, RSA_CA_PRIVATE_KEY_PASSWORD, \ 8 | RSA_CA_SSH_PUBLIC_KEY, RSA_CA_PRIVATE_KEY_NOT_ENCRYPTED 9 | 10 | 11 | def test_valid_key_valid_password(): 12 | ca = get_ssh_certificate_authority(RSA_CA_PRIVATE_KEY, RSA_CA_PRIVATE_KEY_PASSWORD) 13 | assert isinstance(ca, RSACertificateAuthority) 14 | assert SSHPublicKeyType.RSA == ca.public_key_type 15 | assert 65537 == ca.e 16 | assert ca.get_signature_key() == RSA_CA_SSH_PUBLIC_KEY 17 | 18 | 19 | def test_valid_key_not_encrypted(): 20 | ca = get_ssh_certificate_authority(RSA_CA_PRIVATE_KEY_NOT_ENCRYPTED) 21 | assert SSHPublicKeyType.RSA == ca.public_key_type 22 | assert 65537 == ca.e 23 | 24 | 25 | def test_valid_key_missing_password(): 26 | with pytest.raises(TypeError): 27 | get_ssh_certificate_authority(RSA_CA_PRIVATE_KEY) 28 | 29 | 30 | def test_valid_key_invalid_password(): 31 | with pytest.raises(ValueError): 32 | get_ssh_certificate_authority(RSA_CA_PRIVATE_KEY, b'bogus') 33 | 34 | 35 | def test_valid_key_not_encrypted_invalid_pass(): 36 | with pytest.raises(TypeError): 37 | get_ssh_certificate_authority(RSA_CA_PRIVATE_KEY_NOT_ENCRYPTED, b'bogus') 38 | 39 | 40 | def test_invalid_key(): 41 | with pytest.raises(TypeError): 42 | get_ssh_certificate_authority(b'bogus') 43 | -------------------------------------------------------------------------------- /tests/ssh/test_ssh_certificate_builder_factory.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from bless.ssh.certificate_authorities.ssh_certificate_authority_factory import \ 4 | get_ssh_certificate_authority 5 | from bless.ssh.certificates.rsa_certificate_builder import RSACertificateBuilder, \ 6 | SSHCertifiedKeyType 7 | from bless.ssh.certificates.ed25519_certificate_builder import ED25519CertificateBuilder 8 | from bless.ssh.certificates.ssh_certificate_builder import SSHCertificateType 9 | from bless.ssh.certificates.ssh_certificate_builder_factory import get_ssh_certificate_builder 10 | from tests.ssh.vectors import RSA_CA_PRIVATE_KEY, RSA_CA_PRIVATE_KEY_PASSWORD, \ 11 | EXAMPLE_RSA_PUBLIC_KEY, EXAMPLE_ED25519_PUBLIC_KEY 12 | 13 | 14 | def test_valid_rsa_request(): 15 | ca = get_ssh_certificate_authority(RSA_CA_PRIVATE_KEY, RSA_CA_PRIVATE_KEY_PASSWORD) 16 | cert_builder = get_ssh_certificate_builder(ca, SSHCertificateType.USER, EXAMPLE_RSA_PUBLIC_KEY) 17 | cert = cert_builder.get_cert_file() 18 | assert isinstance(cert_builder, RSACertificateBuilder) 19 | assert cert.startswith(SSHCertifiedKeyType.RSA) 20 | 21 | 22 | def test_valid_ed25519_request(): 23 | ca = get_ssh_certificate_authority(RSA_CA_PRIVATE_KEY, RSA_CA_PRIVATE_KEY_PASSWORD) 24 | cert_builder = get_ssh_certificate_builder(ca, SSHCertificateType.USER, EXAMPLE_ED25519_PUBLIC_KEY) 25 | cert = cert_builder.get_cert_file() 26 | assert isinstance(cert_builder, ED25519CertificateBuilder) 27 | assert cert.startswith(SSHCertifiedKeyType.ED25519) 28 | 29 | 30 | def test_invalid_key_request(): 31 | with pytest.raises(TypeError): 32 | ca = get_ssh_certificate_authority(RSA_CA_PRIVATE_KEY, RSA_CA_PRIVATE_KEY_PASSWORD) 33 | get_ssh_certificate_builder(ca, SSHCertificateType.USER, 'bogus') 34 | -------------------------------------------------------------------------------- /bless/cache/bless_lambda_cache.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.cache.bless_lambda_cache 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | import base64 7 | import os 8 | 9 | import boto3 10 | from bless.config.bless_config import BlessConfig 11 | from botocore.exceptions import ClientError 12 | 13 | 14 | class BlessLambdaCache: 15 | region = None 16 | config = None 17 | ca_private_key_password = None 18 | ca_private_key_password_error = None 19 | 20 | def __init__(self, ca_private_key_password=None, 21 | config_file=None): 22 | """ 23 | 24 | :param ca_private_key_password: For local testing, if the password is provided, skip the KMS 25 | decrypt. 26 | :param config_file: The config file to load the SSH CA private key from, and additional settings. 27 | """ 28 | # AWS Region determines configs related to KMS 29 | if 'AWS_REGION' in os.environ: 30 | self.region = os.environ['AWS_REGION'] 31 | else: 32 | self.region = 'us-west-2' 33 | 34 | # Load the deployment config values 35 | self.config = BlessConfig(self.region, config_file=config_file) 36 | 37 | password_ciphertext_b64 = self.config.getpassword() 38 | 39 | # decrypt ca private key password 40 | if ca_private_key_password is None: 41 | kms_client = boto3.client('kms', region_name=self.region) 42 | try: 43 | ca_password = kms_client.decrypt( 44 | CiphertextBlob=base64.b64decode(password_ciphertext_b64)) 45 | self.ca_private_key_password = ca_password['Plaintext'] 46 | except ClientError as e: 47 | self.ca_private_key_password_error = str(e) 48 | else: 49 | self.ca_private_key_password = ca_private_key_password 50 | -------------------------------------------------------------------------------- /tests/ssh/test_ssh_public_key_rsa.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from bless.ssh.public_keys.rsa_public_key import RSAPublicKey 4 | from tests.ssh.vectors import EXAMPLE_RSA_PUBLIC_KEY, \ 5 | EXAMPLE_RSA_PUBLIC_KEY_NO_DESCRIPTION, EXAMPLE_ECDSA_PUBLIC_KEY, EXAMPLE_RSA_PUBLIC_KEY_N, \ 6 | EXAMPLE_RSA_PUBLIC_KEY_E, EXAMPLE_RSA_PUBLIC_KEY_2048, EXAMPLE_RSA_PUBLIC_KEY_1024, \ 7 | EXAMPLE_RSA_PUBLIC_KEY_SMALLPRIME, EXAMPLE_RSA_PUBLIC_KEY_E3 8 | 9 | 10 | def test_valid_key(): 11 | pub_key = RSAPublicKey(EXAMPLE_RSA_PUBLIC_KEY) 12 | assert 'Test RSA User Key' == pub_key.key_comment 13 | assert EXAMPLE_RSA_PUBLIC_KEY_N == pub_key.n 14 | assert EXAMPLE_RSA_PUBLIC_KEY_E == pub_key.e 15 | assert 'RSA 57:3d:48:4c:65:90:30:8e:39:ba:d8:fa:d0:20:2e:6c' == pub_key.fingerprint 16 | 17 | 18 | def test_valid_key_no_description(): 19 | pub_key = RSAPublicKey(EXAMPLE_RSA_PUBLIC_KEY_NO_DESCRIPTION) 20 | assert '' == pub_key.key_comment 21 | assert EXAMPLE_RSA_PUBLIC_KEY_N == pub_key.n 22 | assert EXAMPLE_RSA_PUBLIC_KEY_E == pub_key.e 23 | assert 'RSA 57:3d:48:4c:65:90:30:8e:39:ba:d8:fa:d0:20:2e:6c' == pub_key.fingerprint 24 | 25 | 26 | def test_invalid_keys(): 27 | with pytest.raises(TypeError): 28 | RSAPublicKey(EXAMPLE_ECDSA_PUBLIC_KEY) 29 | 30 | with pytest.raises(ValueError): 31 | RSAPublicKey('bogus') 32 | 33 | 34 | def test_validation_for_signing(): 35 | pub_key = RSAPublicKey(EXAMPLE_RSA_PUBLIC_KEY_1024) 36 | with pytest.raises(ValueError): 37 | pub_key.validate_for_signing() 38 | 39 | pub_key_sp = RSAPublicKey(EXAMPLE_RSA_PUBLIC_KEY_SMALLPRIME) 40 | with pytest.raises(ValueError): 41 | pub_key_sp.validate_for_signing() 42 | 43 | pub_key_e3 = RSAPublicKey(EXAMPLE_RSA_PUBLIC_KEY_E3) 44 | with pytest.raises(ValueError): 45 | pub_key_e3.validate_for_signing() 46 | 47 | pub_key_valid = RSAPublicKey(EXAMPLE_RSA_PUBLIC_KEY_2048) 48 | try: 49 | pub_key_valid.validate_for_signing() 50 | except ValueError: 51 | pytest.fail("Valid key failed to validate") 52 | -------------------------------------------------------------------------------- /bless/ssh/certificates/rsa_certificate_builder.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.ssh.certificates.rsa_certificate_builder 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | from bless.ssh.certificates.ssh_certificate_builder import \ 7 | SSHCertificateBuilder, SSHCertifiedKeyType 8 | from bless.ssh.protocol.ssh_protocol import pack_ssh_mpint 9 | 10 | 11 | class RSACertificateBuilder(SSHCertificateBuilder): 12 | def __init__(self, ca, cert_type, ssh_public_key_rsa): 13 | """ 14 | Produces an SSH certificate for RSA public keys. 15 | :param ca: The SSHCertificateAuthority that will sign the certificate. The 16 | SSHCertificateAuthority type does not need to be the same type as the 17 | SSHCertificateBuilder. 18 | :param cert_type: The SSHCertificateType. Is this a User or Host certificate? Some of 19 | the SSH Certificate fields do not apply or have a slightly different meaning depending on 20 | the certificate type. 21 | See http://cvsweb.openbsd.org/cgi-bin/cvsweb/src/usr.bin/ssh/PROTOCOL.certkeys 22 | :param ssh_public_key_rsa: The RSAPublicKey to issue a certificate for. 23 | """ 24 | super(RSACertificateBuilder, self).__init__(ca, cert_type) 25 | self.cert_key_type = SSHCertifiedKeyType.RSA 26 | self.ssh_public_key = ssh_public_key_rsa 27 | self.public_key_comment = ssh_public_key_rsa.key_comment 28 | self.e = ssh_public_key_rsa.e 29 | self.n = ssh_public_key_rsa.n 30 | 31 | def _serialize_ssh_public_key(self): 32 | """ 33 | Serialize the Public Key into the RSA exponent and public modulus stored as SSH mpints. 34 | http://cvsweb.openbsd.org/cgi-bin/cvsweb/src/usr.bin/ssh/PROTOCOL.certkeys 35 | :return: The bytes that belong in the SSH Certificate between the nonce and the 36 | certificate serial number. 37 | """ 38 | public_key = pack_ssh_mpint(self.e) 39 | public_key += pack_ssh_mpint(self.n) 40 | return public_key 41 | -------------------------------------------------------------------------------- /bless/ssh/certificates/ed25519_certificate_builder.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.ssh.certificates.ed25519_certificate_builder 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | from bless.ssh.certificates.ssh_certificate_builder import \ 7 | SSHCertificateBuilder, SSHCertifiedKeyType 8 | from bless.ssh.protocol.ssh_protocol import pack_ssh_string 9 | 10 | 11 | class ED25519CertificateBuilder(SSHCertificateBuilder): 12 | def __init__(self, ca, cert_type, ssh_public_key_ed25519): 13 | """ 14 | Produces an SSH certificate for ED25519 public keys. 15 | :param ca: The SSHCertificateAuthority that will sign the certificate. The 16 | SSHCertificateAuthority type does not need to be the same type as the 17 | SSHCertificateBuilder. 18 | :param cert_type: The SSHCertificateType. Is this a User or Host certificate? Some of 19 | the SSH Certificate fields do not apply or have a slightly different meaning depending on 20 | the certificate type. 21 | See http://cvsweb.openbsd.org/cgi-bin/cvsweb/src/usr.bin/ssh/PROTOCOL.certkeys 22 | :param ssh_public_key_ed25519: The ED25519PublicKey to issue a certificate for. 23 | """ 24 | super(ED25519CertificateBuilder, self).__init__(ca, cert_type) 25 | self.cert_key_type = SSHCertifiedKeyType.ED25519 26 | self.ssh_public_key = ssh_public_key_ed25519 27 | self.public_key_comment = ssh_public_key_ed25519.key_comment 28 | self.a = ssh_public_key_ed25519.a 29 | 30 | def _serialize_ssh_public_key(self): 31 | """ 32 | Serialize the Public Key into a string. This is not specified in 33 | http://cvsweb.openbsd.org/cgi-bin/cvsweb/src/usr.bin/ssh/PROTOCOL.certkeys 34 | but https://tools.ietf.org/id/draft-ietf-curdle-ssh-ed25519-02.html 35 | :return: The bytes that belong in the SSH Certificate between the nonce and the 36 | certificate serial number. 37 | """ 38 | public_key = pack_ssh_string(self.a) 39 | return public_key 40 | -------------------------------------------------------------------------------- /tests/aws_lambda/test_bless_lambda_host.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from bless.aws_lambda.bless_lambda_host import lambda_handler_host 4 | from tests.ssh.vectors import EXAMPLE_RSA_PUBLIC_KEY, RSA_CA_PRIVATE_KEY_PASSWORD 5 | 6 | 7 | class Context(object): 8 | aws_request_id = 'bogus aws_request_id' 9 | invoked_function_arn = 'bogus invoked_function_arn' 10 | 11 | 12 | VALID_TEST_REQUEST = { 13 | "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, 14 | "hostnames": "thisthat.com", 15 | } 16 | 17 | VALID_TEST_REQUEST_MULTIPLE_HOSTS = { 18 | "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, 19 | "hostnames": "thisthat.com,thatthis.com", 20 | } 21 | 22 | INVALID_TEST_REQUEST = { 23 | "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, 24 | "hostname": "thisthat.com", # Wrong key name 25 | } 26 | 27 | os.environ['AWS_REGION'] = 'us-west-2' 28 | 29 | 30 | def test_basic_local_request(): 31 | output = lambda_handler_host(VALID_TEST_REQUEST, context=Context, 32 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 33 | entropy_check=False, 34 | config_file=os.path.join(os.path.dirname(__file__), 'bless-test.cfg')) 35 | print(output) 36 | assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ') 37 | 38 | 39 | def test_basic_local_request_with_multiple_hosts(): 40 | output = lambda_handler_host(VALID_TEST_REQUEST_MULTIPLE_HOSTS, context=Context, 41 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 42 | entropy_check=False, 43 | config_file=os.path.join(os.path.dirname(__file__), 'bless-test.cfg')) 44 | print(output) 45 | assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ') 46 | 47 | 48 | def test_invalid_request(): 49 | output = lambda_handler_host(INVALID_TEST_REQUEST, context=Context, 50 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 51 | entropy_check=False, 52 | config_file=os.path.join(os.path.dirname(__file__), 'bless-test.cfg')) 53 | assert output['errorType'] == 'InputValidationError' 54 | -------------------------------------------------------------------------------- /bless/ssh/public_keys/ed25519_public_key.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.ssh.public_keys.ed25519_public_key 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | import base64 7 | import hashlib 8 | 9 | from bless.ssh.public_keys.ssh_public_key import SSHPublicKey, SSHPublicKeyType 10 | from cryptography.hazmat.primitives.serialization import ssh 11 | 12 | 13 | class ED25519PublicKey(SSHPublicKey): 14 | def __init__(self, ssh_public_key): 15 | """ 16 | Extracts the useful ED25519 Public Key information from an SSH Public Key file. 17 | :param ssh_public_key: SSH Public Key file contents. (i.e. 'ssh-ed25519 AAAAB3NzaC1yc2E..'). 18 | """ 19 | super(ED25519PublicKey, self).__init__() 20 | 21 | self.type = SSHPublicKeyType.ED25519 22 | 23 | split_ssh_public_key = ssh_public_key.split(' ') 24 | split_key_len = len(split_ssh_public_key) 25 | 26 | # is there a key comment at the end? 27 | if split_key_len > 2: 28 | self.key_comment = ' '.join(split_ssh_public_key[2:]) 29 | else: 30 | self.key_comment = '' 31 | 32 | # hazmat does not support ed25519 so we have out own loader based on serialization.load_ssh_public_key 33 | 34 | if split_key_len < 2: 35 | raise ValueError( 36 | 'Key is not in the proper format or contains extra data.') 37 | 38 | key_type = split_ssh_public_key[0] 39 | key_body = split_ssh_public_key[1] 40 | 41 | if key_type != SSHPublicKeyType.ED25519: 42 | raise TypeError("Public Key is not the correct type or format") 43 | 44 | try: 45 | decoded_data = base64.b64decode(key_body) 46 | except TypeError: 47 | raise ValueError('Key is not in the proper format.') 48 | 49 | inner_key_type, rest = ssh._ssh_read_next_string(decoded_data) 50 | 51 | if inner_key_type != key_type.encode("utf-8"): 52 | raise ValueError( 53 | 'Key header and key body contain different key type values.' 54 | ) 55 | 56 | # ed25519 public key is a single string https://tools.ietf.org/html/rfc8032#section-5.1.5 57 | self.a, rest = ssh._ssh_read_next_string(rest) 58 | 59 | key_bytes = base64.b64decode(split_ssh_public_key[1]) 60 | fingerprint = hashlib.md5(key_bytes).hexdigest() 61 | 62 | self.fingerprint = 'ED25519 ' + ':'.join( 63 | fingerprint[i:i + 2] for i in range(0, len(fingerprint), 2)) 64 | -------------------------------------------------------------------------------- /bless/ssh/certificate_authorities/rsa_certificate_authority.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.ssh.certificate_authorities.rsa_certificate_authority 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | from bless.ssh.certificate_authorities.ssh_certificate_authority import \ 7 | SSHCertificateAuthority 8 | from bless.ssh.protocol.ssh_protocol import pack_ssh_mpint, pack_ssh_string 9 | from bless.ssh.public_keys.ssh_public_key import SSHPublicKeyType 10 | from cryptography.hazmat.backends import default_backend 11 | from cryptography.hazmat.primitives import hashes 12 | from cryptography.hazmat.primitives.asymmetric import padding 13 | from cryptography.hazmat.primitives.serialization import load_pem_private_key 14 | 15 | 16 | class RSACertificateAuthority(SSHCertificateAuthority): 17 | def __init__(self, pem_private_key, private_key_password=None): 18 | """ 19 | RSA Certificate Authority used to sign certificates. 20 | :param pem_private_key: PEM formatted RSA Private Key. It should be encrypted with a 21 | password, but that is not required. 22 | :param private_key_password: Password to decrypt the PEM RSA Private Key, if it is 23 | encrypted. Which it should be. 24 | """ 25 | super(SSHCertificateAuthority, self).__init__() 26 | self.public_key_type = SSHPublicKeyType.RSA 27 | 28 | self.private_key = load_pem_private_key(pem_private_key, 29 | private_key_password, 30 | default_backend()) 31 | 32 | ca_pub_numbers = self.private_key.public_key().public_numbers() 33 | 34 | self.e = ca_pub_numbers.e 35 | self.n = ca_pub_numbers.n 36 | 37 | def get_signature_key(self): 38 | """ 39 | Get the SSH Public Key associated with this CA. 40 | Packed per RFC4253 section 6.6. 41 | :return: SSH Public Key. 42 | """ 43 | key = pack_ssh_string(self.public_key_type) 44 | key += pack_ssh_mpint(self.e) 45 | key += pack_ssh_mpint(self.n) 46 | return key 47 | 48 | def sign(self, body): 49 | """ 50 | Sign the certificate body with the RSA private key. Signatures are computed and 51 | encoded per RFC4253 section 6.6 52 | :param body: All other fields of the SSH Certificate, from the initial string to the 53 | signature key. 54 | :return: SSH RSA Signature. 55 | """ 56 | signature = self.private_key.sign(body, padding.PKCS1v15(), hashes.SHA1()) 57 | 58 | return self._serialize_signature(signature) 59 | -------------------------------------------------------------------------------- /bless/request/bless_request_host.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.request.bless_request_host 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | from enum import Enum 7 | 8 | from bless.config.bless_config import HOSTNAME_VALIDATION_OPTION, HOSTNAME_VALIDATION_DEFAULT 9 | from bless.request.bless_request_common import validate_ssh_public_key 10 | from marshmallow import Schema, fields, validates_schema, ValidationError, post_load, validates 11 | from marshmallow.validate import URL 12 | 13 | HOSTNAME_VALIDATION_OPTIONS = Enum('HostNameValidationOptions', 14 | 'url ' # Valid url format 15 | 'disabled' # no validation 16 | ) 17 | 18 | 19 | def validate_hostname(hostname, hostname_validation): 20 | if hostname_validation == HOSTNAME_VALIDATION_OPTIONS.disabled: 21 | return 22 | else: 23 | validator = URL(require_tld=False, schemes='ssh', error='Invalid hostname "{input}".') 24 | validator('ssh://{}'.format(hostname)) 25 | 26 | 27 | class BlessHostSchema(Schema): 28 | hostnames = fields.Str(required=True) 29 | public_key_to_sign = fields.Str(validate=validate_ssh_public_key, required=True) 30 | 31 | @validates_schema(pass_original=True) 32 | def check_unknown_fields(self, data, original_data): 33 | unknown = set(original_data) - set(self.fields) 34 | if unknown: 35 | raise ValidationError('Unknown field', unknown) 36 | 37 | @post_load 38 | def make_bless_request(self, data): 39 | return BlessHostRequest(**data) 40 | 41 | @validates('hostnames') 42 | def validate_hostnames(self, hostnames): 43 | if HOSTNAME_VALIDATION_OPTION in self.context: 44 | hostname_validation = HOSTNAME_VALIDATION_OPTIONS[self.context[HOSTNAME_VALIDATION_OPTION]] 45 | else: 46 | hostname_validation = HOSTNAME_VALIDATION_OPTIONS[HOSTNAME_VALIDATION_DEFAULT] 47 | for hostname in hostnames.split(','): 48 | validate_hostname(hostname, hostname_validation) 49 | 50 | 51 | class BlessHostRequest: 52 | def __init__(self, hostnames, public_key_to_sign): 53 | """ 54 | A BlessRequest must have the following key value pairs to be valid. 55 | :param hostnames: Comma-separated list of hostname(s) to include in this host certificate. 56 | :param public_key_to_sign: The id_XXX.pub that will be used in the SSH request. This is enforced in the issued certificate. 57 | """ 58 | self.hostnames = hostnames 59 | self.public_key_to_sign = public_key_to_sign 60 | 61 | def __eq__(self, other): 62 | return self.__dict__ == other.__dict__ 63 | -------------------------------------------------------------------------------- /bless/aws_lambda/bless_lambda_common.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.aws_lambda.bless_lambda_common 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | import logging 7 | import os 8 | 9 | import boto3 10 | from bless.cache.bless_lambda_cache import BlessLambdaCache 11 | from bless.config.bless_config import BLESS_OPTIONS_SECTION, LOGGING_LEVEL_OPTION, ENTROPY_MINIMUM_BITS_OPTION, \ 12 | RANDOM_SEED_BYTES_OPTION 13 | 14 | global_bless_cache = None 15 | 16 | 17 | def success_response(cert): 18 | return { 19 | 'certificate': cert 20 | } 21 | 22 | 23 | def error_response(error_type, error_message): 24 | return { 25 | 'errorType': error_type, 26 | 'errorMessage': error_message 27 | } 28 | 29 | 30 | def set_logger(config): 31 | logging_level = config.get(BLESS_OPTIONS_SECTION, LOGGING_LEVEL_OPTION) 32 | numeric_level = getattr(logging, logging_level.upper(), None) 33 | if not isinstance(numeric_level, int): 34 | raise ValueError('Invalid log level: {}'.format(logging_level)) 35 | 36 | logger = logging.getLogger() 37 | logger.setLevel(numeric_level) 38 | return logger 39 | 40 | 41 | def check_entropy(config, logger): 42 | """ 43 | Check the entropy pool and seed it with KMS if desired 44 | """ 45 | region = os.environ['AWS_REGION'] 46 | kms_client = boto3.client('kms', region_name=region) 47 | entropy_minimum_bits = config.getint(BLESS_OPTIONS_SECTION, ENTROPY_MINIMUM_BITS_OPTION) 48 | random_seed_bytes = config.getint(BLESS_OPTIONS_SECTION, RANDOM_SEED_BYTES_OPTION) 49 | 50 | with open('/proc/sys/kernel/random/entropy_avail', 'r') as f: 51 | entropy = int(f.read()) 52 | logger.debug(entropy) 53 | if entropy < entropy_minimum_bits: 54 | logger.info( 55 | 'System entropy was {}, which is lower than the entropy_' 56 | 'minimum {}. Using KMS to seed /dev/urandom'.format( 57 | entropy, entropy_minimum_bits)) 58 | response = kms_client.generate_random( 59 | NumberOfBytes=random_seed_bytes) 60 | random_seed = response['Plaintext'] 61 | with open('/dev/urandom', 'w') as urandom: 62 | urandom.write(random_seed) 63 | 64 | 65 | def setup_lambda_cache(ca_private_key_password, config_file): 66 | # For testing, ignore the static bless_cache, otherwise fill the cache one time. 67 | global global_bless_cache 68 | if ca_private_key_password is not None or config_file is not None: 69 | bless_cache = BlessLambdaCache(ca_private_key_password, config_file) 70 | elif global_bless_cache is None: 71 | global_bless_cache = BlessLambdaCache(config_file=os.path.join(os.getcwd(), 'bless_deploy.cfg')) 72 | bless_cache = global_bless_cache 73 | else: 74 | bless_cache = global_bless_cache 75 | return bless_cache 76 | -------------------------------------------------------------------------------- /bless_client/bless_client_host.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """bless_client 4 | A sample client to invoke the BLESS Host SSH Cert Lambda function and save the signed SSH Certificate. 5 | 6 | Usage: 7 | bless_client_host.py region lambda_function_name hostnames 8 | 9 | region: AWS region where your lambda is deployed. 10 | 11 | lambda_function_name: The AWS Lambda function's alias or ARN to invoke. 12 | 13 | hostnames: Comma-separated list of hostname(s) to include in this host certificate. 14 | 15 | id_rsa.pub to sign: The id_rsa.pub that will be used in the SSH request. This is 16 | enforced in the issued certificate. 17 | 18 | output id_rsa-cert.pub: The file where the certificate should be saved. Per man SSH(1): 19 | "ssh will also try to load certificate information from the filename 20 | obtained by appending -cert.pub to identity filenames" e.g. the . 21 | """ 22 | import json 23 | import os 24 | import stat 25 | import sys 26 | 27 | import boto3 28 | 29 | 30 | def main(argv): 31 | if len(argv) != 5: 32 | print( 33 | 'Usage: bless_client_host.py region lambda_function_name hostnames ' 34 | '') 35 | print(len(argv)) 36 | return -1 37 | 38 | region, lambda_function_name, hostnames, public_key_filename, certificate_filename = argv 39 | 40 | with open(public_key_filename, 'r') as f: 41 | public_key = f.read().strip() 42 | 43 | payload = {'hostnames': hostnames, 'public_key_to_sign': public_key} 44 | 45 | payload_json = json.dumps(payload) 46 | 47 | print('Executing:') 48 | print('payload_json is: \'{}\''.format(payload_json)) 49 | lambda_client = boto3.client('lambda', region_name=region) 50 | response = lambda_client.invoke(FunctionName=lambda_function_name, 51 | InvocationType='RequestResponse', LogType='None', 52 | Payload=payload_json) 53 | print('{}\n'.format(response['ResponseMetadata'])) 54 | 55 | if response['StatusCode'] != 200: 56 | print('Error creating cert.') 57 | return -1 58 | 59 | payload = json.loads(response['Payload'].read()) 60 | 61 | if 'certificate' not in payload: 62 | print(payload) 63 | return -1 64 | 65 | cert = payload['certificate'] 66 | 67 | with os.fdopen(os.open(certificate_filename, os.O_WRONLY | os.O_CREAT, 0o600), 68 | 'w') as cert_file: 69 | cert_file.write(cert) 70 | 71 | # If cert_file already existed with the incorrect permissions, fix them. 72 | file_status = os.stat(certificate_filename) 73 | if 0o600 != (file_status.st_mode & 0o777): 74 | os.chmod(certificate_filename, stat.S_IRUSR | stat.S_IWUSR) 75 | 76 | print('Wrote Certificate to: ' + certificate_filename) 77 | 78 | 79 | if __name__ == '__main__': 80 | main(sys.argv[1:]) 81 | -------------------------------------------------------------------------------- /tests/aws_lambda/only-use-for-unit-tests.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | Proc-Type: 4,ENCRYPTED 3 | DEK-Info: AES-128-CBC,C534195782F2DD74B6218FFF4D3F7576 4 | 5 | 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 54 | -----END RSA PRIVATE KEY----- 55 | -------------------------------------------------------------------------------- /bless/ssh/public_keys/rsa_public_key.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.ssh.public_keys.rsa_public_key 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | import base64 7 | import hashlib 8 | 9 | from bless.ssh.public_keys.ssh_public_key import SSHPublicKey, SSHPublicKeyType 10 | from cryptography.hazmat.backends import default_backend 11 | from cryptography.hazmat.primitives import serialization 12 | from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers 13 | 14 | 15 | def check_small_primes(n): 16 | """ 17 | Returns True if n is divisible by a number in SMALL_PRIMES. 18 | Based on the MPL licensed 19 | https://github.com/letsencrypt/boulder/blob/58e27c0964a62772e7864e8a12e565ef8a975035/core/good_key.go 20 | """ 21 | small_primes = [ 22 | 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 23 | 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 24 | 109, 113, 127, 131, 137, 139, 149, 151, 157, 163, 167, 25 | 173, 179, 181, 191, 193, 197, 199, 211, 223, 227, 229, 26 | 233, 239, 241, 251, 257, 263, 269, 271, 277, 281, 283, 27 | 293, 307, 311, 313, 317, 331, 337, 347, 349, 353, 359, 28 | 367, 373, 379, 383, 389, 397, 401, 409, 419, 421, 431, 29 | 433, 439, 443, 449, 457, 461, 463, 467, 479, 487, 491, 30 | 499, 503, 509, 521, 523, 541, 547, 557, 563, 569, 571, 31 | 577, 587, 593, 599, 601, 607, 613, 617, 619, 631, 641, 32 | 643, 647, 653, 659, 661, 673, 677, 683, 691, 701, 709, 33 | 719, 727, 733, 739, 743, 751 34 | ] 35 | for prime in small_primes: 36 | if (n % prime == 0): 37 | return True 38 | return False 39 | 40 | 41 | class RSAPublicKey(SSHPublicKey): 42 | def __init__(self, ssh_public_key): 43 | """ 44 | Extracts the useful RSA Public Key information from an SSH Public Key file. 45 | :param ssh_public_key: SSH Public Key file contents. (i.e. 'ssh-rsa AAAAB3NzaC1yc2E..'). 46 | """ 47 | super(RSAPublicKey, self).__init__() 48 | 49 | self.type = SSHPublicKeyType.RSA 50 | 51 | split_ssh_public_key = ssh_public_key.split(' ') 52 | split_key_len = len(split_ssh_public_key) 53 | 54 | # is there a key comment at the end? 55 | if split_key_len > 2: 56 | self.key_comment = ' '.join(split_ssh_public_key[2:]) 57 | else: 58 | self.key_comment = '' 59 | 60 | public_key = serialization.load_ssh_public_key(ssh_public_key.encode('ascii'), default_backend()) 61 | ca_pub_numbers = public_key.public_numbers() 62 | if not isinstance(ca_pub_numbers, RSAPublicNumbers): 63 | raise TypeError("Public Key is not the correct type or format") 64 | 65 | self.key_size = public_key.key_size 66 | self.e = ca_pub_numbers.e 67 | self.n = ca_pub_numbers.n 68 | 69 | key_bytes = base64.b64decode(split_ssh_public_key[1]) 70 | fingerprint = hashlib.md5(key_bytes).hexdigest() 71 | 72 | self.fingerprint = 'RSA ' + ':'.join( 73 | fingerprint[i:i + 2] for i in range(0, len(fingerprint), 2)) 74 | 75 | def validate_for_signing(self): 76 | """ 77 | Raises an error if the public key looks weak 78 | """ 79 | if (self.key_size < 2048 80 | or self.e < 65537 81 | or self.n % 2 == 0 82 | or check_small_primes(self.n)): 83 | raise ValueError("Unsafe RSA public key") 84 | -------------------------------------------------------------------------------- /bless_client/bless_client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """bless_client 4 | A sample client to invoke the BLESS Lambda function and save the signed SSH Certificate. 5 | 6 | Usage: 7 | bless_client.py region lambda_function_name bastion_user bastion_user_ip remote_usernames 8 | bastion_ips bastion_command 9 | 10 | region: AWS region where your lambda is deployed. 11 | 12 | lambda_function_name: The AWS Lambda function's alias or ARN to invoke. 13 | 14 | bastion_user: The user on the bastion, who is initiating the SSH request. 15 | 16 | bastion_user_ip: The IP of the user accessing the bastion. 17 | 18 | remote_usernames: Comma-separated list of username(s) or authorized principals on the remote 19 | server that will be used in the SSH request. This is enforced in the issued certificate. 20 | 21 | bastion_ips: The source IP(s) where the SSH connection will be initiated from. 22 | Addresses should be comma-separated and can be individual IPs or CIDR format (nn.nn.nn.nn/nn 23 | or hhhh::hhhh/nn). This is enforced in the issued certificate. 24 | 25 | bastion_command: Text information about the SSH request of the bastion_user. 26 | 27 | id_rsa.pub to sign: The id_rsa.pub that will be used in the SSH request. This is 28 | enforced in the issued certificate. 29 | 30 | output id_rsa-cert.pub: The file where the certificate should be saved. Per man SSH(1): 31 | "ssh will also try to load certificate information from the filename 32 | obtained by appending -cert.pub to identity filenames" e.g. the . 33 | """ 34 | import json 35 | import os 36 | import stat 37 | import sys 38 | 39 | import boto3 40 | 41 | 42 | def main(argv): 43 | if len(argv) < 9 or len(argv) > 10: 44 | print( 45 | 'Usage: bless_client.py region lambda_function_name bastion_user bastion_user_ip ' 46 | 'remote_usernames bastion_ips bastion_command ' 47 | ' [kmsauth token]') 48 | return -1 49 | 50 | region, lambda_function_name, bastion_user, bastion_user_ip, remote_usernames, bastion_ips, \ 51 | bastion_command, public_key_filename, certificate_filename = argv[:9] 52 | 53 | with open(public_key_filename, 'r') as f: 54 | public_key = f.read().strip() 55 | 56 | payload = {'bastion_user': bastion_user, 'bastion_user_ip': bastion_user_ip, 57 | 'remote_usernames': remote_usernames, 'bastion_ips': bastion_ips, 58 | 'command': bastion_command, 'public_key_to_sign': public_key} 59 | 60 | if len(argv) == 10: 61 | payload['kmsauth_token'] = argv[9] 62 | 63 | payload_json = json.dumps(payload) 64 | 65 | print('Executing:') 66 | print('payload_json is: \'{}\''.format(payload_json)) 67 | lambda_client = boto3.client('lambda', region_name=region) 68 | response = lambda_client.invoke(FunctionName=lambda_function_name, 69 | InvocationType='RequestResponse', LogType='None', 70 | Payload=payload_json) 71 | print('{}\n'.format(response['ResponseMetadata'])) 72 | 73 | if response['StatusCode'] != 200: 74 | print('Error creating cert.') 75 | return -1 76 | 77 | payload = json.loads(response['Payload'].read()) 78 | 79 | if 'certificate' not in payload: 80 | print(payload) 81 | return -1 82 | 83 | cert = payload['certificate'] 84 | 85 | with os.fdopen(os.open(certificate_filename, os.O_WRONLY | os.O_CREAT, 0o600), 86 | 'w') as cert_file: 87 | cert_file.write(cert) 88 | 89 | # If cert_file already existed with the incorrect permissions, fix them. 90 | file_status = os.stat(certificate_filename) 91 | if 0o600 != (file_status.st_mode & 0o777): 92 | os.chmod(certificate_filename, stat.S_IRUSR | stat.S_IWUSR) 93 | 94 | print('Wrote Certificate to: ' + certificate_filename) 95 | 96 | 97 | if __name__ == '__main__': 98 | main(sys.argv[1:]) 99 | -------------------------------------------------------------------------------- /tests/ssh/test_ssh_protocol.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import pytest 3 | from bless.ssh.protocol.ssh_protocol import pack_ssh_mpint, _hex_characters_length, \ 4 | pack_ssh_uint32, pack_ssh_uint64, pack_ssh_string 5 | 6 | 7 | def test_strings(): 8 | strings = {'': binascii.unhexlify('00000000'), 'abc': binascii.unhexlify('00000003616263'), 9 | b'1234': binascii.unhexlify('0000000431323334'), '1234': binascii.unhexlify('0000000431323334')} 10 | 11 | for known_input, known_answer in strings.items(): 12 | assert known_answer == pack_ssh_string(known_input) 13 | 14 | 15 | def test_mpint_known_answers(): 16 | # mipint values are from https://www.ietf.org/rfc/rfc4251.txt 17 | mpints = {int(0): binascii.unhexlify('00000000'), 18 | int(0x9a378f9b2e332a7): binascii.unhexlify('0000000809a378f9b2e332a7'), 19 | int(0x80): binascii.unhexlify('000000020080'), int(-0x1234): binascii.unhexlify('00000002edcc'), 20 | int(-0xdeadbeef): binascii.unhexlify('00000005ff21524111')} 21 | for known_input, known_answer in mpints.items(): 22 | assert known_answer == pack_ssh_mpint(known_input) 23 | 24 | 25 | def test_mpints(): 26 | mpints = {int(-1): binascii.unhexlify('00000001ff'), int(1): binascii.unhexlify('0000000101'), 27 | int(127): binascii.unhexlify('000000017f'), int(128): binascii.unhexlify('000000020080'), 28 | int(-128): binascii.unhexlify('0000000180'), int(-129): binascii.unhexlify('00000002ff7f'), 29 | int(255): binascii.unhexlify('0000000200ff'), int(256): binascii.unhexlify('000000020100'), 30 | int(-256): binascii.unhexlify('00000002ff00'), int(-257): binascii.unhexlify('00000002feff')} 31 | for known_input, known_answer in mpints.items(): 32 | assert known_answer == pack_ssh_mpint(known_input) 33 | 34 | 35 | def test_hex_characters_length(): 36 | digits = {0: 0, 1: 2, 64: 2, 127: 2, 128: 4, 16384: 4, 32767: 4, 32768: 6, -1: 2, 37 | int(-0x1234): 4, int(-0xdeadbeef): 10, -128: 2} 38 | for known_input, known_answer in digits.items(): 39 | assert known_answer == _hex_characters_length(known_input) 40 | 41 | 42 | def test_uint32(): 43 | uint32s = {0x00: binascii.unhexlify('00000000'), 0x0a: binascii.unhexlify('0000000a'), 44 | 0xab: binascii.unhexlify('000000ab'), 0xabcd: binascii.unhexlify('0000abcd'), 45 | 0xabcdef: binascii.unhexlify('00abcdef'), 0xffffffff: binascii.unhexlify('ffffffff'), 46 | 0xf0f0f0f0: binascii.unhexlify('f0f0f0f0'), 0x0f0f0f0f: binascii.unhexlify('0f0f0f0f')} 47 | 48 | for known_input, known_answer in uint32s.items(): 49 | assert known_answer == pack_ssh_uint32(known_input) 50 | 51 | 52 | def test_uint64(): 53 | uint64s = {0x00: binascii.unhexlify('0000000000000000'), 0x0a: binascii.unhexlify('000000000000000a'), 54 | 0xab: binascii.unhexlify('00000000000000ab'), 0xabcd: binascii.unhexlify('000000000000abcd'), 55 | 0xabcdef: binascii.unhexlify('0000000000abcdef'), 56 | 0xffffffff: binascii.unhexlify('00000000ffffffff'), 57 | 0xf0f0f0f0: binascii.unhexlify('00000000f0f0f0f0'), 58 | 0x0f0f0f0f: binascii.unhexlify('000000000f0f0f0f'), 59 | 0xf0f0f0f000000000: binascii.unhexlify('f0f0f0f000000000'), 60 | 0x0f0f0f0f00000000: binascii.unhexlify('0f0f0f0f00000000'), 61 | 0xffffffffffffffff: binascii.unhexlify('ffffffffffffffff')} 62 | 63 | for known_input, known_answer in uint64s.items(): 64 | assert known_answer == pack_ssh_uint64(known_input) 65 | 66 | 67 | def test_floats(): 68 | with pytest.raises(TypeError): 69 | pack_ssh_uint64(4.2) 70 | 71 | with pytest.raises(TypeError): 72 | pack_ssh_uint32(4.2) 73 | 74 | 75 | def test_uint_too_long(): 76 | with pytest.raises(ValueError): 77 | pack_ssh_uint64(0x1FFFFFFFFFFFFFFFF) 78 | 79 | with pytest.raises(ValueError): 80 | pack_ssh_uint32(int(0x1FFFFFFFF)) 81 | 82 | with pytest.raises(ValueError): 83 | pack_ssh_uint32(int(0x1FFFFFFFF)) 84 | -------------------------------------------------------------------------------- /bless/config/bless_deploy_example.cfg: -------------------------------------------------------------------------------- 1 | # This section and its options are optional 2 | [Bless Options] 3 | # Number of seconds +/- the issued time for the certificate to be valid 4 | certificate_validity_after_seconds = 120 5 | certificate_validity_before_seconds = 120 6 | # Minimum number of bits in the system entropy pool before requiring an additional seeding step 7 | entropy_minimum_bits = 2048 8 | # Number of bytes of random to fetch from KMS to seed /dev/urandom 9 | random_seed_bytes = 256 10 | # Set the logging level 11 | logging_level = INFO 12 | # Comma separated list of the SSH Certificate extensions to include. Not specifying this uses the ssh-keygen defaults: 13 | # certificate_extensions = permit-X11-forwarding,permit-agent-forwarding,permit-port-forwarding,permit-pty,permit-user-rc 14 | # Username validation options are described in bless_request_user.py:USERNAME_VALIDATION_OPTIONS 15 | # Configure how bastion_user names are validated. 16 | # username_validation = useradd 17 | # Configure how remote_usernames names are validated. 18 | # remote_usernames_validation = principal 19 | # Configure a regex of blacklisted remote_usernames that will be rejected for any value of remote_usernames_validation. 20 | # remote_usernames_blacklist = root|admin.* 21 | # Number of seconds +/- the issued time for the server certificates to be valid 22 | # server_certificate_validity_before_seconds = 120 23 | # server_certificate_validity_after_seconds = 31536000 24 | # Configure how server certificate hostnames are validated 25 | # hostname_validation = url 26 | 27 | # These values are all required to be modified for deployment 28 | [Bless CA] 29 | # You must set an encrypted private key password for each AWS Region you deploy into 30 | # for each aws region specify a config option like '{}_password'.format(aws_region) 31 | us-east-1_password = 32 | us-west-2_password = 33 | # Or you can set a default password. Region specific password have precedence over the default 34 | # default_password = 35 | # Specify the file name of your SSH CA's Private Key in PEM format. 36 | ca_private_key_file = 37 | # Or specify the private key directly as a base64 encoded string. 38 | # ca_private_key = 39 | 40 | # This section is optional 41 | [KMS Auth] 42 | # Enable kmsauth, to ensure the certificate's username matches the AWS user 43 | # use_kmsauth = True 44 | 45 | # One or multiple KMS keys, setup for kmsauth (see github.com/lyft/python-kmsauth) 46 | # kmsauth_key_id = arn:aws:kms:us-east-1:000000012345:key/eeff5544-6677-8899-9988-aaaabbbbcccc 47 | 48 | # If using kmsauth, you need to set the kmsauth service name. Users need to set the 'to' 49 | # context to this same service name when they create a kmsauth token. 50 | # kmsauth_serviceid = bless-production 51 | 52 | # By default, kmsauth requires that requested bastion_user must be the same as the requested remote_usernames. If you 53 | # want Bless to sign a certificate for a different remote_usernames (like root, or a shared admin account), you must 54 | # specify those allowed names here. * will allow signing for all remote_usernames 55 | # kmsauth_remote_usernames_allowed = ubuntu,root,ec2-user,stufflikethat 56 | 57 | # If the kmsauth_remote_usernames_allowed option is set, kmsauth will allow certifiates for those usernames 58 | # to be generated by any user who can invoke the lambda function. If you would like to ensure that users have to 59 | # be in a an IAM group pertaining to the remote_username, enable this option. 60 | # kmsauth_validate_remote_usernames_against_iam_groups = False 61 | 62 | # For use with the kmsauth_validate_remote_usernames_against_iam_groups option. By default the required format for 63 | # the group name is "ssh-{}".format(remote_username), but that can be changed here. The groups must have a 64 | # consistent naming scheme and must all contain the remote_username once. For example, ssh-ubuntu. 65 | # kmsauth_iam_group_name_format = ssh-{} 66 | -------------------------------------------------------------------------------- /bless/ssh/protocol/ssh_protocol.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.ssh.protocol.ssh_protocol 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | import binascii 7 | import struct 8 | 9 | 10 | def pack_ssh_mpint(mpint): 11 | """ 12 | Packs multiple precision integers. 13 | See Section 5 of https://www.ietf.org/rfc/rfc4251.txt for more information. 14 | :param mpint: Signed long or int to pack. 15 | :return: An SSH string containing the mpint in two's complement format. 16 | """ 17 | if mpint != 0: 18 | hex_digits = _hex_characters_length(mpint) 19 | format_string = "0{:d}x".format(hex_digits) 20 | 21 | # Take the 2's complement of negative numbers. 22 | # If it was needed, this will result in a leading 0xFF 23 | if mpint < 0: 24 | # hex_digits * 4 = number of bits. 25 | mpint += 1 << (hex_digits * 4) 26 | 27 | # If the results needed an extra byte of padding, this will provide a leading 0x00 28 | hex_mpint = format(mpint, format_string) 29 | bytes = binascii.unhexlify(hex_mpint) 30 | else: 31 | # Per RFC4251 a 0 value mpint results in a null string. 32 | bytes = '' 33 | 34 | ret = pack_ssh_string(bytes) 35 | 36 | return ret 37 | 38 | 39 | def pack_ssh_string(string): 40 | """ 41 | Packs arbitrary length binary strings. 42 | See Section 5 of https://www.ietf.org/rfc/rfc4251.txt for more information. 43 | :param string: String or Unicode string. Unicode is encoded as utf-8. 44 | :return: An SSH String stored as a unint32 representing the length of the input string, 45 | followed by that many bytes. 46 | """ 47 | if isinstance(string, str): 48 | string = string.encode('utf-8') 49 | 50 | str_len = len(string) 51 | 52 | if len(string) > 4294967295: 53 | raise ValueError("String must be less than 2^32 bytes long.") 54 | 55 | return struct.pack('>I{}s'.format(str_len), str_len, string) 56 | 57 | 58 | def pack_ssh_uint64(i): 59 | """ 60 | Packs a 64-bit unsigned integer. 61 | :param i: integer 62 | :return: Eight bytes in the order of decreasing significance (network byte order). 63 | """ 64 | if not isinstance(i, int): 65 | raise TypeError("Must be an int") 66 | elif i.bit_length() > 64: 67 | raise ValueError("Must be a 64bit value.") 68 | 69 | return struct.pack('>Q', i) 70 | 71 | 72 | def pack_ssh_uint32(i): 73 | """ 74 | Packs a 32-bit unsigned integer. 75 | :param i: integer or long. 76 | :return: Four bytes in the order of decreasing significance (network byte order). 77 | """ 78 | if not isinstance(i, int): 79 | raise TypeError("Must be an int") 80 | elif i.bit_length() > 32: 81 | raise ValueError("Must be a 32bit value.") 82 | 83 | return struct.pack('>I', i) 84 | 85 | 86 | def _hex_characters_length(mpint): 87 | """ 88 | Subroutine for pack_ssh_mpint. 89 | :param mpint: Signed long or int to pack. 90 | :return: The number of hex characters needed to represent a multiple precision integer. 91 | """ 92 | if mpint == 0: 93 | return 0 94 | 95 | # how many bytes? 96 | num_bits = mpint.bit_length() 97 | num_bytes = num_bits // 8 98 | 99 | # if there are remaining bits, we need an extra byte 100 | if num_bits % 8: 101 | num_bytes += 1 102 | 103 | # What is the highest bit in the highest byte? 104 | shift = (num_bytes * 8) - 1 105 | mask = 1 << shift 106 | 107 | if mpint > 0: 108 | if mpint & mask: 109 | # if the mpint is positive, and the MSB of the highest byte is set, 110 | # pack_ssh_mpint will need to pad with a leading 0x00 111 | num_bytes += 1 112 | else: 113 | if not mpint & mask: 114 | # if the mpint is negative, and the MSB of the highest byte is not set, 115 | # pack_ssh_mpint will need pad with a leading 0xFF 116 | num_bytes += 1 117 | 118 | return num_bytes * 2 119 | -------------------------------------------------------------------------------- /bless/aws_lambda/bless_lambda_host.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.aws_lambda.bless_lambda_host 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | import time 7 | 8 | from bless.aws_lambda.bless_lambda_common import success_response, error_response, set_logger, check_entropy, \ 9 | setup_lambda_cache 10 | from bless.config.bless_config import BLESS_OPTIONS_SECTION, SERVER_CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION, \ 11 | SERVER_CERTIFICATE_VALIDITY_AFTER_SEC_OPTION, HOSTNAME_VALIDATION_OPTION 12 | from bless.request.bless_request_host import BlessHostSchema 13 | from bless.ssh.certificate_authorities.ssh_certificate_authority_factory import get_ssh_certificate_authority 14 | from bless.ssh.certificates.ssh_certificate_builder import SSHCertificateType 15 | from bless.ssh.certificates.ssh_certificate_builder_factory import get_ssh_certificate_builder 16 | from marshmallow import ValidationError 17 | 18 | 19 | def lambda_handler_host( 20 | event, context=None, ca_private_key_password=None, 21 | entropy_check=True, 22 | config_file=None): 23 | """ 24 | This is the function that will be called when the lambda function starts. 25 | :param event: Dictionary of the json request. 26 | :param context: AWS LambdaContext Object 27 | http://docs.aws.amazon.com/lambda/latest/dg/python-context-object.html 28 | :param ca_private_key_password: For local testing, if the password is provided, skip the KMS 29 | decrypt. 30 | :param entropy_check: For local testing, if set to false, it will skip checking entropy and 31 | won't try to fetch additional random from KMS. 32 | :param config_file: The config file to load the SSH CA private key from, and additional settings. 33 | :return: the SSH Certificate that can be written to id_rsa-cert.pub or similar file. 34 | """ 35 | bless_cache = setup_lambda_cache(ca_private_key_password, config_file) 36 | 37 | # Load the deployment config values 38 | config = bless_cache.config 39 | 40 | logger = set_logger(config) 41 | 42 | certificate_validity_before_seconds = config.getint(BLESS_OPTIONS_SECTION, 43 | SERVER_CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION) 44 | certificate_validity_after_seconds = config.getint(BLESS_OPTIONS_SECTION, 45 | SERVER_CERTIFICATE_VALIDITY_AFTER_SEC_OPTION) 46 | 47 | ca_private_key = config.getprivatekey() 48 | 49 | # Process cert request 50 | schema = BlessHostSchema(strict=True) 51 | schema.context[HOSTNAME_VALIDATION_OPTION] = config.get(BLESS_OPTIONS_SECTION, HOSTNAME_VALIDATION_OPTION) 52 | 53 | try: 54 | request = schema.load(event).data 55 | except ValidationError as e: 56 | return error_response('InputValidationError', str(e)) 57 | 58 | # todo: You'll want to bring your own hostnames validation. 59 | logger.info('Bless lambda invoked by [public_key: {}] for hostnames[{}]'.format(request.public_key_to_sign, 60 | request.hostnames)) 61 | 62 | # Make sure we have the ca private key password 63 | if bless_cache.ca_private_key_password is None: 64 | return error_response('ClientError', bless_cache.ca_private_key_password_error) 65 | else: 66 | ca_private_key_password = bless_cache.ca_private_key_password 67 | 68 | # if running as a Lambda, we can check the entropy pool and seed it with KMS if desired 69 | if entropy_check: 70 | check_entropy(config, logger) 71 | 72 | # cert values determined only by lambda and its configs 73 | current_time = int(time.time()) 74 | valid_before = current_time + certificate_validity_after_seconds 75 | valid_after = current_time - certificate_validity_before_seconds 76 | 77 | # Build the cert 78 | ca = get_ssh_certificate_authority(ca_private_key, ca_private_key_password) 79 | cert_builder = get_ssh_certificate_builder(ca, SSHCertificateType.HOST, 80 | request.public_key_to_sign) 81 | 82 | for hostname in request.hostnames.split(','): 83 | cert_builder.add_valid_principal(hostname) 84 | 85 | cert_builder.set_valid_before(valid_before) 86 | cert_builder.set_valid_after(valid_after) 87 | 88 | # cert_builder is needed to obtain the SSH public key's fingerprint 89 | key_id = 'request[{}] ssh_key[{}] ca[{}] valid_to[{}]'.format( 90 | context.aws_request_id, cert_builder.ssh_public_key.fingerprint, context.invoked_function_arn, 91 | time.strftime("%Y/%m/%d %H:%M:%S", time.gmtime(valid_before)) 92 | ) 93 | 94 | cert_builder.set_key_id(key_id) 95 | cert = cert_builder.get_cert_file() 96 | 97 | logger.info( 98 | 'Issued a server cert to hostnames[{}] with key_id[{}] and ' 99 | 'valid_from[{}])'.format( 100 | request.hostnames, key_id, 101 | time.strftime("%Y/%m/%d %H:%M:%S", time.gmtime(valid_after)))) 102 | return success_response(cert) 103 | -------------------------------------------------------------------------------- /tests/request/test_bless_request_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from bless.config.bless_config import USERNAME_VALIDATION_OPTION, REMOTE_USERNAMES_VALIDATION_OPTION, \ 3 | REMOTE_USERNAMES_BLACKLIST_OPTION 4 | from bless.request.bless_request_user import validate_ips, validate_user, USERNAME_VALIDATION_OPTIONS, BlessUserSchema 5 | from marshmallow import ValidationError 6 | 7 | 8 | def test_validate_ips(): 9 | validate_ips('127.0.0.1') 10 | with pytest.raises(ValidationError): 11 | validate_ips('256.0.0.0') 12 | validate_ips('127.0.0.1,172.1.1.1') 13 | with pytest.raises(ValidationError): 14 | validate_ips('256.0.0.0,172.1.1.1') 15 | 16 | 17 | def test_validate_ips_cidr(): 18 | validate_ips('10.0.0.0/8,172.1.1.1') 19 | with pytest.raises(ValidationError): 20 | validate_ips('10.10.10.10/8') 21 | 22 | 23 | def test_validate_user_too_long(): 24 | with pytest.raises(ValidationError) as e: 25 | validate_user('a33characterusernameyoumustbenuts', USERNAME_VALIDATION_OPTIONS.useradd) 26 | assert str(e.value) == 'Username is too long.' 27 | 28 | 29 | @pytest.mark.parametrize("test_input", [ 30 | ('user#invalid'), 31 | ('$userinvalid'), 32 | ('userinvali$d'), 33 | ('userin&valid'), 34 | (' userinvalid') 35 | ]) 36 | def test_validate_user_contains_junk(test_input): 37 | with pytest.raises(ValidationError) as e: 38 | validate_user(test_input, USERNAME_VALIDATION_OPTIONS.useradd) 39 | assert str(e.value) == 'Username contains invalid characters.' 40 | 41 | 42 | @pytest.mark.parametrize("test_input", [ 43 | ('uservalid'), 44 | ('a32characterusernameyoumustok$'), 45 | ('_uservalid$'), 46 | ('abc123_-valid') 47 | ]) 48 | def test_validate_user(test_input): 49 | validate_user(test_input, USERNAME_VALIDATION_OPTIONS.useradd) 50 | 51 | 52 | def test_validate_user_debian_too_long(): 53 | with pytest.raises(ValidationError) as e: 54 | validate_user('a33characterusernameyoumustbenuts', USERNAME_VALIDATION_OPTIONS.debian) 55 | assert str(e.value) == 'Username is too long.' 56 | 57 | 58 | @pytest.mark.parametrize("test_input", [ 59 | ('~userinvalid'), 60 | ('-userinvalid'), 61 | ('+userinvalid'), 62 | ('user:invalid'), 63 | ('user,invalid'), 64 | ('user invalid'), 65 | ('user\tinvalid'), 66 | ('user\ninvalid') 67 | ]) 68 | def test_validate_user_debian_invalid(test_input): 69 | with pytest.raises(ValidationError) as e: 70 | validate_user(test_input, USERNAME_VALIDATION_OPTIONS.debian) 71 | assert str(e.value) == 'Username contains invalid characters.' 72 | 73 | 74 | @pytest.mark.parametrize("test_input", [ 75 | ('root'), 76 | ("admin"), 77 | ("administrator"), 78 | ('balrog'), 79 | ("teal'c") 80 | ]) 81 | def test_validate_user_blacklist(test_input): 82 | with pytest.raises(ValidationError) as e: 83 | validate_user(test_input, USERNAME_VALIDATION_OPTIONS.principal, 'root|admin.*|balrog|.+\'.*') 84 | assert str(e.value) == 'Username contains invalid characters.' 85 | 86 | 87 | @pytest.mark.parametrize("test_input", [ 88 | ('uservalid'), 89 | ('a32characterusernameyoumustok$'), 90 | ('_uservalid$'), 91 | ('abc123_-valid'), 92 | ('user~valid'), 93 | ('user-valid'), 94 | ('user+valid'), 95 | ]) 96 | def test_validate_user_debian(test_input): 97 | validate_user(test_input, USERNAME_VALIDATION_OPTIONS.debian) 98 | 99 | 100 | @pytest.mark.parametrize("test_input", [ 101 | ('uservalid'), 102 | ('a32characterusernameyoumustok$'), 103 | ('!"$%&\'()*+-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~') 104 | ]) 105 | def test_validate_user_principal(test_input): 106 | validate_user(test_input, USERNAME_VALIDATION_OPTIONS.principal) 107 | 108 | 109 | @pytest.mark.parametrize("test_input", [ 110 | ('a33characterusernameyoumustbenuts@example.com'), 111 | ('a@example.com'), 112 | ('a+b@example.com') 113 | ]) 114 | def test_validate_user_email(test_input): 115 | validate_user(test_input, USERNAME_VALIDATION_OPTIONS.email) 116 | 117 | 118 | @pytest.mark.parametrize("test_input", [ 119 | ('a33characterusernameyoumustbenuts@ex@mple.com'), 120 | ('a@example'), 121 | ]) 122 | def test_invalid_user_email(test_input): 123 | with pytest.raises(ValidationError) as e: 124 | validate_user(test_input, USERNAME_VALIDATION_OPTIONS.email) 125 | assert str(e.value) == 'Invalid email address.' 126 | 127 | 128 | @pytest.mark.parametrize("test_input", [ 129 | ('a33characterusernameyoumustbenuts'), 130 | ('~:, \n\t@'), 131 | ('uservalid,!"$%&\'()*+-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~,'), 132 | ]) 133 | def test_validate_user_disabled(test_input): 134 | validate_user(test_input, USERNAME_VALIDATION_OPTIONS.disabled) 135 | 136 | 137 | @pytest.mark.parametrize("test_input", [ 138 | ('uservalid'), 139 | ('uservalid,uservalid2'), 140 | ('uservalid,!"$%&\'()*+-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~,' 141 | 'uservalid2') 142 | ]) 143 | def test_validate_multiple_principals(test_input): 144 | BlessUserSchema().validate_remote_usernames(test_input) 145 | 146 | schema = BlessUserSchema() 147 | schema.context[USERNAME_VALIDATION_OPTION] = USERNAME_VALIDATION_OPTIONS.principal.name 148 | schema.context[REMOTE_USERNAMES_VALIDATION_OPTION] = USERNAME_VALIDATION_OPTIONS.principal.name 149 | schema.context[REMOTE_USERNAMES_BLACKLIST_OPTION] = 'balrog' 150 | schema.validate_remote_usernames(test_input) 151 | 152 | 153 | @pytest.mark.parametrize("test_input", [ 154 | ('user invalid'), 155 | ('uservalid,us#erinvalid2'), 156 | ('uservalid,,uservalid2'), 157 | (' uservalid'), 158 | ('user\ninvalid'), 159 | ('~:, \n\t@') 160 | ]) 161 | def test_invalid_multiple_principals(test_input): 162 | with pytest.raises(ValidationError) as e: 163 | BlessUserSchema().validate_remote_usernames(test_input) 164 | assert str(e.value) == 'Principal contains invalid characters.' 165 | 166 | 167 | def test_invalid_user_with_default_context_of_useradd(): 168 | with pytest.raises(ValidationError) as e: 169 | BlessUserSchema().validate_bastion_user('user#invalid') 170 | assert str(e.value) == 'Username contains invalid characters.' 171 | 172 | 173 | def test_invalid_call_of_validate_user(): 174 | with pytest.raises(ValidationError) as e: 175 | validate_user('test', None) 176 | assert str(e.value) == 'Invalid username validator.' 177 | -------------------------------------------------------------------------------- /bless/request/bless_request_user.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.request.bless_request_user 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | import re 7 | from enum import Enum 8 | 9 | import ipaddress 10 | from bless.config.bless_config import USERNAME_VALIDATION_OPTION, REMOTE_USERNAMES_VALIDATION_OPTION, \ 11 | USERNAME_VALIDATION_DEFAULT, REMOTE_USERNAMES_VALIDATION_DEFAULT, REMOTE_USERNAMES_BLACKLIST_OPTION, \ 12 | REMOTE_USERNAMES_BLACKLIST_DEFAULT 13 | from bless.request.bless_request_common import validate_ssh_public_key 14 | from marshmallow import Schema, fields, post_load, ValidationError, validates_schema 15 | from marshmallow import validates 16 | from marshmallow.validate import Email 17 | 18 | # man 8 useradd 19 | USERNAME_PATTERN = re.compile(r'[a-z_][a-z0-9_-]*[$]?\Z') 20 | 21 | # debian 22 | # On Debian, the only constraints are that usernames must neither start 23 | # with a dash ('-') nor plus ('+') nor tilde ('~') nor contain a colon 24 | # (':'), a comma (','), or a whitespace (space: ' ', end of line: '\n', 25 | # tabulation: '\t', etc.). Note that using a slash ('/') may break the 26 | # default algorithm for the definition of the user's home directory. 27 | USERNAME_PATTERN_DEBIAN = re.compile(r'\A[^-+~][^:,\s]*\Z') 28 | 29 | # It appears that most printable ascii is valid, excluding whitespace, #, and commas. 30 | # There doesn't seem to be any practical size limits of an SSH Certificate Principal (> 4096B allowed). 31 | PRINCIPAL_PATTERN = re.compile(r'[\d\w!"$%&\'()*+\-./:;<=>?@\[\\\]\^`{|}~]+\Z') 32 | 33 | USERNAME_VALIDATION_OPTIONS = Enum('UserNameValidationOptions', 34 | 'useradd ' # Allowable usernames per 'man 8 useradd' 35 | 'debian ' # Allowable usernames on debian systems. 36 | 'email ' # username is a valid email address. 37 | 'principal ' # SSH Certificate Principal. See 'man 5 sshd_config'. 38 | 'disabled') # no additional validation of the string. 39 | 40 | 41 | def validate_ips(ips): 42 | try: 43 | for ip in ips.split(','): 44 | ipaddress.ip_network(ip, strict=True) 45 | except ValueError: 46 | raise ValidationError('Invalid IP address.') 47 | 48 | 49 | def validate_user(user, username_validation, username_blacklist=None): 50 | if username_blacklist: 51 | if re.match(username_blacklist, user) is not None: 52 | raise ValidationError('Username contains invalid characters.') 53 | 54 | if username_validation == USERNAME_VALIDATION_OPTIONS.disabled: 55 | return 56 | elif username_validation == USERNAME_VALIDATION_OPTIONS.email: 57 | Email('Invalid email address.').__call__(user) 58 | elif username_validation == USERNAME_VALIDATION_OPTIONS.principal: 59 | _validate_principal(user) 60 | elif len(user) > 32: 61 | raise ValidationError('Username is too long.') 62 | elif username_validation == USERNAME_VALIDATION_OPTIONS.useradd: 63 | _validate_user_useradd(user) 64 | elif username_validation == USERNAME_VALIDATION_OPTIONS.debian: 65 | _validate_user_debian(user) 66 | else: 67 | raise ValidationError('Invalid username validator.') 68 | 69 | 70 | def _validate_user_useradd(user): 71 | if USERNAME_PATTERN.match(user) is None: 72 | raise ValidationError('Username contains invalid characters.') 73 | 74 | 75 | def _validate_user_debian(user): 76 | if USERNAME_PATTERN_DEBIAN.match(user) is None: 77 | raise ValidationError('Username contains invalid characters.') 78 | 79 | 80 | def _validate_principal(principal): 81 | if PRINCIPAL_PATTERN.match(principal) is None: 82 | raise ValidationError('Principal contains invalid characters.') 83 | 84 | 85 | class BlessUserSchema(Schema): 86 | bastion_ips = fields.Str(validate=validate_ips, required=True) 87 | bastion_user = fields.Str(required=True) 88 | bastion_user_ip = fields.Str(validate=validate_ips, required=True) 89 | command = fields.Str(required=True) 90 | public_key_to_sign = fields.Str(validate=validate_ssh_public_key, required=True) 91 | remote_usernames = fields.Str(required=True) 92 | kmsauth_token = fields.Str(required=False) 93 | 94 | @validates_schema(pass_original=True) 95 | def check_unknown_fields(self, data, original_data): 96 | unknown = set(original_data) - set(self.fields) 97 | if unknown: 98 | raise ValidationError('Unknown field', unknown) 99 | 100 | @post_load 101 | def make_bless_request(self, data): 102 | return BlessUserRequest(**data) 103 | 104 | @validates('bastion_user') 105 | def validate_bastion_user(self, user): 106 | if USERNAME_VALIDATION_OPTION in self.context: 107 | username_validation = USERNAME_VALIDATION_OPTIONS[self.context[USERNAME_VALIDATION_OPTION]] 108 | else: 109 | username_validation = USERNAME_VALIDATION_OPTIONS[USERNAME_VALIDATION_DEFAULT] 110 | validate_user(user, username_validation) 111 | 112 | @validates('remote_usernames') 113 | def validate_remote_usernames(self, remote_usernames): 114 | if REMOTE_USERNAMES_VALIDATION_OPTION in self.context: 115 | username_validation = USERNAME_VALIDATION_OPTIONS[self.context[REMOTE_USERNAMES_VALIDATION_OPTION]] 116 | else: 117 | username_validation = USERNAME_VALIDATION_OPTIONS[REMOTE_USERNAMES_VALIDATION_DEFAULT] 118 | if REMOTE_USERNAMES_BLACKLIST_OPTION in self.context: 119 | username_blacklist = self.context[REMOTE_USERNAMES_BLACKLIST_OPTION] 120 | else: 121 | username_blacklist = REMOTE_USERNAMES_BLACKLIST_DEFAULT 122 | for remote_username in remote_usernames.split(','): 123 | validate_user(remote_username, username_validation, username_blacklist) 124 | 125 | 126 | class BlessUserRequest: 127 | def __init__(self, bastion_ips, bastion_user, bastion_user_ip, command, public_key_to_sign, 128 | remote_usernames, kmsauth_token=None): 129 | """ 130 | A BlessRequest must have the following key value pairs to be valid. 131 | :param bastion_ips: The source IPs where the SSH connection will be initiated from. This is 132 | enforced in the issued certificate. 133 | :param bastion_user: The user on the bastion, who is initiating the SSH request. 134 | :param bastion_user_ip: The IP of the user accessing the bastion. 135 | :param command: Text information about the SSH request of the user. 136 | :param public_key_to_sign: The id_XXX.pub that will be used in the SSH request. This is 137 | enforced in the issued certificate. 138 | :param remote_usernames: Comma-separated list of username(s) or authorized principals on the remote 139 | server that will be used in the SSH request. This is enforced in the issued certificate. 140 | :param kmsauth_token: An optional kms auth token to authenticate the user. 141 | """ 142 | self.bastion_ips = bastion_ips 143 | self.bastion_user = bastion_user 144 | self.bastion_user_ip = bastion_user_ip 145 | self.command = command 146 | self.public_key_to_sign = public_key_to_sign 147 | self.remote_usernames = remote_usernames 148 | self.kmsauth_token = kmsauth_token 149 | 150 | def __eq__(self, other): 151 | return self.__dict__ == other.__dict__ 152 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![alt text](bless_logo.png "BLESS") 2 | # BLESS - Bastion's Lambda Ephemeral SSH Service 3 | [![Build Status](https://travis-ci.org/Netflix/bless.svg?branch=master)](https://travis-ci.org/Netflix/bless) [![Test coverage](https://coveralls.io/repos/github/Netflix/bless/badge.svg?branch=master)](https://coveralls.io/github/Netflix/bless) [![Join the chat at https://gitter.im/Netflix/bless](https://badges.gitter.im/Netflix/bless.svg)](https://gitter.im/Netflix/bless?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![NetflixOSS Lifecycle](https://img.shields.io/osslifecycle/Netflix/bless.svg)]() 4 | 5 | BLESS is an SSH Certificate Authority that runs as an AWS Lambda function and is used to sign SSH 6 | public keys. 7 | 8 | SSH Certificates are an excellent way to authorize users to access a particular SSH host, 9 | as they can be restricted for a single use case, and can be short lived. Instead of managing the 10 | authorized_keys of a host, or controlling who has access to SSH Private Keys, hosts just 11 | need to be configured to trust an SSH CA. 12 | 13 | BLESS should be run as an AWS Lambda in an isolated AWS account. Because BLESS needs access to a 14 | private key which is trusted by your hosts, an isolated AWS account helps restrict who can access 15 | that private key, or modify the BLESS code you are running. 16 | 17 | AWS Lambda functions can use an AWS IAM Policy to limit which IAM Roles can invoke the Lambda 18 | Function. If properly configured, you can restrict which IAM Roles can request SSH Certificates. 19 | For example, your SSH Bastion (aka SSH Jump Host) can run with the only IAM Role with access to 20 | invoke a BLESS Lambda Function configured with the SSH CA key trusted by the instances accessible 21 | to that SSH Bastion. 22 | 23 | ## Getting Started 24 | These instructions are to get BLESS up and running in your local development environment. 25 | ### Installation Instructions 26 | Clone the repo: 27 | 28 | $ git clone git@github.com:Netflix/bless.git 29 | 30 | Cd to the bless repo: 31 | 32 | $ cd bless 33 | 34 | Create a virtualenv if you haven't already: 35 | 36 | $ python3.7 -m venv venv 37 | 38 | Activate the venv: 39 | 40 | $ source venv/bin/activate 41 | 42 | Install package and test dependencies: 43 | 44 | (venv) $ make develop 45 | 46 | Run the tests: 47 | 48 | (venv) $ make test 49 | 50 | 51 | ## Deployment 52 | To deploy an AWS Lambda Function, you need to provide a .zip with the code and all dependencies. 53 | The .zip must contain your lambda code and configurations at the top level of the .zip. The BLESS 54 | Makefile includes a publish target to package up everything into a deploy-able .zip if they are in 55 | the expected locations. You will need to setup your own Python 3.7 lambda to deploy the .zip to. 56 | 57 | Previously the AWS Lambda Handler needed to be set to `bless_lambda.lambda_handler`, and this would generate a user 58 | cert. `bless_lambda.lambda_handler` still works for user certs. `bless_lambda_user.lambda_handler_user` is a handler 59 | that can also be used to issue user certificates. 60 | 61 | A new handler `bless_lambda_host.lambda_handler_host` has been created to allow for the creation of host SSH certs. 62 | 63 | All three handlers exist in the published .zip. 64 | 65 | ### Compiling BLESS Lambda Dependencies 66 | To deploy code as a Lambda Function, you need to package up all of the dependencies. You will need to 67 | compile and include your dependencies before you can publish a working AWS Lambda. 68 | 69 | BLESS uses a docker container running [Amazon Linux 2](https://hub.docker.com/_/amazonlinux) to package everything up: 70 | - Execute ```make lambda-deps``` and this will run a container and save all the dependencies in ./aws_lambda_libs 71 | 72 | ### Protecting the CA Private Key 73 | - Generate a password protected RSA Private Key in the PEM format: 74 | ``` 75 | $ ssh-keygen -t rsa -b 4096 -m PEM -f bless-ca- -C "SSH CA Key" 76 | ``` 77 | - **Note:** OpenSSH Private Key format is not supported. 78 | - Use KMS to encrypt your password. You will need a KMS key per region, and you will need to 79 | encrypt your password for each region. You can use the AWS Console to paste in a simple lambda 80 | function like this: 81 | ``` 82 | import boto3 83 | import base64 84 | import os 85 | 86 | 87 | def lambda_handler(event, context): 88 | region = os.environ['AWS_REGION'] 89 | client = boto3.client('kms', region_name=region) 90 | response = client.encrypt( 91 | KeyId='alias/your_kms_key', 92 | Plaintext='Do not forget to delete the real plain text when done' 93 | ) 94 | 95 | ciphertext = response['CiphertextBlob'] 96 | return base64.b64encode(ciphertext) 97 | ``` 98 | 99 | - Manage your Private Keys .pem files and passwords outside of this repo. 100 | - Update your bless_deploy.cfg with your Private Key's filename and encrypted passwords. 101 | - Provide your desired ./lambda_configs/ca_key_name.pem prior to Publishing a new Lambda .zip 102 | - Set the permissions of ./lambda_configs/ca_key_name.pem to 444. 103 | 104 | You can now provide your private key and/or encrypted private key password via the lambda environment or config file. 105 | In the `[Bless CA]` section, you can set `ca_private_key` instead of the `ca_private_key_file` with a base64 encoded 106 | version of your .pem (e.g. `cat key.pem | base64` ). 107 | 108 | Because every config file option is supported in the environment, you can also just set `bless_ca_default_password` 109 | and/or `bless_ca_ca_private_key`. Due to limits on AWS Lambda environment variables, you'll need to compress RSA 4096 110 | private keys, which you can now do by setting `bless_ca_ca_private_key_compression`. For example, set 111 | `bless_ca_ca_private_key_compression = bz2` and `bless_ca_ca_private_key` to the output of 112 | `cat ca-key.pem | bzip2 | base64`. 113 | 114 | ### BLESS Config File 115 | - Refer to the the [Example BLESS Config File](bless/config/bless_deploy_example.cfg) and its 116 | included documentation. 117 | - Manage your bless_deploy.cfg files outside of this repo. 118 | - Provide your desired ./lambda_configs/bless_deploy.cfg prior to Publishing a new Lambda .zip 119 | - The required [Bless CA] option values must be set for your environment. 120 | - Every option can be changed in the environment. The environment variable name is constructed 121 | as section_name_option_name (all lowercase, spaces replaced with underscores). 122 | 123 | ### Publish Lambda .zip 124 | - Provide your desired ./lambda_configs/ca_key_name.pem prior to Publishing 125 | - Provide your desired [BLESS Config File](bless/config/bless_deploy_example.cfg) at 126 | ./lambda_configs/bless_deploy.cfg prior to Publishing 127 | - Provide the [compiled dependencies](#compiling-bless-lambda-dependencies) at ./aws_lambda_libs 128 | - run: 129 | ``` 130 | (venv) $ make publish 131 | ``` 132 | 133 | - deploy ./publish/bless_lambda.zip to AWS via the AWS Console, 134 | [AWS SDK](http://boto3.readthedocs.io/en/latest/reference/services/lambda.html), or 135 | [S3](https://aws.amazon.com/blogs/compute/new-deployment-options-for-aws-lambda/) 136 | - remember to deploy it to all regions. 137 | 138 | 139 | ### Lambda Requirements 140 | You should deploy this function into its own AWS account to limit who has access to modify the 141 | code, configs, or IAM Policies. An isolated account also limits who has access to the KMS keys 142 | used to protect the SSH CA Key. 143 | 144 | The BLESS Lambda function should run as its own IAM Role and will need access to an AWS KMS Key in 145 | each region where the function is deployed. The BLESS IAMRole will also need permissions to obtain 146 | random from kms (kms:GenerateRandom) and permissions for logging to CloudWatch Logs 147 | (logs:CreateLogGroup,logs:CreateLogStream,logs:PutLogEvents). 148 | 149 | ## Using BLESS 150 | After you have [deployed BLESS](#deployment) you can run the sample [BLESS Client](bless_client/bless_client.py) 151 | from a system with access to the required [AWS Credentials](http://boto3.readthedocs.io/en/latest/guide/configuration.html). 152 | This client is really just a proof of concept to validate that you have a functional lambda being called with valid 153 | IAM credentials. 154 | 155 | (venv) $ ./bless_client.py region lambda_function_name bastion_user bastion_user_ip remote_usernames bastion_source_ip bastion_command 156 | 157 | 158 | ## Verifying Certificates 159 | You can inspect the contents of a certificate with ssh-keygen directly: 160 | 161 | $ ssh-keygen -L -f your-cert.pub 162 | 163 | ## Enabling BLESS Certificates On Servers 164 | Add the following line to `/etc/ssh/sshd_config`: 165 | 166 | TrustedUserCAKeys /etc/ssh/cas.pub 167 | 168 | Add a new file, owned by and only writable by root, at `/etc/ssh/cas.pub` with the contents: 169 | 170 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQ… #id_rsa.pub of an SSH CA 171 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQ… #id_rsa.pub of an offline SSH CA 172 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQ… #id_rsa.pub of an offline SSH CA 2 173 | 174 | To simplify SSH CA Key rotation you should provision multiple CA Keys, and leave them offline until 175 | you are ready to rotate them. 176 | 177 | Additional information about the TrustedUserCAKeys file is [here](https://www.freebsd.org/cgi/man.cgi?sshd_config(5)) 178 | 179 | ## Project resources 180 | - Source code 181 | - Issue tracker 182 | -------------------------------------------------------------------------------- /tests/ssh/test_ssh_certificate_rsa.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | import pytest 4 | from cryptography.hazmat.primitives.serialization.ssh import _ssh_read_next_string 5 | 6 | from bless.ssh.certificate_authorities.rsa_certificate_authority import RSACertificateAuthority 7 | from bless.ssh.certificates.rsa_certificate_builder import RSACertificateBuilder 8 | from bless.ssh.certificates.ed25519_certificate_builder import ED25519CertificateBuilder 9 | from bless.ssh.certificates.ssh_certificate_builder import SSHCertificateType 10 | from bless.ssh.public_keys.rsa_public_key import RSAPublicKey 11 | from bless.ssh.public_keys.ed25519_public_key import ED25519PublicKey 12 | from tests.ssh.vectors import RSA_CA_PRIVATE_KEY, RSA_CA_PRIVATE_KEY_PASSWORD, \ 13 | EXAMPLE_RSA_PUBLIC_KEY, EXAMPLE_RSA_PUBLIC_KEY_NO_DESCRIPTION, RSA_USER_CERT_MINIMAL, \ 14 | RSA_USER_CERT_DEFAULTS, RSA_USER_CERT_DEFAULTS_NO_PUBLIC_KEY_COMMENT, \ 15 | RSA_USER_CERT_MANY_PRINCIPALS, RSA_HOST_CERT_MANY_PRINCIPALS, \ 16 | RSA_USER_CERT_FORCE_COMMAND_AND_SOURCE_ADDRESS, \ 17 | RSA_USER_CERT_FORCE_COMMAND_AND_SOURCE_ADDRESS_KEY_ID, RSA_HOST_CERT_MANY_PRINCIPALS_KEY_ID, \ 18 | RSA_USER_CERT_MANY_PRINCIPALS_KEY_ID, RSA_USER_CERT_DEFAULTS_NO_PUBLIC_KEY_COMMENT_KEY_ID, \ 19 | RSA_USER_CERT_DEFAULTS_KEY_ID, SSH_CERT_DEFAULT_EXTENSIONS, SSH_CERT_CUSTOM_EXTENSIONS, \ 20 | EXAMPLE_ED25519_PUBLIC_KEY, ED25519_USER_CERT_DEFAULTS, ED25519_USER_CERT_DEFAULTS_KEY_ID 21 | 22 | USER1 = 'user1' 23 | 24 | 25 | def get_basic_public_key(public_key): 26 | return RSAPublicKey(public_key) 27 | 28 | 29 | def get_basic_rsa_ca(): 30 | return RSACertificateAuthority(RSA_CA_PRIVATE_KEY, RSA_CA_PRIVATE_KEY_PASSWORD) 31 | 32 | 33 | def get_basic_cert_builder_rsa(cert_type=SSHCertificateType.USER, 34 | public_key=EXAMPLE_RSA_PUBLIC_KEY): 35 | ca = get_basic_rsa_ca() 36 | pub_key = get_basic_public_key(public_key) 37 | return RSACertificateBuilder(ca, cert_type, pub_key) 38 | 39 | 40 | def extract_nonce_from_cert(cert_file): 41 | cert = cert_file.split(' ')[1] 42 | cert_type, cert_remainder = _ssh_read_next_string(base64.b64decode(cert)) 43 | nonce, cert_remainder = _ssh_read_next_string(cert_remainder) 44 | return nonce 45 | 46 | 47 | def test_valid_principals(): 48 | USER2 = 'second_user' 49 | 50 | cert = get_basic_cert_builder_rsa() 51 | 52 | # No principals by default 53 | assert list() == cert.valid_principals 54 | 55 | # Two principals 56 | cert.add_valid_principal(USER1) 57 | cert.add_valid_principal(USER2) 58 | assert [USER1, USER2] == cert.valid_principals 59 | 60 | # Adding a null principal should throw a ValueError 61 | with pytest.raises(ValueError): 62 | cert.add_valid_principal('') 63 | 64 | # Adding same principal twice should not change the list, and throw a ValueError 65 | with pytest.raises(ValueError): 66 | cert.add_valid_principal(USER1) 67 | assert [USER1, USER2] == cert.valid_principals 68 | 69 | 70 | def test_serialize_no_principals(): 71 | cert = get_basic_cert_builder_rsa() 72 | 73 | assert list() == cert.valid_principals 74 | assert b'' == cert._serialize_valid_principals() 75 | 76 | 77 | def test_serialize_one_principal(): 78 | expected = base64.b64decode('AAAABXVzZXIx') 79 | 80 | cert = get_basic_cert_builder_rsa() 81 | cert.add_valid_principal(USER1) 82 | 83 | assert expected == cert._serialize_valid_principals() 84 | 85 | 86 | def test_serialize_multiple_principals(): 87 | users = 'user1,user2,other_user1,other_user2' 88 | expected = base64.b64decode('AAAABXVzZXIxAAAABXVzZXIyAAAAC290aGVyX3VzZXIxAAAAC290aGVyX3VzZXIy') 89 | 90 | cert = get_basic_cert_builder_rsa() 91 | for user in users.split(','): 92 | cert.add_valid_principal(user) 93 | 94 | assert expected == cert._serialize_valid_principals() 95 | 96 | 97 | def test_no_extensions(): 98 | cert_builder = get_basic_cert_builder_rsa() 99 | assert cert_builder.extensions is None 100 | 101 | cert_builder.clear_extensions() 102 | assert b'' == cert_builder._serialize_extensions() 103 | 104 | 105 | def test_bogus_cert_validity_range(): 106 | cert_builder = get_basic_cert_builder_rsa() 107 | with pytest.raises(ValueError): 108 | cert_builder.set_valid_before(99) 109 | cert_builder.set_valid_after(100) 110 | cert_builder._validate_cert_properties() 111 | 112 | 113 | def test_bogus_critical_options(): 114 | cert_builder = get_basic_cert_builder_rsa() 115 | with pytest.raises(ValueError): 116 | cert_builder.set_critical_option_force_command('') 117 | 118 | with pytest.raises(ValueError): 119 | cert_builder.set_critical_option_source_addresses('') 120 | 121 | 122 | def test_rsa_user_cert_minimal(): 123 | cert_builder = get_basic_cert_builder_rsa() 124 | cert_builder.set_nonce(nonce=extract_nonce_from_cert(RSA_USER_CERT_MINIMAL)) 125 | cert_builder.clear_extensions() 126 | cert = cert_builder.get_cert_file() 127 | assert RSA_USER_CERT_MINIMAL == cert 128 | 129 | 130 | def test_default_extensions(): 131 | cert_builder = get_basic_cert_builder_rsa() 132 | cert_builder.set_extensions_to_default() 133 | assert SSH_CERT_DEFAULT_EXTENSIONS == cert_builder._serialize_extensions() 134 | 135 | 136 | def test_add_extensions(): 137 | extensions = {'permit-port-forwarding', 138 | 'permit-pty', 'permit-user-rc'} 139 | 140 | cert_builder = get_basic_cert_builder_rsa() 141 | 142 | for extension in extensions: 143 | cert_builder.add_extension(extension) 144 | 145 | assert SSH_CERT_CUSTOM_EXTENSIONS == cert_builder._serialize_extensions() 146 | 147 | 148 | def test_rsa_user_cert_defaults(): 149 | cert_builder = get_basic_cert_builder_rsa() 150 | cert_builder.set_nonce(nonce=extract_nonce_from_cert(RSA_USER_CERT_DEFAULTS)) 151 | cert_builder.set_key_id(RSA_USER_CERT_DEFAULTS_KEY_ID) 152 | 153 | cert = cert_builder.get_cert_file() 154 | assert RSA_USER_CERT_DEFAULTS == cert 155 | 156 | 157 | def test_rsa_user_cert_duplicate_signs(): 158 | cert_builder = get_basic_cert_builder_rsa() 159 | cert_builder.set_nonce(nonce=extract_nonce_from_cert(RSA_USER_CERT_DEFAULTS)) 160 | cert_builder.set_key_id(RSA_USER_CERT_DEFAULTS_KEY_ID) 161 | cert_builder._sign_cert() 162 | 163 | cert = cert_builder.get_cert_file() 164 | assert RSA_USER_CERT_DEFAULTS == cert 165 | 166 | 167 | def test_rsa_user_cert_defaults_no_public_key_comment(): 168 | cert_builder = get_basic_cert_builder_rsa(public_key=EXAMPLE_RSA_PUBLIC_KEY_NO_DESCRIPTION) 169 | cert_builder.set_nonce( 170 | nonce=extract_nonce_from_cert(RSA_USER_CERT_DEFAULTS_NO_PUBLIC_KEY_COMMENT)) 171 | cert_builder.set_key_id(RSA_USER_CERT_DEFAULTS_NO_PUBLIC_KEY_COMMENT_KEY_ID) 172 | 173 | cert = cert_builder.get_cert_file() 174 | assert RSA_USER_CERT_DEFAULTS_NO_PUBLIC_KEY_COMMENT == cert 175 | 176 | 177 | def test_rsa_user_cert_many_principals(): 178 | cert_builder = get_basic_cert_builder_rsa() 179 | cert_builder.set_nonce(nonce=extract_nonce_from_cert(RSA_USER_CERT_MANY_PRINCIPALS)) 180 | cert_builder.set_key_id(RSA_USER_CERT_MANY_PRINCIPALS_KEY_ID) 181 | 182 | principals = 'user1,user2,other_user1,other_user2' 183 | for principal in principals.split(','): 184 | cert_builder.add_valid_principal(principal) 185 | 186 | cert = cert_builder.get_cert_file() 187 | assert RSA_USER_CERT_MANY_PRINCIPALS == cert 188 | 189 | 190 | def test_rsa_host_cert_many_principals(): 191 | cert_builder = get_basic_cert_builder_rsa(cert_type=SSHCertificateType.HOST) 192 | cert_builder.set_nonce(nonce=extract_nonce_from_cert(RSA_HOST_CERT_MANY_PRINCIPALS)) 193 | cert_builder.set_key_id(RSA_HOST_CERT_MANY_PRINCIPALS_KEY_ID) 194 | 195 | principals = 'host.example.com,192.168.1.1,host2.example.com' 196 | for principal in principals.split(','): 197 | cert_builder.add_valid_principal(principal) 198 | 199 | cert = cert_builder.get_cert_file() 200 | assert RSA_HOST_CERT_MANY_PRINCIPALS == cert 201 | 202 | 203 | def test_rsa_user_cert_critical_opt_source_address(): 204 | cert_builder = get_basic_cert_builder_rsa() 205 | cert_builder.set_nonce( 206 | nonce=extract_nonce_from_cert(RSA_USER_CERT_FORCE_COMMAND_AND_SOURCE_ADDRESS)) 207 | cert_builder.set_key_id(RSA_USER_CERT_FORCE_COMMAND_AND_SOURCE_ADDRESS_KEY_ID) 208 | cert_builder.set_critical_option_force_command('/bin/ls') 209 | cert_builder.set_critical_option_source_addresses('192.168.1.0/24') 210 | 211 | cert = cert_builder.get_cert_file() 212 | 213 | assert RSA_USER_CERT_FORCE_COMMAND_AND_SOURCE_ADDRESS == cert 214 | 215 | 216 | def test_nonce(): 217 | cert_builder = get_basic_cert_builder_rsa() 218 | cert_builder.set_nonce() 219 | 220 | cert_builder2 = get_basic_cert_builder_rsa() 221 | cert_builder2.set_nonce() 222 | 223 | assert cert_builder.nonce != cert_builder2.nonce 224 | 225 | 226 | def test_ed25519_user_cert_defaults(): 227 | ca = get_basic_rsa_ca() 228 | pub_key = ED25519PublicKey(EXAMPLE_ED25519_PUBLIC_KEY) 229 | cert_builder = ED25519CertificateBuilder(ca, SSHCertificateType.USER, pub_key) 230 | cert_builder.set_nonce( 231 | nonce=extract_nonce_from_cert(ED25519_USER_CERT_DEFAULTS)) 232 | cert_builder.set_key_id(ED25519_USER_CERT_DEFAULTS_KEY_ID) 233 | 234 | cert = cert_builder.get_cert_file() 235 | assert ED25519_USER_CERT_DEFAULTS == cert 236 | -------------------------------------------------------------------------------- /bless/aws_lambda/bless_lambda_user.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.aws_lambda.bless_lambda_user 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | import time 7 | 8 | import boto3 9 | from bless.aws_lambda.bless_lambda_common import success_response, error_response, set_logger, check_entropy, \ 10 | setup_lambda_cache 11 | from bless.config.bless_config import BLESS_OPTIONS_SECTION, \ 12 | CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION, \ 13 | CERTIFICATE_VALIDITY_AFTER_SEC_OPTION, \ 14 | USERNAME_VALIDATION_OPTION, \ 15 | KMSAUTH_SECTION, \ 16 | KMSAUTH_USEKMSAUTH_OPTION, \ 17 | KMSAUTH_REMOTE_USERNAMES_ALLOWED_OPTION, \ 18 | VALIDATE_REMOTE_USERNAMES_AGAINST_IAM_GROUPS_OPTION, \ 19 | KMSAUTH_SERVICE_ID_OPTION, \ 20 | TEST_USER_OPTION, \ 21 | CERTIFICATE_EXTENSIONS_OPTION, \ 22 | REMOTE_USERNAMES_VALIDATION_OPTION, \ 23 | IAM_GROUP_NAME_VALIDATION_FORMAT_OPTION, \ 24 | REMOTE_USERNAMES_BLACKLIST_OPTION 25 | from bless.request.bless_request_user import BlessUserSchema 26 | from bless.ssh.certificate_authorities.ssh_certificate_authority_factory import \ 27 | get_ssh_certificate_authority 28 | from bless.ssh.certificates.ssh_certificate_builder import SSHCertificateType 29 | from bless.ssh.certificates.ssh_certificate_builder_factory import get_ssh_certificate_builder 30 | from kmsauth import KMSTokenValidator, TokenValidationError 31 | from marshmallow.exceptions import ValidationError 32 | 33 | 34 | def lambda_handler_user( 35 | event, context=None, ca_private_key_password=None, 36 | entropy_check=True, 37 | config_file=None): 38 | """ 39 | This is the function that will be called when the lambda function starts. 40 | :param event: Dictionary of the json request. 41 | :param context: AWS LambdaContext Object 42 | http://docs.aws.amazon.com/lambda/latest/dg/python-context-object.html 43 | :param ca_private_key_password: For local testing, if the password is provided, skip the KMS 44 | decrypt. 45 | :param entropy_check: For local testing, if set to false, it will skip checking entropy and 46 | won't try to fetch additional random from KMS. 47 | :param config_file: The config file to load the SSH CA private key from, and additional settings. 48 | :return: the SSH Certificate that can be written to id_rsa-cert.pub or similar file. 49 | """ 50 | bless_cache = setup_lambda_cache(ca_private_key_password, config_file) 51 | 52 | # AWS Region determines configs related to KMS 53 | region = bless_cache.region 54 | 55 | # Load the deployment config values 56 | config = bless_cache.config 57 | 58 | logger = set_logger(config) 59 | 60 | certificate_validity_before_seconds = config.getint(BLESS_OPTIONS_SECTION, 61 | CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION) 62 | certificate_validity_after_seconds = config.getint(BLESS_OPTIONS_SECTION, 63 | CERTIFICATE_VALIDITY_AFTER_SEC_OPTION) 64 | ca_private_key = config.getprivatekey() 65 | certificate_extensions = config.get(BLESS_OPTIONS_SECTION, CERTIFICATE_EXTENSIONS_OPTION) 66 | 67 | # Process cert request 68 | schema = BlessUserSchema(strict=True) 69 | schema.context[USERNAME_VALIDATION_OPTION] = config.get(BLESS_OPTIONS_SECTION, USERNAME_VALIDATION_OPTION) 70 | schema.context[REMOTE_USERNAMES_VALIDATION_OPTION] = config.get(BLESS_OPTIONS_SECTION, 71 | REMOTE_USERNAMES_VALIDATION_OPTION) 72 | schema.context[REMOTE_USERNAMES_BLACKLIST_OPTION] = config.get(BLESS_OPTIONS_SECTION, 73 | REMOTE_USERNAMES_BLACKLIST_OPTION) 74 | 75 | try: 76 | request = schema.load(event).data 77 | except ValidationError as e: 78 | return error_response('InputValidationError', str(e)) 79 | 80 | logger.info('Bless lambda invoked by [user: {0}, bastion_ips:{1}, public_key: {2}, kmsauth_token:{3}]'.format( 81 | request.bastion_user, 82 | request.bastion_user_ip, 83 | request.public_key_to_sign, 84 | request.kmsauth_token)) 85 | 86 | # Make sure we have the ca private key password 87 | if bless_cache.ca_private_key_password is None: 88 | return error_response('ClientError', bless_cache.ca_private_key_password_error) 89 | else: 90 | ca_private_key_password = bless_cache.ca_private_key_password 91 | 92 | # if running as a Lambda, we can check the entropy pool and seed it with KMS if desired 93 | if entropy_check: 94 | check_entropy(config, logger) 95 | 96 | # cert values determined only by lambda and its configs 97 | current_time = int(time.time()) 98 | test_user = config.get(BLESS_OPTIONS_SECTION, TEST_USER_OPTION) 99 | if test_user and (request.bastion_user == test_user or request.remote_usernames == test_user): 100 | # This is a test call, the lambda will issue an invalid 101 | # certificate where valid_before < valid_after 102 | valid_before = current_time 103 | valid_after = current_time + 1 104 | bypass_time_validity_check = True 105 | else: 106 | valid_before = current_time + certificate_validity_after_seconds 107 | valid_after = current_time - certificate_validity_before_seconds 108 | bypass_time_validity_check = False 109 | 110 | # Authenticate the user with KMS, if key is setup 111 | if config.getboolean(KMSAUTH_SECTION, KMSAUTH_USEKMSAUTH_OPTION): 112 | if request.kmsauth_token: 113 | # Allow bless to sign the cert for a different remote user than the name of the user who signed it 114 | allowed_remotes = config.get(KMSAUTH_SECTION, KMSAUTH_REMOTE_USERNAMES_ALLOWED_OPTION) 115 | if allowed_remotes: 116 | allowed_users = allowed_remotes.split(',') 117 | requested_remotes = request.remote_usernames.split(',') 118 | if allowed_users != ['*'] and not all([u in allowed_users for u in requested_remotes]): 119 | return error_response('KMSAuthValidationError', 120 | 'unallowed remote_usernames [{}]'.format(request.remote_usernames)) 121 | 122 | # Check if the user is in the required IAM groups 123 | if config.getboolean(KMSAUTH_SECTION, VALIDATE_REMOTE_USERNAMES_AGAINST_IAM_GROUPS_OPTION): 124 | iam = boto3.client('iam') 125 | user_groups = iam.list_groups_for_user(UserName=request.bastion_user) 126 | 127 | group_name_template = config.get(KMSAUTH_SECTION, IAM_GROUP_NAME_VALIDATION_FORMAT_OPTION) 128 | for requested_remote in requested_remotes: 129 | required_group_name = group_name_template.format(requested_remote) 130 | 131 | user_is_in_group = any( 132 | group 133 | for group in user_groups['Groups'] 134 | if group['GroupName'] == required_group_name 135 | ) 136 | 137 | if not user_is_in_group: 138 | return error_response('KMSAuthValidationError', 139 | 'user {} is not in the {} iam group'.format(request.bastion_user, 140 | required_group_name)) 141 | 142 | elif request.remote_usernames != request.bastion_user: 143 | return error_response('KMSAuthValidationError', 144 | 'remote_usernames must be the same as bastion_user') 145 | try: 146 | validator = KMSTokenValidator( 147 | None, 148 | config.getkmsauthkeyids(), 149 | config.get(KMSAUTH_SECTION, KMSAUTH_SERVICE_ID_OPTION), 150 | region 151 | ) 152 | # decrypt_token will raise a TokenValidationError if token doesn't match 153 | validator.decrypt_token( 154 | "2/user/{}".format(request.bastion_user), 155 | request.kmsauth_token 156 | ) 157 | except TokenValidationError as e: 158 | return error_response('KMSAuthValidationError', str(e)) 159 | else: 160 | return error_response('InputValidationError', 'Invalid request, missing kmsauth token') 161 | 162 | # Build the cert 163 | ca = get_ssh_certificate_authority(ca_private_key, ca_private_key_password) 164 | cert_builder = get_ssh_certificate_builder(ca, SSHCertificateType.USER, 165 | request.public_key_to_sign) 166 | for username in request.remote_usernames.split(','): 167 | cert_builder.add_valid_principal(username) 168 | 169 | cert_builder.set_valid_before(valid_before) 170 | cert_builder.set_valid_after(valid_after) 171 | 172 | if certificate_extensions: 173 | for e in certificate_extensions.split(','): 174 | if e: 175 | cert_builder.add_extension(e) 176 | else: 177 | cert_builder.clear_extensions() 178 | 179 | # cert_builder is needed to obtain the SSH public key's fingerprint 180 | key_id = 'request[{}] for[{}] from[{}] command[{}] ssh_key[{}] ca[{}] valid_to[{}]'.format( 181 | context.aws_request_id, request.bastion_user, request.bastion_user_ip, request.command, 182 | cert_builder.ssh_public_key.fingerprint, context.invoked_function_arn, 183 | time.strftime("%Y/%m/%d %H:%M:%S", time.gmtime(valid_before))) 184 | cert_builder.set_critical_option_source_addresses(request.bastion_ips) 185 | cert_builder.set_key_id(key_id) 186 | cert = cert_builder.get_cert_file(bypass_time_validity_check) 187 | 188 | logger.info( 189 | 'Issued a cert to bastion_ips[{}] for remote_usernames[{}] with key_id[{}] and ' 190 | 'valid_from[{}])'.format( 191 | request.bastion_ips, request.remote_usernames, key_id, 192 | time.strftime("%Y/%m/%d %H:%M:%S", time.gmtime(valid_after)))) 193 | return success_response(cert) 194 | -------------------------------------------------------------------------------- /bless/config/bless_config.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.config.bless_config 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | import configparser 7 | import base64 8 | import os 9 | import re 10 | import zlib 11 | import bz2 12 | 13 | BLESS_OPTIONS_SECTION = 'Bless Options' 14 | CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION = 'certificate_validity_before_seconds' 15 | CERTIFICATE_VALIDITY_AFTER_SEC_OPTION = 'certificate_validity_after_seconds' 16 | CERTIFICATE_VALIDITY_SEC_DEFAULT = 60 * 2 17 | SERVER_CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION = 'server_certificate_validity_before_seconds' 18 | SERVER_CERTIFICATE_VALIDITY_BEFORE_SEC_DEFAULT = 120 19 | SERVER_CERTIFICATE_VALIDITY_AFTER_SEC_OPTION = 'server_certificate_validity_after_seconds' 20 | SERVER_CERTIFICATE_VALIDITY_AFTER_SEC_DEFAULT = 31536000 21 | 22 | ENTROPY_MINIMUM_BITS_OPTION = 'entropy_minimum_bits' 23 | ENTROPY_MINIMUM_BITS_DEFAULT = 2048 24 | 25 | RANDOM_SEED_BYTES_OPTION = 'random_seed_bytes' 26 | RANDOM_SEED_BYTES_DEFAULT = 256 27 | 28 | LOGGING_LEVEL_OPTION = 'logging_level' 29 | LOGGING_LEVEL_DEFAULT = 'INFO' 30 | 31 | TEST_USER_OPTION = 'test_user' 32 | TEST_USER_DEFAULT = None 33 | 34 | CERTIFICATE_EXTENSIONS_OPTION = 'certificate_extensions' 35 | # These are the the ssh-keygen default extensions: 36 | CERTIFICATE_EXTENSIONS_DEFAULT = 'permit-X11-forwarding,' \ 37 | 'permit-agent-forwarding,' \ 38 | 'permit-port-forwarding,' \ 39 | 'permit-pty,' \ 40 | 'permit-user-rc' 41 | 42 | HOSTNAME_VALIDATION_OPTION = 'hostname_validation' 43 | HOSTNAME_VALIDATION_DEFAULT = 'url' 44 | 45 | BLESS_CA_SECTION = 'Bless CA' 46 | CA_PRIVATE_KEY_FILE_OPTION = 'ca_private_key_file' 47 | CA_PRIVATE_KEY_OPTION = 'ca_private_key' 48 | CA_PRIVATE_KEY_COMPRESSION_OPTION = 'ca_private_key_compression' 49 | CA_PRIVATE_KEY_COMPRESSION_OPTION_DEFAULT = None 50 | 51 | REGION_PASSWORD_OPTION_SUFFIX = '_password' 52 | 53 | KMSAUTH_SECTION = 'KMS Auth' 54 | KMSAUTH_USEKMSAUTH_OPTION = 'use_kmsauth' 55 | KMSAUTH_USEKMSAUTH_DEFAULT = "False" 56 | 57 | KMSAUTH_KEY_ID_OPTION = 'kmsauth_key_id' 58 | KMSAUTH_KEY_ID_DEFAULT = '' 59 | 60 | KMSAUTH_REMOTE_USERNAMES_ALLOWED_OPTION = 'kmsauth_remote_usernames_allowed' 61 | KMSAUTH_REMOTE_USERNAMES_ALLOWED_OPTION_DEFAULT = None 62 | 63 | KMSAUTH_SERVICE_ID_OPTION = 'kmsauth_serviceid' 64 | KMSAUTH_SERVICE_ID_DEFAULT = None 65 | 66 | USERNAME_VALIDATION_OPTION = 'username_validation' 67 | USERNAME_VALIDATION_DEFAULT = 'useradd' 68 | 69 | REMOTE_USERNAMES_VALIDATION_OPTION = 'remote_usernames_validation' 70 | REMOTE_USERNAMES_VALIDATION_DEFAULT = 'principal' 71 | 72 | VALIDATE_REMOTE_USERNAMES_AGAINST_IAM_GROUPS_OPTION = 'kmsauth_validate_remote_usernames_against_iam_groups' 73 | VALIDATE_REMOTE_USERNAMES_AGAINST_IAM_GROUPS_DEFAULT = "False" 74 | 75 | IAM_GROUP_NAME_VALIDATION_FORMAT_OPTION = 'kmsauth_iam_group_name_format' 76 | IAM_GROUP_NAME_VALIDATION_FORMAT_DEFAULT = 'ssh-{}' 77 | 78 | REMOTE_USERNAMES_BLACKLIST_OPTION = 'remote_usernames_blacklist' 79 | REMOTE_USERNAMES_BLACKLIST_DEFAULT = None 80 | 81 | 82 | class BlessConfig(configparser.RawConfigParser, object): 83 | def __init__(self, aws_region, config_file): 84 | """ 85 | Parses the BLESS config file, and provides some reasonable default values if they are 86 | absent from the config file. 87 | 88 | The [Bless Options] section is entirely optional, and has defaults. 89 | 90 | The [Bless CA] section is required. 91 | :param aws_region: The AWS Region BLESS is deployed to. 92 | :param config_file: Path to the connfig file. 93 | """ 94 | self.aws_region = aws_region 95 | defaults = {CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION: CERTIFICATE_VALIDITY_SEC_DEFAULT, 96 | CERTIFICATE_VALIDITY_AFTER_SEC_OPTION: CERTIFICATE_VALIDITY_SEC_DEFAULT, 97 | ENTROPY_MINIMUM_BITS_OPTION: ENTROPY_MINIMUM_BITS_DEFAULT, 98 | RANDOM_SEED_BYTES_OPTION: RANDOM_SEED_BYTES_DEFAULT, 99 | LOGGING_LEVEL_OPTION: LOGGING_LEVEL_DEFAULT, 100 | TEST_USER_OPTION: TEST_USER_DEFAULT, 101 | KMSAUTH_SERVICE_ID_OPTION: KMSAUTH_SERVICE_ID_DEFAULT, 102 | KMSAUTH_KEY_ID_OPTION: KMSAUTH_KEY_ID_DEFAULT, 103 | KMSAUTH_REMOTE_USERNAMES_ALLOWED_OPTION: KMSAUTH_REMOTE_USERNAMES_ALLOWED_OPTION_DEFAULT, 104 | KMSAUTH_USEKMSAUTH_OPTION: KMSAUTH_USEKMSAUTH_DEFAULT, 105 | CERTIFICATE_EXTENSIONS_OPTION: CERTIFICATE_EXTENSIONS_DEFAULT, 106 | USERNAME_VALIDATION_OPTION: USERNAME_VALIDATION_DEFAULT, 107 | REMOTE_USERNAMES_VALIDATION_OPTION: REMOTE_USERNAMES_VALIDATION_DEFAULT, 108 | VALIDATE_REMOTE_USERNAMES_AGAINST_IAM_GROUPS_OPTION: VALIDATE_REMOTE_USERNAMES_AGAINST_IAM_GROUPS_DEFAULT, 109 | IAM_GROUP_NAME_VALIDATION_FORMAT_OPTION: IAM_GROUP_NAME_VALIDATION_FORMAT_DEFAULT, 110 | REMOTE_USERNAMES_BLACKLIST_OPTION: REMOTE_USERNAMES_BLACKLIST_DEFAULT, 111 | CA_PRIVATE_KEY_COMPRESSION_OPTION: CA_PRIVATE_KEY_COMPRESSION_OPTION_DEFAULT, 112 | SERVER_CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION: SERVER_CERTIFICATE_VALIDITY_BEFORE_SEC_DEFAULT, 113 | SERVER_CERTIFICATE_VALIDITY_AFTER_SEC_OPTION: SERVER_CERTIFICATE_VALIDITY_AFTER_SEC_DEFAULT, 114 | HOSTNAME_VALIDATION_OPTION: HOSTNAME_VALIDATION_DEFAULT 115 | } 116 | configparser.RawConfigParser.__init__(self, defaults=defaults) 117 | self.read(config_file) 118 | 119 | if not self.has_section(BLESS_CA_SECTION): 120 | self.add_section(BLESS_CA_SECTION) 121 | 122 | if not self.has_section(BLESS_OPTIONS_SECTION): 123 | self.add_section(BLESS_OPTIONS_SECTION) 124 | 125 | if not self.has_section(KMSAUTH_SECTION): 126 | self.add_section(KMSAUTH_SECTION) 127 | 128 | if not self.has_option(BLESS_CA_SECTION, self.aws_region + REGION_PASSWORD_OPTION_SUFFIX): 129 | if not self.has_option(BLESS_CA_SECTION, 'default' + REGION_PASSWORD_OPTION_SUFFIX): 130 | raise ValueError("No Region Specific And No Default Password Provided.") 131 | 132 | def getpassword(self): 133 | """ 134 | Returns the correct encrypted password based off of the aws_region. 135 | :return: A Base64 encoded KMS CiphertextBlob. 136 | """ 137 | if self.has_option(BLESS_CA_SECTION, self.aws_region + REGION_PASSWORD_OPTION_SUFFIX): 138 | return self.get(BLESS_CA_SECTION, self.aws_region + REGION_PASSWORD_OPTION_SUFFIX) 139 | return self.get(BLESS_CA_SECTION, 'default' + REGION_PASSWORD_OPTION_SUFFIX) 140 | 141 | def getkmsauthkeyids(self): 142 | """ 143 | Returns a list of kmsauth keys used for validation (so a key generated 144 | in one region can validate in another). 145 | :return: A list of kmsauth key ids 146 | """ 147 | return list(map(str.strip, self.get(KMSAUTH_SECTION, KMSAUTH_KEY_ID_OPTION).split(','))) 148 | 149 | def getprivatekey(self): 150 | """ 151 | Get a private key from either a file specified in the config file, or from an environment variable. Env 152 | Vars in Lambda can't contain a 4096 RSA key uncompressed, so compressed keys are also supported. 153 | :return: byte string that contains the private key in PEM format (ascii). 154 | """ 155 | compression = self.get(BLESS_CA_SECTION, CA_PRIVATE_KEY_COMPRESSION_OPTION) 156 | 157 | if self.has_option(BLESS_CA_SECTION, CA_PRIVATE_KEY_OPTION): 158 | return self._decompress(base64.b64decode(self.get(BLESS_CA_SECTION, CA_PRIVATE_KEY_OPTION)), compression) 159 | 160 | ca_private_key_file = self.get(BLESS_CA_SECTION, CA_PRIVATE_KEY_FILE_OPTION) 161 | 162 | # read the private key .pem 163 | with open(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, ca_private_key_file), 'rb') as f: 164 | return self._decompress(f.read(), compression) 165 | 166 | def has_option(self, section, option): 167 | """ 168 | Checks if an option exists. 169 | 170 | This will search in both the environment variables and in the config file 171 | :param section: The section to search in 172 | :param option: The option to check 173 | :return: True if it exists, False otherwise 174 | """ 175 | environment_key = self._environment_key(section, option) 176 | if environment_key in os.environ: 177 | return True 178 | else: 179 | return super(BlessConfig, self).has_option(section, option) 180 | 181 | def get(self, section, option, **kwargs): 182 | """ 183 | Gets a value from the configuration. 184 | 185 | Checks the environment before looking in the config file. 186 | :param section: The config section to look in 187 | :param option: The config option to look at 188 | :return: The value of the config option 189 | """ 190 | environment_key = self._environment_key(section, option) 191 | output = os.environ.get(environment_key, None) 192 | if output is None: 193 | output = super(BlessConfig, self).get(section, option, **kwargs) 194 | return output 195 | 196 | @staticmethod 197 | def _environment_key(section, option): 198 | return (re.sub(r'\W+', '_', section) + '_' + re.sub(r'\W+', '_', option)).lower() 199 | 200 | @staticmethod 201 | def _decompress(data, algorithm): 202 | """ 203 | Decompress a byte string based of the provided algorithm. 204 | :param data: byte string 205 | :param algorithm: string with the name of the compression algorithm used 206 | :return: decompressed byte string. 207 | """ 208 | if algorithm is None or algorithm == 'none': 209 | result = data 210 | elif algorithm == 'zlib': 211 | result = zlib.decompress(data) 212 | elif algorithm == 'bz2': 213 | result = bz2.decompress(data) 214 | else: 215 | raise ValueError("Compression {} is not supported.".format(algorithm)) 216 | 217 | return result 218 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2016 Netflix, Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /tests/config/test_bless_config.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | import zlib 4 | 5 | import pytest 6 | from bless.config.bless_config import BlessConfig, \ 7 | BLESS_OPTIONS_SECTION, \ 8 | CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION, \ 9 | CERTIFICATE_VALIDITY_AFTER_SEC_OPTION, \ 10 | ENTROPY_MINIMUM_BITS_OPTION, \ 11 | RANDOM_SEED_BYTES_OPTION, \ 12 | CERTIFICATE_VALIDITY_SEC_DEFAULT, \ 13 | ENTROPY_MINIMUM_BITS_DEFAULT, \ 14 | RANDOM_SEED_BYTES_DEFAULT, \ 15 | LOGGING_LEVEL_DEFAULT, \ 16 | LOGGING_LEVEL_OPTION, \ 17 | BLESS_CA_SECTION, \ 18 | CA_PRIVATE_KEY_FILE_OPTION, \ 19 | KMSAUTH_SECTION, \ 20 | KMSAUTH_USEKMSAUTH_OPTION, \ 21 | KMSAUTH_KEY_ID_OPTION, \ 22 | KMSAUTH_SERVICE_ID_OPTION, \ 23 | CERTIFICATE_EXTENSIONS_OPTION, \ 24 | USERNAME_VALIDATION_OPTION, \ 25 | USERNAME_VALIDATION_DEFAULT, \ 26 | REMOTE_USERNAMES_VALIDATION_OPTION, \ 27 | CA_PRIVATE_KEY_COMPRESSION_OPTION, \ 28 | CA_PRIVATE_KEY_COMPRESSION_OPTION_DEFAULT, \ 29 | SERVER_CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION, \ 30 | SERVER_CERTIFICATE_VALIDITY_BEFORE_SEC_DEFAULT, \ 31 | SERVER_CERTIFICATE_VALIDITY_AFTER_SEC_OPTION, \ 32 | SERVER_CERTIFICATE_VALIDITY_AFTER_SEC_DEFAULT, \ 33 | HOSTNAME_VALIDATION_OPTION, \ 34 | HOSTNAME_VALIDATION_DEFAULT, \ 35 | VALIDATE_REMOTE_USERNAMES_AGAINST_IAM_GROUPS_OPTION 36 | 37 | 38 | def test_empty_config(): 39 | with pytest.raises(ValueError): 40 | BlessConfig('us-west-2', config_file='') 41 | 42 | 43 | def test_config_no_password(): 44 | with pytest.raises(ValueError) as e: 45 | BlessConfig('bogus-region', 46 | config_file=os.path.join(os.path.dirname(__file__), 'full.cfg')) 47 | assert 'No Region Specific And No Default Password Provided.' == str(e.value) 48 | 49 | config = BlessConfig('bogus-region', 50 | config_file=os.path.join(os.path.dirname(__file__), 'full-with-default.cfg')) 51 | assert '' == config.getpassword() 52 | 53 | 54 | def test_wrong_compression_env_key(monkeypatch): 55 | extra_environment_variables = { 56 | 'bless_ca_default_password': '', 57 | 'bless_ca_ca_private_key_compression': 'lzh', 58 | 'bless_ca_ca_private_key': str(base64.b64encode(b''), encoding='ascii') 59 | } 60 | 61 | for k, v in extra_environment_variables.items(): 62 | monkeypatch.setenv(k, v) 63 | 64 | # Create an empty config, everything is set in the environment 65 | config = BlessConfig('us-east-1', config_file='') 66 | 67 | with pytest.raises(ValueError) as e: 68 | config.getprivatekey() 69 | 70 | assert "Compression lzh is not supported." == str(e.value) 71 | 72 | 73 | def test_none_compression_env_key(monkeypatch): 74 | extra_environment_variables = { 75 | 'bless_ca_default_password': '', 76 | 'bless_ca_ca_private_key_compression': 'none', 77 | 'bless_ca_ca_private_key': str(base64.b64encode(b''), encoding='ascii') 78 | } 79 | 80 | for k, v in extra_environment_variables.items(): 81 | monkeypatch.setenv(k, v) 82 | 83 | # Create an empty config, everything is set in the environment 84 | config = BlessConfig('us-east-1', config_file='') 85 | 86 | assert b'' == config.getprivatekey() 87 | 88 | 89 | def test_zlib_positive_compression(monkeypatch): 90 | extra_environment_variables = { 91 | 'bless_ca_default_password': '', 92 | 'bless_ca_ca_private_key_compression': 'zlib', 93 | 'bless_ca_ca_private_key': str(base64.b64encode(zlib.compress(b'')), 94 | encoding='ascii') 95 | } 96 | 97 | for k, v in extra_environment_variables.items(): 98 | monkeypatch.setenv(k, v) 99 | 100 | # Create an empty config, everything is set in the environment 101 | config = BlessConfig('us-east-1', config_file='') 102 | 103 | assert b'' == config.getprivatekey() 104 | 105 | 106 | def test_zlib_compression_env_with_uncompressed_key(monkeypatch): 107 | extra_environment_variables = { 108 | 'bless_ca_default_password': '', 109 | 'bless_ca_ca_private_key_compression': 'zlib', 110 | 'bless_ca_ca_private_key': str(base64.b64encode(b''), encoding='ascii'), 111 | } 112 | 113 | for k, v in extra_environment_variables.items(): 114 | monkeypatch.setenv(k, v) 115 | 116 | # Create an empty config, everything is set in the environment 117 | config = BlessConfig('us-east-1', config_file='') 118 | 119 | with pytest.raises(zlib.error) as e: 120 | config.getprivatekey() 121 | 122 | 123 | def test_config_environment_override(monkeypatch): 124 | extra_environment_variables = { 125 | 'bless_options_certificate_validity_after_seconds': '1', 126 | 'bless_options_certificate_validity_before_seconds': '1', 127 | 'bless_options_server_certificate_validity_after_seconds': '1', 128 | 'bless_options_server_certificate_validity_before_seconds': '1', 129 | 'bless_options_hostname_validation': 'disabled', 130 | 'bless_options_entropy_minimum_bits': '2', 131 | 'bless_options_random_seed_bytes': '3', 132 | 'bless_options_logging_level': 'DEBUG', 133 | 'bless_options_certificate_extensions': 'permit-X11-forwarding', 134 | 'bless_options_username_validation': 'debian', 135 | 'bless_options_remote_usernames_validation': 'useradd', 136 | 137 | 'bless_ca_us_east_1_password': '', 138 | 'bless_ca_default_password': '', 139 | 'bless_ca_ca_private_key_file': '', 140 | 'bless_ca_ca_private_key': str(base64.b64encode(b''), encoding='ascii'), 141 | 142 | 'kms_auth_use_kmsauth': 'True', 143 | 'kms_auth_kmsauth_key_id': '', 144 | 'kms_auth_kmsauth_serviceid': 'bless-test', 145 | } 146 | 147 | for k, v in extra_environment_variables.items(): 148 | monkeypatch.setenv(k, v) 149 | 150 | # Create an empty config, everything is set in the environment 151 | config = BlessConfig('us-east-1', config_file='') 152 | 153 | assert 1 == config.getint(BLESS_OPTIONS_SECTION, CERTIFICATE_VALIDITY_AFTER_SEC_OPTION) 154 | assert 1 == config.getint(BLESS_OPTIONS_SECTION, CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION) 155 | assert 1 == config.getint(BLESS_OPTIONS_SECTION, SERVER_CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION) 156 | assert 1 == config.getint(BLESS_OPTIONS_SECTION, SERVER_CERTIFICATE_VALIDITY_AFTER_SEC_OPTION) 157 | assert 2 == config.getint(BLESS_OPTIONS_SECTION, ENTROPY_MINIMUM_BITS_OPTION) 158 | assert 3 == config.getint(BLESS_OPTIONS_SECTION, RANDOM_SEED_BYTES_OPTION) 159 | assert 'DEBUG' == config.get(BLESS_OPTIONS_SECTION, LOGGING_LEVEL_OPTION) 160 | assert 'permit-X11-forwarding' == config.get(BLESS_OPTIONS_SECTION, CERTIFICATE_EXTENSIONS_OPTION) 161 | assert 'debian' == config.get(BLESS_OPTIONS_SECTION, USERNAME_VALIDATION_OPTION) 162 | assert 'disabled' == config.get(BLESS_OPTIONS_SECTION, HOSTNAME_VALIDATION_OPTION) 163 | assert 'useradd' == config.get(BLESS_OPTIONS_SECTION, REMOTE_USERNAMES_VALIDATION_OPTION) 164 | 165 | assert '' == config.getpassword() 166 | assert '' == config.get(BLESS_CA_SECTION, CA_PRIVATE_KEY_FILE_OPTION) 167 | assert b'' == config.getprivatekey() 168 | 169 | assert config.getboolean(KMSAUTH_SECTION, KMSAUTH_USEKMSAUTH_OPTION) 170 | assert '' == config.get(KMSAUTH_SECTION, KMSAUTH_KEY_ID_OPTION) 171 | assert 'bless-test' == config.get(KMSAUTH_SECTION, KMSAUTH_SERVICE_ID_OPTION) 172 | 173 | config.aws_region = 'invalid' 174 | assert '' == config.getpassword() 175 | 176 | 177 | @pytest.mark.parametrize( 178 | "config, region, expected_cert_valid, expected_entropy_min, expected_rand_seed, " 179 | "expected_host_cert_before_valid, expected_host_cert_after_valid, " 180 | "expected_log_level, expected_password, expected_username_validation, " 181 | "expected_hostname_validation, expected_key_compression", 182 | [ 183 | ((os.path.join(os.path.dirname(__file__), 'minimal.cfg')), 'us-west-2', 184 | CERTIFICATE_VALIDITY_SEC_DEFAULT, 185 | ENTROPY_MINIMUM_BITS_DEFAULT, RANDOM_SEED_BYTES_DEFAULT, 186 | SERVER_CERTIFICATE_VALIDITY_BEFORE_SEC_DEFAULT, 187 | SERVER_CERTIFICATE_VALIDITY_AFTER_SEC_DEFAULT, 188 | LOGGING_LEVEL_DEFAULT, 189 | '', 190 | USERNAME_VALIDATION_DEFAULT, 191 | HOSTNAME_VALIDATION_DEFAULT, 192 | CA_PRIVATE_KEY_COMPRESSION_OPTION_DEFAULT 193 | ), 194 | ((os.path.join(os.path.dirname(__file__), 'full-zlib.cfg')), 'us-west-2', 195 | 1, 2, 3, 4, 5, 'DEBUG', 196 | '', 197 | 'debian', 198 | 'disabled', 199 | 'zlib' 200 | ), 201 | ((os.path.join(os.path.dirname(__file__), 'full.cfg')), 'us-east-1', 202 | 1, 2, 3, 4, 5, 'DEBUG', 203 | '', 204 | 'debian', 205 | 'disabled', 206 | 'zlib' 207 | ) 208 | ]) 209 | def test_configs(config, region, expected_cert_valid, expected_entropy_min, expected_rand_seed, 210 | expected_host_cert_before_valid, expected_host_cert_after_valid, 211 | expected_log_level, expected_password, expected_username_validation, 212 | expected_hostname_validation, expected_key_compression): 213 | config = BlessConfig(region, config_file=config) 214 | assert expected_cert_valid == config.getint(BLESS_OPTIONS_SECTION, 215 | CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION) 216 | assert expected_cert_valid == config.getint(BLESS_OPTIONS_SECTION, 217 | CERTIFICATE_VALIDITY_AFTER_SEC_OPTION) 218 | assert expected_entropy_min == config.getint(BLESS_OPTIONS_SECTION, 219 | ENTROPY_MINIMUM_BITS_OPTION) 220 | assert expected_rand_seed == config.getint(BLESS_OPTIONS_SECTION, 221 | RANDOM_SEED_BYTES_OPTION) 222 | assert expected_host_cert_before_valid == config.getint(BLESS_OPTIONS_SECTION, 223 | SERVER_CERTIFICATE_VALIDITY_BEFORE_SEC_OPTION) 224 | assert expected_host_cert_after_valid == config.getint(BLESS_OPTIONS_SECTION, 225 | SERVER_CERTIFICATE_VALIDITY_AFTER_SEC_OPTION) 226 | assert expected_log_level == config.get(BLESS_OPTIONS_SECTION, LOGGING_LEVEL_OPTION) 227 | assert expected_password == config.getpassword() 228 | assert expected_username_validation == config.get(BLESS_OPTIONS_SECTION, 229 | USERNAME_VALIDATION_OPTION) 230 | assert expected_hostname_validation == config.get(BLESS_OPTIONS_SECTION, 231 | HOSTNAME_VALIDATION_OPTION) 232 | assert expected_key_compression == config.get(BLESS_CA_SECTION, 233 | CA_PRIVATE_KEY_COMPRESSION_OPTION) 234 | 235 | 236 | def test_kms_config_opts(monkeypatch): 237 | # Default option 238 | config = BlessConfig("us-east-1", config_file=os.path.join(os.path.dirname(__file__), 'full.cfg')) 239 | assert config.getboolean(KMSAUTH_SECTION, KMSAUTH_USEKMSAUTH_OPTION) is False 240 | 241 | # Config file value 242 | config = BlessConfig("us-east-1", config_file=os.path.join(os.path.dirname(__file__), 'full-with-kmsauth.cfg')) 243 | assert config.getboolean(KMSAUTH_SECTION, KMSAUTH_USEKMSAUTH_OPTION) is True 244 | assert config.getboolean(KMSAUTH_SECTION, VALIDATE_REMOTE_USERNAMES_AGAINST_IAM_GROUPS_OPTION) is False 245 | -------------------------------------------------------------------------------- /bless/ssh/certificates/ssh_certificate_builder.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: bless.ssh.certificates.ssh_certificate_builder 3 | :copyright: (c) 2016 by Netflix Inc., see AUTHORS for more 4 | :license: Apache, see LICENSE for more details. 5 | """ 6 | import base64 7 | 8 | import os 9 | from bless.ssh.protocol.ssh_protocol import pack_ssh_string, pack_ssh_uint64, pack_ssh_uint32 10 | 11 | 12 | class SSHCertificateType(object): 13 | USER = 1 14 | HOST = 2 15 | 16 | 17 | class SSHCertifiedKeyType(object): 18 | RSA = 'ssh-rsa-cert-v01@openssh.com' 19 | ED25519 = 'ssh-ed25519-cert-v01@openssh.com' 20 | # todo support more key types: 21 | # 'ecdsa-sha2-nistp256-cert-v01@openssh.com' 22 | # 'ecdsa-sha2-nistp384-cert-v01@openssh.com' 23 | # 'ecdsa-sha2-nistp521-cert-v01@openssh.com' 24 | 25 | 26 | class SSHCertificateBuilder(object): 27 | def __init__(self, ca, cert_type): 28 | """ 29 | An abstract base class used to produce an SSH Certificate for various public key types. 30 | :param ca: The SSHCertificateAuthority that will sign the certificate. The 31 | SSHCertificateAuthority type does not need to be the same type as the 32 | SSHCertificateBuilder. 33 | :param cert_type: The SSHCertificateType. Is this a User or Host certificate? Some of 34 | the SSH Certificate fields do not apply or have a slightly different meaning depending on 35 | the certificate type. 36 | See http://cvsweb.openbsd.org/cgi-bin/cvsweb/src/usr.bin/ssh/PROTOCOL.certkeys 37 | """ 38 | self.ca = ca # required 39 | self.nonce = None # optional, has default = os.urandom(32) 40 | self.public_key_comment = None 41 | self.serial = None # can be set, has default = 0 42 | self.cert_type = None # required: User = 1, Host = 2 43 | self.key_id = None # optional, default = '' 44 | self.valid_principals = list() # optional, default = '' 45 | self.valid_after = None # optional, default = 0 46 | self.valid_before = None # optional, default = 2^64-1 47 | self.critical_option_force_command = None # optional, default = '' 48 | self.critical_option_source_address = None # optional, default = '' 49 | self.extensions = None # optional, default = '' 50 | self.reserved = '' # should always be this value 51 | self.signature = None 52 | self.signed_cert = None 53 | self.public_key_comment = None 54 | self.cert_type = cert_type 55 | 56 | # todo real abstract classes 57 | def _serialize_ssh_public_key(self): 58 | """ 59 | Serialize the Public Key per the spec: 60 | http://cvsweb.openbsd.org/cgi-bin/cvsweb/src/usr.bin/ssh/PROTOCOL.certkeys 61 | :return: The bytes that belong in the SSH Certificate between the nonce and the 62 | certificate serial number. 63 | """ 64 | raise NotImplementedError("Child classes should override this") 65 | 66 | def set_nonce(self, nonce=None): 67 | """ 68 | Sets the nonce to be included as a part of the certificate body. 69 | :param nonce: If no nonce is specified, this will fetch 32 Bytes from os.urandom. 70 | """ 71 | if nonce is None: 72 | nonce = os.urandom(32) 73 | self.nonce = nonce 74 | 75 | def set_serial(self, serial=0): 76 | """ 77 | Sets an optional serial number of the SSH Certificate. 78 | :param serial: A uint64 serial number. 79 | """ 80 | self.serial = serial 81 | 82 | def set_key_id(self, key_id=''): 83 | """ 84 | Sets the key id of a certificate, which is just a string that ends up getting singed by 85 | the CA. This key id is super useful because it gets logged by sshd when the certificate 86 | is used to successfully authenticate users. Depending on your environment, the logging of 87 | this string will eventually be truncated at ~325 characters. 88 | :param key_id: String to include in the certificate, to be logged when the certificate 89 | is used. 90 | """ 91 | self.key_id = key_id 92 | 93 | def add_valid_principal(self, valid_principal): 94 | """ 95 | Individually add one valid principal to the certificate. You can add many principals to an 96 | SSH Certificate. 97 | 98 | For User SSH Certificates, a valid principal defines which remote user account(s) the 99 | certificate is valid for. 100 | 101 | For Host SSH Certificates, a valid principal defines which hostname(s) the certificate is 102 | valid for. 103 | 104 | You want to set at least one valid principal. Not doing means the certificate is valid 105 | for any user/hostname. 106 | See http://cvsweb.openbsd.org/cgi-bin/cvsweb/src/usr.bin/ssh/PROTOCOL.certkeys 107 | :param valid_principal: String with the username or hostname. 108 | """ 109 | if valid_principal: 110 | if valid_principal not in self.valid_principals: 111 | self.valid_principals.append(valid_principal) 112 | else: 113 | raise ValueError("Principal {} already added.".format(valid_principal)) 114 | else: 115 | raise ValueError("Provide a non-null string") 116 | 117 | def set_valid_after(self, after=0): 118 | """ 119 | Sets the SSH Certificate validity start time. Not setting a value will result in an SSH 120 | Certificate that is valid since time 0. 121 | :param after: Integer of the desired Unix epoch time. 122 | """ 123 | self.valid_after = after 124 | 125 | def set_valid_before(self, before=18446744073709551615): 126 | """ 127 | Sets the SSH Certificate validity end time. Not setting a value will result in an SSH 128 | Certificate that never expires. Probably not what you want to do. 129 | :param before: Integer of the desired Unix epoch time 130 | """ 131 | self.valid_before = before 132 | 133 | def set_critical_option_force_command(self, command): 134 | """ 135 | Sets a command that will be executed whenever this SSH Certificate is used for 136 | authentication. This will replace any command specified by the SSH command. 137 | :param command: String of the program (and arguments) to run on the remote host. 138 | """ 139 | if command: 140 | self.critical_option_force_command = command 141 | else: 142 | raise ValueError("Provide a non-null string") 143 | 144 | def set_critical_option_source_addresses(self, address): 145 | """ 146 | Sets which IP address(es) this certificate can be used from for authentication. Addresses 147 | should be comma-separated and can be individual IPs or CIDR format (nn.nn.nn.nn/nn or 148 | hhhh::hhhh/nn). 149 | 150 | Not setting this means the SSH Certificate is valid from any IP. Probably not what you 151 | want to do. 152 | :param address: String of one or more comma-separated IPs or CIDRs. 153 | """ 154 | if address: 155 | self.critical_option_source_address = address 156 | else: 157 | raise ValueError("Provide a non-null string") 158 | 159 | def clear_extensions(self): 160 | """ 161 | Removes any previously set SSH Certificate Extensions. 162 | """ 163 | self.extensions = set() 164 | 165 | def set_extensions_to_default(self): 166 | """ 167 | Sets the SSH Certificate Extensions set to the same defaults ssh-keygen would provide. 168 | 169 | SSH Certificate Extensions enable certain SSH features. If they are not present, 170 | sessions authenticated with the certificate cannot use them. 171 | 172 | See http://cvsweb.openbsd.org/cgi-bin/cvsweb/src/usr.bin/ssh/PROTOCOL.certkeys 173 | """ 174 | if self.cert_type is SSHCertificateType.USER: 175 | self.extensions = {'permit-X11-forwarding', 176 | 'permit-agent-forwarding', 177 | 'permit-port-forwarding', 178 | 'permit-pty', 'permit-user-rc'} 179 | else: 180 | # SSHCertificateType.HOST has no applicable extensions. 181 | self.clear_extensions() 182 | 183 | def add_extension(self, extension): 184 | """ 185 | Add an individual SSH Certificate Extension to the certificate. 186 | 187 | SSH Certificate Extensions enable certain SSH features. If they are not present, 188 | sessions authenticated with the certificate cannot use them. 189 | 190 | See http://cvsweb.openbsd.org/cgi-bin/cvsweb/src/usr.bin/ssh/PROTOCOL.certkeys 191 | :param extension: the extension to include 192 | """ 193 | if self.extensions is None: 194 | self.extensions = set() 195 | 196 | self.extensions.add(extension) 197 | 198 | def get_cert_file(self, bypass_time_validity_check=False): 199 | """ 200 | Generate the SSH Certificate that can be written to id_rsa-cert.pub or similar file. 201 | 202 | This will initialize any unset SSH Certificate attributes to sane defaults, verify the 203 | validity range, and sign the certificate. 204 | :return: String with all of the required SSH Certificate contents, that can be written 205 | to a file. 206 | """ 207 | file_contents = ( 208 | "{} {} {}" 209 | ).format(self.cert_key_type, 210 | str(base64.b64encode(self._sign_cert(bypass_time_validity_check)), encoding='ascii'), 211 | self.public_key_comment) 212 | return file_contents 213 | 214 | def _initialize_unset_attributes(self): 215 | if self.nonce is None: 216 | self.set_nonce() 217 | 218 | if self.serial is None: 219 | self.set_serial() 220 | 221 | if self.valid_after is None: 222 | self.set_valid_after() 223 | 224 | if self.valid_before is None: 225 | self.set_valid_before() 226 | 227 | if self.key_id is None: 228 | self.set_key_id() 229 | 230 | if self.extensions is None: 231 | self.set_extensions_to_default() 232 | 233 | if not self.public_key_comment: 234 | self.public_key_comment = \ 235 | 'Certificate type[{}] principals[{}] with the id[{}]'.format( 236 | self.cert_type, ','.join(self.valid_principals), self.key_id) 237 | 238 | def _validate_cert_properties(self): 239 | if self.valid_after >= self.valid_before: 240 | raise ValueError("Impossible validity period") 241 | 242 | def _sign_cert(self, bypass_time_validity_check=False): 243 | if self.signed_cert is None: 244 | # build cert body 245 | self._initialize_unset_attributes() 246 | if not bypass_time_validity_check: 247 | self._validate_cert_properties() 248 | body_bytes = self._serialize_certificate_body() 249 | 250 | # sign the body 251 | sig_bytes = self.ca.sign(body_bytes) 252 | self.signed_cert = body_bytes + sig_bytes 253 | return self.signed_cert 254 | 255 | def _serialize_certificate_body(self): 256 | body = pack_ssh_string(self.cert_key_type) 257 | body += pack_ssh_string(self.nonce) 258 | body += self._serialize_ssh_public_key() 259 | body += pack_ssh_uint64(self.serial) 260 | body += pack_ssh_uint32(self.cert_type) 261 | body += pack_ssh_string(self.key_id) 262 | body += pack_ssh_string(self._serialize_valid_principals()) 263 | body += pack_ssh_uint64(self.valid_after) 264 | body += pack_ssh_uint64(self.valid_before) 265 | body += pack_ssh_string(self._serialize_critical_options()) 266 | body += pack_ssh_string(self._serialize_extensions()) 267 | body += pack_ssh_string('') 268 | body += pack_ssh_string(self.ca.get_signature_key()) 269 | return body 270 | 271 | def _serialize_extensions(self): 272 | # Options must be lexically ordered by "name" if they appear in the 273 | # sequence. Each named option may only appear once in a certificate. 274 | extensions_list = sorted(self.extensions) 275 | 276 | serialized = b'' 277 | # Format is a series of {extension name}{empty string} 278 | for extension in extensions_list: 279 | serialized += pack_ssh_string(extension) 280 | serialized += pack_ssh_string('') 281 | 282 | return serialized 283 | 284 | def _serialize_valid_principals(self): 285 | serialized = b'' 286 | 287 | for principal in self.valid_principals: 288 | serialized += pack_ssh_string(principal) 289 | 290 | return serialized 291 | 292 | def _serialize_critical_options(self): 293 | # Options must be lexically ordered by "name" if they appear in the 294 | # sequence. Each named option may only appear once in a certificate. 295 | serialized = b'' 296 | 297 | if self.critical_option_force_command is not None: 298 | serialized += pack_ssh_string('force-command') 299 | serialized += pack_ssh_string( 300 | pack_ssh_string(self.critical_option_force_command)) 301 | 302 | if self.critical_option_source_address is not None: 303 | serialized += pack_ssh_string('source-address') 304 | serialized += pack_ssh_string( 305 | pack_ssh_string(self.critical_option_source_address)) 306 | 307 | return serialized 308 | -------------------------------------------------------------------------------- /tests/aws_lambda/test_bless_lambda_user.py: -------------------------------------------------------------------------------- 1 | import os 2 | import zlib 3 | 4 | import pytest 5 | 6 | from bless.aws_lambda.bless_lambda_user import lambda_handler_user 7 | from bless.aws_lambda.bless_lambda import lambda_handler 8 | from tests.ssh.vectors import EXAMPLE_RSA_PUBLIC_KEY, RSA_CA_PRIVATE_KEY_PASSWORD, \ 9 | EXAMPLE_ED25519_PUBLIC_KEY, EXAMPLE_ECDSA_PUBLIC_KEY 10 | 11 | 12 | class Context(object): 13 | aws_request_id = 'bogus aws_request_id' 14 | invoked_function_arn = 'bogus invoked_function_arn' 15 | 16 | 17 | VALID_TEST_REQUEST = { 18 | "remote_usernames": "user", 19 | "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, 20 | "command": "ssh user@server", 21 | "bastion_ips": "127.0.0.1", 22 | "bastion_user": "user", 23 | "bastion_user_ip": "127.0.0.1" 24 | } 25 | 26 | VALID_TEST_REQUEST_ED2551 = { 27 | "remote_usernames": "user", 28 | "public_key_to_sign": EXAMPLE_ED25519_PUBLIC_KEY, 29 | "command": "ssh user@server", 30 | "bastion_ips": "127.0.0.1", 31 | "bastion_user": "user", 32 | "bastion_user_ip": "127.0.0.1" 33 | } 34 | 35 | VALID_TEST_REQUEST_USERNAME_VALIDATION_EMAIL_REMOTE_USERNAMES_USERADD = { 36 | "remote_usernames": "user,anotheruser", 37 | "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, 38 | "command": "ssh user@server", 39 | "bastion_ips": "127.0.0.1", 40 | "bastion_user": "someone@example.com", 41 | "bastion_user_ip": "127.0.0.1" 42 | } 43 | 44 | VALID_TEST_REQUEST_USERNAME_VALIDATION_DISABLED = { 45 | "remote_usernames": "'~:, \n\t@'", 46 | "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, 47 | "command": "ssh user@server", 48 | "bastion_ips": "127.0.0.1", 49 | "bastion_user": "a33characterusernameyoumustbenuts", 50 | "bastion_user_ip": "127.0.0.1" 51 | } 52 | 53 | INVALID_TEST_REQUEST = { 54 | "remote_usernames": "user", 55 | "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, 56 | "command": "ssh user@server", 57 | "bastion_ips": "invalid_ip", 58 | "bastion_user": "user", 59 | "bastion_user_ip": "invalid_ip" 60 | } 61 | 62 | VALID_TEST_REQUEST_KMSAUTH = { 63 | "remote_usernames": "user", 64 | "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, 65 | "command": "ssh user@server", 66 | "bastion_ips": "127.0.0.1", 67 | "bastion_user": "user", 68 | "bastion_user_ip": "127.0.0.1", 69 | "kmsauth_token": "validkmsauthtoken", 70 | } 71 | 72 | INVALID_TEST_REQUEST_KEY_TYPE = { 73 | "remote_usernames": "user", 74 | "public_key_to_sign": EXAMPLE_ECDSA_PUBLIC_KEY, 75 | "command": "ssh user@server", 76 | "bastion_ips": "127.0.0.1", 77 | "bastion_user": "user", 78 | "bastion_user_ip": "127.0.0.1" 79 | } 80 | 81 | INVALID_TEST_REQUEST_EXTRA_FIELD = { 82 | "remote_usernames": "user", 83 | "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, 84 | "command": "ssh user@server", 85 | "bastion_ips": "127.0.0.1", 86 | "bastion_user": "user", 87 | "bastion_user_ip": "127.0.0.1", 88 | "bastion_ip": "127.0.0.1" # Note this is now an invalid field. 89 | } 90 | 91 | INVALID_TEST_REQUEST_MISSING_FIELD = { 92 | "remote_usernames": "user", 93 | "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, 94 | "bastion_ips": "127.0.0.1", 95 | "bastion_user": "user", 96 | "bastion_user_ip": "127.0.0.1" 97 | } 98 | 99 | VALID_TEST_REQUEST_MULTIPLE_PRINCIPALS = { 100 | "remote_usernames": "user1,user2", 101 | "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, 102 | "command": "ssh user@server", 103 | "bastion_ips": "127.0.0.1", 104 | "bastion_user": "user", 105 | "bastion_user_ip": "127.0.0.1" 106 | } 107 | 108 | INVALID_TEST_REQUEST_MULTIPLE_PRINCIPALS = { 109 | "remote_usernames": ",user#", 110 | "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, 111 | "command": "ssh user@server", 112 | "bastion_ips": "127.0.0.1", 113 | "bastion_user": "user", 114 | "bastion_user_ip": "127.0.0.1" 115 | } 116 | 117 | INVALID_TEST_REQUEST_USERNAME_INVALID = { 118 | "remote_usernames": "user", 119 | "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, 120 | "command": "ssh user@server", 121 | "bastion_ips": "127.0.0.1", 122 | "bastion_user": "~@.", 123 | "bastion_user_ip": "127.0.0.1" 124 | } 125 | 126 | INVALID_TEST_KMSAUTH_REQUEST_USERNAME_DOESNT_MATCH_REMOTE = { 127 | "remote_usernames": "userb", 128 | "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, 129 | "command": "ssh user@server", 130 | "bastion_ips": "127.0.0.1", 131 | "bastion_user": "usera", 132 | "bastion_user_ip": "127.0.0.1", 133 | "kmsauth_token": "validkmsauthtoken" 134 | } 135 | 136 | INVALID_TEST_KMSAUTH_REQUEST_DIFFERENT_REMOTE_USER = { 137 | "remote_usernames": "root", 138 | "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, 139 | "command": "ssh user@server", 140 | "bastion_ips": "127.0.0.1", 141 | "bastion_user": "usera", 142 | "bastion_user_ip": "127.0.0.1", 143 | "kmsauth_token": "validkmsauthtoken" 144 | } 145 | 146 | VALID_TEST_KMSAUTH_REQUEST_DIFFERENT_REMOTE_USER = { 147 | "remote_usernames": "alloweduser", 148 | "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, 149 | "command": "ssh user@server", 150 | "bastion_ips": "127.0.0.1", 151 | "bastion_user": "usera", 152 | "bastion_user_ip": "127.0.0.1", 153 | "kmsauth_token": "validkmsauthtoken" 154 | } 155 | 156 | INVALID_TEST_REQUEST_BLACKLISTED_REMOTE_USERNAME = { 157 | "remote_usernames": "alloweduser,balrog", 158 | "public_key_to_sign": EXAMPLE_RSA_PUBLIC_KEY, 159 | "command": "ssh user@server", 160 | "bastion_ips": "127.0.0.1", 161 | "bastion_user": "user", 162 | "bastion_user_ip": "127.0.0.1" 163 | } 164 | 165 | 166 | def test_basic_local_request_with_wrapper(): 167 | output = lambda_handler(VALID_TEST_REQUEST, context=Context, 168 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 169 | entropy_check=False, 170 | config_file=os.path.join(os.path.dirname(__file__), 'bless-test.cfg')) 171 | assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ') 172 | 173 | 174 | def test_basic_local_request(): 175 | output = lambda_handler_user(VALID_TEST_REQUEST, context=Context, 176 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 177 | entropy_check=False, 178 | config_file=os.path.join(os.path.dirname(__file__), 'bless-test.cfg')) 179 | assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ') 180 | 181 | 182 | def test_basic_local_request_ed2551(): 183 | output = lambda_handler_user(VALID_TEST_REQUEST_ED2551, context=Context, 184 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 185 | entropy_check=False, 186 | config_file=os.path.join(os.path.dirname(__file__), 'bless-test.cfg')) 187 | assert output['certificate'].startswith('ssh-ed25519-cert-v01@openssh.com ') 188 | 189 | 190 | def test_basic_local_unused_kmsauth_request(): 191 | output = lambda_handler_user(VALID_TEST_REQUEST_KMSAUTH, context=Context, 192 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 193 | entropy_check=False, 194 | config_file=os.path.join(os.path.dirname(__file__), 'bless-test.cfg')) 195 | assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ') 196 | 197 | 198 | def test_basic_local_missing_kmsauth_request(): 199 | output = lambda_handler_user(VALID_TEST_REQUEST, context=Context, 200 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 201 | entropy_check=False, 202 | config_file=os.path.join(os.path.dirname(__file__), 203 | 'bless-test-kmsauth.cfg')) 204 | assert output['errorType'] == 'InputValidationError' 205 | 206 | 207 | def test_basic_local_username_validation_disabled(monkeypatch): 208 | extra_environment_variables = { 209 | 'bless_ca_default_password': '', 210 | 'bless_ca_ca_private_key_file': 'tests/aws_lambda/only-use-for-unit-tests.pem', 211 | 'bless_options_username_validation': 'disabled', 212 | 'bless_options_remote_usernames_validation': 'disabled', 213 | } 214 | 215 | for k, v in extra_environment_variables.items(): 216 | monkeypatch.setenv(k, v) 217 | 218 | output = lambda_handler_user(VALID_TEST_REQUEST_USERNAME_VALIDATION_DISABLED, context=Context, 219 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 220 | entropy_check=False, 221 | config_file=os.path.join(os.path.dirname(__file__), '')) 222 | assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ') 223 | 224 | 225 | def test_basic_local_username_validation_email_remote_usernames_useradd(monkeypatch): 226 | extra_environment_variables = { 227 | 'bless_ca_default_password': '', 228 | 'bless_ca_ca_private_key_file': 'tests/aws_lambda/only-use-for-unit-tests.pem', 229 | 'bless_options_username_validation': 'email', 230 | 'bless_options_remote_usernames_validation': 'useradd', 231 | } 232 | 233 | for k, v in extra_environment_variables.items(): 234 | monkeypatch.setenv(k, v) 235 | 236 | output = lambda_handler_user(VALID_TEST_REQUEST_USERNAME_VALIDATION_EMAIL_REMOTE_USERNAMES_USERADD, context=Context, 237 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 238 | entropy_check=False, 239 | config_file=os.path.join(os.path.dirname(__file__), '')) 240 | assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ') 241 | 242 | 243 | def test_basic_ca_private_key_file_bz2(monkeypatch): 244 | extra_environment_variables = { 245 | 'bless_ca_default_password': '', 246 | 'bless_ca_ca_private_key_file': 'tests/aws_lambda/only-use-for-unit-tests.pem.bz2', 247 | 'bless_ca_ca_private_key_compression': 'bz2', 248 | 'bless_options_username_validation': 'email', 249 | 'bless_options_remote_usernames_validation': 'useradd', 250 | } 251 | 252 | for k, v in extra_environment_variables.items(): 253 | monkeypatch.setenv(k, v) 254 | 255 | output = lambda_handler_user(VALID_TEST_REQUEST_USERNAME_VALIDATION_EMAIL_REMOTE_USERNAMES_USERADD, context=Context, 256 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 257 | entropy_check=False, 258 | config_file=os.path.join(os.path.dirname(__file__), '')) 259 | assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ') 260 | 261 | 262 | def test_basic_ca_private_key_env_bz2(monkeypatch): 263 | extra_environment_variables = { 264 | 'bless_ca_default_password': '', 265 | 'bless_ca_ca_private_key': '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', 266 | 'bless_ca_ca_private_key_compression': 'bz2', 267 | 'bless_options_username_validation': 'email', 268 | 'bless_options_remote_usernames_validation': 'useradd', 269 | } 270 | 271 | for k, v in extra_environment_variables.items(): 272 | monkeypatch.setenv(k, v) 273 | 274 | output = lambda_handler_user(VALID_TEST_REQUEST_USERNAME_VALIDATION_EMAIL_REMOTE_USERNAMES_USERADD, context=Context, 275 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 276 | entropy_check=False, 277 | config_file=os.path.join(os.path.dirname(__file__), '')) 278 | assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ') 279 | 280 | 281 | def test_basic_ca_private_key_file_zlib(monkeypatch): 282 | extra_environment_variables = { 283 | 'bless_ca_default_password': '', 284 | 'bless_ca_ca_private_key_file': 'tests/aws_lambda/only-use-for-unit-tests.zlib', 285 | 'bless_ca_ca_private_key_compression': 'zlib', 286 | 'bless_options_username_validation': 'email', 287 | 'bless_options_remote_usernames_validation': 'useradd', 288 | } 289 | 290 | for k, v in extra_environment_variables.items(): 291 | monkeypatch.setenv(k, v) 292 | 293 | output = lambda_handler_user(VALID_TEST_REQUEST_USERNAME_VALIDATION_EMAIL_REMOTE_USERNAMES_USERADD, context=Context, 294 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 295 | entropy_check=False, 296 | config_file=os.path.join(os.path.dirname(__file__), '')) 297 | assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ') 298 | 299 | 300 | def test_basic_ca_private_key_env_zlib(monkeypatch): 301 | extra_environment_variables = { 302 | 'bless_ca_default_password': '', 303 | 'bless_ca_ca_private_key': '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', 304 | 'bless_ca_ca_private_key_compression': 'zlib', 305 | 'bless_options_username_validation': 'email', 306 | 'bless_options_remote_usernames_validation': 'useradd', 307 | } 308 | 309 | for k, v in extra_environment_variables.items(): 310 | monkeypatch.setenv(k, v) 311 | 312 | output = lambda_handler_user(VALID_TEST_REQUEST_USERNAME_VALIDATION_EMAIL_REMOTE_USERNAMES_USERADD, context=Context, 313 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 314 | entropy_check=False, 315 | config_file=os.path.join(os.path.dirname(__file__), '')) 316 | assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ') 317 | 318 | 319 | def test_basic_ca_private_key_file_none_compression(monkeypatch): 320 | extra_environment_variables = { 321 | 'bless_ca_default_password': '', 322 | 'bless_ca_ca_private_key_file': 'tests/aws_lambda/only-use-for-unit-tests.pem', 323 | 'bless_ca_ca_private_key_compression': 'none', 324 | 'bless_options_username_validation': 'email', 325 | 'bless_options_remote_usernames_validation': 'useradd', 326 | } 327 | 328 | for k, v in extra_environment_variables.items(): 329 | monkeypatch.setenv(k, v) 330 | 331 | output = lambda_handler_user(VALID_TEST_REQUEST_USERNAME_VALIDATION_EMAIL_REMOTE_USERNAMES_USERADD, context=Context, 332 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 333 | entropy_check=False, 334 | config_file=os.path.join(os.path.dirname(__file__), '')) 335 | assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ') 336 | 337 | 338 | def test_invalid_uncompressed_with_zlib(monkeypatch): 339 | extra_environment_variables = { 340 | 'bless_ca_default_password': '', 341 | 'bless_ca_ca_private_key_file': 'tests/aws_lambda/only-use-for-unit-tests.pem', 342 | 'bless_ca_ca_private_key_compression': 'zlib', 343 | 'bless_options_username_validation': 'email', 344 | 'bless_options_remote_usernames_validation': 'useradd', 345 | } 346 | 347 | for k, v in extra_environment_variables.items(): 348 | monkeypatch.setenv(k, v) 349 | 350 | with pytest.raises(zlib.error): 351 | lambda_handler_user(VALID_TEST_REQUEST_USERNAME_VALIDATION_EMAIL_REMOTE_USERNAMES_USERADD, context=Context, 352 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 353 | entropy_check=False, 354 | config_file=os.path.join(os.path.dirname(__file__), '')) 355 | 356 | 357 | def test_invalid_uncompressed_with_bz2(monkeypatch): 358 | extra_environment_variables = { 359 | 'bless_ca_default_password': '', 360 | 'bless_ca_ca_private_key_file': 'tests/aws_lambda/only-use-for-unit-tests.pem', 361 | 'bless_ca_ca_private_key_compression': 'bz2', 362 | 'bless_options_username_validation': 'email', 363 | 'bless_options_remote_usernames_validation': 'useradd', 364 | } 365 | 366 | for k, v in extra_environment_variables.items(): 367 | monkeypatch.setenv(k, v) 368 | 369 | with pytest.raises(OSError): 370 | lambda_handler_user(VALID_TEST_REQUEST_USERNAME_VALIDATION_EMAIL_REMOTE_USERNAMES_USERADD, context=Context, 371 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 372 | entropy_check=False, 373 | config_file=os.path.join(os.path.dirname(__file__), '')) 374 | 375 | 376 | def test_invalid_username_request(): 377 | output = lambda_handler_user(INVALID_TEST_REQUEST_USERNAME_INVALID, context=Context, 378 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 379 | entropy_check=False, 380 | config_file=os.path.join(os.path.dirname(__file__), 381 | 'bless-test.cfg')) 382 | assert output['errorType'] == 'InputValidationError' 383 | 384 | 385 | def test_invalid_kmsauth_request(): 386 | output = lambda_handler_user(VALID_TEST_REQUEST_KMSAUTH, context=Context, 387 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 388 | entropy_check=False, 389 | config_file=os.path.join(os.path.dirname(__file__), 390 | 'bless-test-kmsauth.cfg')) 391 | assert output['errorType'] == 'KMSAuthValidationError' 392 | 393 | 394 | def test_invalid_request(): 395 | output = lambda_handler_user(INVALID_TEST_REQUEST, context=Context, 396 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 397 | entropy_check=False, 398 | config_file=os.path.join(os.path.dirname(__file__), 'bless-test.cfg')) 399 | assert output['errorType'] == 'InputValidationError' 400 | 401 | 402 | def test_local_request_key_not_found(): 403 | with pytest.raises(IOError): 404 | lambda_handler_user(VALID_TEST_REQUEST, context=Context, 405 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 406 | entropy_check=False, 407 | config_file=os.path.join(os.path.dirname(__file__), 'bless-test-broken.cfg')) 408 | 409 | 410 | def test_local_request_config_not_found(): 411 | with pytest.raises(ValueError): 412 | lambda_handler_user(VALID_TEST_REQUEST, context=Context, 413 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 414 | entropy_check=False, 415 | config_file=os.path.join(os.path.dirname(__file__), 'none')) 416 | 417 | 418 | def test_local_request_invalid_pub_key(): 419 | output = lambda_handler_user(INVALID_TEST_REQUEST_KEY_TYPE, context=Context, 420 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 421 | entropy_check=False, 422 | config_file=os.path.join(os.path.dirname(__file__), 'bless-test.cfg')) 423 | assert output['errorType'] == 'InputValidationError' 424 | 425 | 426 | def test_local_request_extra_field(): 427 | output = lambda_handler_user(INVALID_TEST_REQUEST_EXTRA_FIELD, context=Context, 428 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 429 | entropy_check=False, 430 | config_file=os.path.join(os.path.dirname(__file__), 'bless-test.cfg')) 431 | assert output['errorType'] == 'InputValidationError' 432 | 433 | 434 | def test_local_request_missing_field(): 435 | output = lambda_handler_user(INVALID_TEST_REQUEST_MISSING_FIELD, context=Context, 436 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 437 | entropy_check=False, 438 | config_file=os.path.join(os.path.dirname(__file__), 'bless-test.cfg')) 439 | assert output['errorType'] == 'InputValidationError' 440 | 441 | 442 | def test_local_request_with_test_user(): 443 | output = lambda_handler_user(VALID_TEST_REQUEST, context=Context, 444 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 445 | entropy_check=False, 446 | config_file=os.path.join(os.path.dirname(__file__), 'bless-test-with-test-user.cfg')) 447 | assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ') 448 | 449 | 450 | def test_local_request_with_custom_certificate_extensions(): 451 | output = lambda_handler_user(VALID_TEST_REQUEST, context=Context, 452 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 453 | entropy_check=False, 454 | config_file=os.path.join(os.path.dirname(__file__), 455 | 'bless-test-with-certificate-extensions.cfg')) 456 | assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ') 457 | 458 | 459 | def test_local_request_with_empty_certificate_extensions(): 460 | output = lambda_handler_user(VALID_TEST_REQUEST, context=Context, 461 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 462 | entropy_check=False, 463 | config_file=os.path.join(os.path.dirname(__file__), 464 | 'bless-test-with-certificate-extensions-empty.cfg')) 465 | assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ') 466 | 467 | 468 | def test_local_request_with_multiple_principals(): 469 | output = lambda_handler_user(VALID_TEST_REQUEST_MULTIPLE_PRINCIPALS, context=Context, 470 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 471 | entropy_check=False, 472 | config_file=os.path.join(os.path.dirname(__file__), 473 | 'bless-test.cfg')) 474 | assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ') 475 | 476 | 477 | def test_invalid_request_with_multiple_principals(): 478 | output = lambda_handler_user(INVALID_TEST_REQUEST_MULTIPLE_PRINCIPALS, context=Context, 479 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 480 | entropy_check=False, 481 | config_file=os.path.join(os.path.dirname(__file__), 482 | 'bless-test.cfg')) 483 | assert output['errorType'] == 'InputValidationError' 484 | 485 | 486 | def test_invalid_request_with_mismatched_bastion_and_remote(): 487 | ''' 488 | Test default kmsauth behavior, that a bastion_user and remote_usernames must match 489 | :return: 490 | ''' 491 | output = lambda_handler_user(INVALID_TEST_KMSAUTH_REQUEST_USERNAME_DOESNT_MATCH_REMOTE, context=Context, 492 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 493 | entropy_check=False, 494 | config_file=os.path.join(os.path.dirname(__file__), 495 | 'bless-test-kmsauth.cfg')) 496 | assert output['errorType'] == 'KMSAuthValidationError' 497 | 498 | 499 | def test_invalid_request_with_unallowed_remote(): 500 | output = lambda_handler_user(INVALID_TEST_KMSAUTH_REQUEST_DIFFERENT_REMOTE_USER, context=Context, 501 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 502 | entropy_check=False, 503 | config_file=os.path.join(os.path.dirname(__file__), 504 | 'bless-test-kmsauth-different-remote.cfg')) 505 | assert output['errorType'] == 'KMSAuthValidationError' 506 | 507 | 508 | def test_valid_request_with_allowed_remote(mocker): 509 | mocker.patch("kmsauth.KMSTokenValidator.decrypt_token") 510 | output = lambda_handler_user(VALID_TEST_KMSAUTH_REQUEST_DIFFERENT_REMOTE_USER, context=Context, 511 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 512 | entropy_check=False, 513 | config_file=os.path.join(os.path.dirname(__file__), 514 | 'bless-test-kmsauth-different-remote.cfg')) 515 | assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ') 516 | 517 | 518 | def test_valid_request_with_allowed_remote_and_allowed_iam_group(mocker): 519 | mocker.patch("kmsauth.KMSTokenValidator.decrypt_token") 520 | clientmock = mocker.MagicMock() 521 | clientmock.list_groups_for_user.return_value = {"Groups": [{"GroupName": "ssh-alloweduser"}]} 522 | botomock = mocker.patch('boto3.client') 523 | botomock.return_value = clientmock 524 | output = lambda_handler_user(VALID_TEST_KMSAUTH_REQUEST_DIFFERENT_REMOTE_USER, context=Context, 525 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 526 | entropy_check=False, 527 | config_file=os.path.join(os.path.dirname(__file__), 528 | 'bless-test-kmsauth-iam-group-validation.cfg')) 529 | assert output['certificate'].startswith('ssh-rsa-cert-v01@openssh.com ') 530 | 531 | 532 | def test_invalid_request_with_allowed_remote_and_not_allowed_iam_group(mocker): 533 | mocker.patch("kmsauth.KMSTokenValidator.decrypt_token") 534 | clientmock = mocker.MagicMock() 535 | clientmock.list_groups_for_user.return_value = {"Groups": [{"GroupName": "ssh-notalloweduser"}]} 536 | botomock = mocker.patch('boto3.client') 537 | botomock.return_value = clientmock 538 | output = lambda_handler_user(VALID_TEST_KMSAUTH_REQUEST_DIFFERENT_REMOTE_USER, context=Context, 539 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 540 | entropy_check=False, 541 | config_file=os.path.join(os.path.dirname(__file__), 542 | 'bless-test-kmsauth-iam-group-validation.cfg')) 543 | assert output['errorType'] == 'KMSAuthValidationError' 544 | 545 | 546 | def test_basic_local_request_blacklisted(monkeypatch): 547 | extra_environment_variables = { 548 | 'bless_options_remote_usernames_blacklist': 'root|balrog', 549 | } 550 | 551 | for k, v in extra_environment_variables.items(): 552 | monkeypatch.setenv(k, v) 553 | 554 | output = lambda_handler_user(INVALID_TEST_REQUEST_BLACKLISTED_REMOTE_USERNAME, context=Context, 555 | ca_private_key_password=RSA_CA_PRIVATE_KEY_PASSWORD, 556 | entropy_check=False, 557 | config_file=os.path.join(os.path.dirname(__file__), 'bless-test.cfg')) 558 | assert output['errorType'] == 'InputValidationError' 559 | --------------------------------------------------------------------------------