diff --git a/.gitignore b/.gitignore index 919c708..0f384be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,11 @@ *.pyc -.coverage -.cache .DS_Store -coverage.xml -venv/ -blessclient.run +/.coverage +/.pytest_cache +/.tox +blessclient.cfg blessclient.egg-info/ +blessclient.run build/ -blessclient.cfg +coverage.xml +venv/ diff --git a/.travis.yml b/.travis.yml index c693a5d..59faea6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,23 @@ sudo: false language: python - matrix: - include: - - python: "2.7" - -install: - - make develop - -script: - - make test + include: + - env: TOXENV=py36 + python: 3.6 + - env: TOXENV=py27 + python: 2.7 + # https://github.com/deadsnakes/travis-ci-python3.7-example + - env: TOXENV=py37 + sudo: required + dist: xenial + addons: + apt: + sources: + - sourceline: 'ppa:deadsnakes/ppa' + packages: + - python3.7-dev +install: pip install tox +script: tox +cache: + directories: + - $HOME/.cache/pip diff --git a/README.md b/README.md index a0bd2d4..ad209c4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ -# Blessclient +# Blessclient -- DEPRECATED + +**NOTE**: We have deprecated python-blessclient and it is no longer actively maintained. A recommended alternative is [blessclient in Go](https://github.com/chanzuckerberg/blessclient). + A client for interacting with [BLESS](https://github.com/lyft/bless) services from users' laptops. Blessclient optimizes to ensure that users can always use ssh as they normally would with a fixed key, with minimal delay. [Netflix's BLESS](https://github.com/netflix/bless) was designed to issue short-lived certificates to users after they logged into a bastion service, that would be used to authenticate the user to other hosts within the cluster. Lyft wanted to use ephemeral ssh certificates for our users too, but wanted to issue these certificates directly to users' laptops, instead of on the bastion. We were able to accomplish this by making a few modifications to Netflix's BLESS and deploying this project, blessclient, to our users' laptops. Doing this allowed Lyft to improve security by extending the existing multi-factor authentication (MFA) setup that we had with AWS to SSH, as well as simplifying our provisioning and deprovisioning process. diff --git a/blessclient.cfg.sample b/blessclient.cfg.sample index abdb673..43669ad 100644 --- a/blessclient.cfg.sample +++ b/blessclient.cfg.sample @@ -18,9 +18,13 @@ kms_service_name: bless-production # the internal IP of each should be listed here. bastion_ips: 10.100.1.230,192.168.200.0/24 -# remote_user: The remote username to authorize for SSH within the certificate. +# remote_user: The remote username to authorize for SSH within the certificate. # Defaults to the AWS user requesting the certificate +# ca_backend: This is the Certificate Authority (CA) backend that will be used in order to +# provide signed SSH certificates to the user. Can either be 'hashicorp-vault' or 'bless'. +ca_backend: bless + [CLIENT] # domain_regex: A (python) regex that is tested by the blessclient to determine if we need # to run bless and get a certificate, or if we can skip it. This prevents blessclient from @@ -58,6 +62,11 @@ update_script: update_blessclient.sh # tokens for when the user assumes the role necessary to call the BLESS Lambda. The default # is 3600 seconds (1 hour). The value must be in the range 900-3600. +# update_sshagent: Specifies whether the identity key should be automatically added to the +# running ssh-agent. If this option is set to 'true', the key and the ssh certificate retrieved +# from lambda are added to the agent. If this option is set to 'false', the key is not added +# to the agent. The default is 'true'. + [LAMBDA] # user_role: IAM Role that the user will assume, in order to run the BLESS Lambda. This # role should be in the same AWS account as your Lambda. @@ -102,3 +111,17 @@ kmsauthkey: 12345678-abab-cdcd-efef-123456789011 [REGION_EAST] awsregion: us-east-1 kmsauthkey: 22345678-abab-cdcd-efef-123456789012 + +[VAULT] +# vault_addr: Same as environment variable $VAULT_ADDR when using the CLI +vault_addr: https://vault.example.com:1234 + +# auth_mount: Specify the mount point for the desired authentication backend. +# Tested using Okta, but should work for others requiring only username/password. +auth_mount: okta + +# ssh_backend_mount: SSH Key signing backend mount point to use in HashiCorp Vault +ssh_backend_mount: ssh-client-signer + +# ssh_backend_role: SSH Key signing role to use with the above specified mount point. +ssh_backend_role: bless \ No newline at end of file diff --git a/blessclient/bless_aws.py b/blessclient/bless_aws.py index 8197270..0ce5b72 100644 --- a/blessclient/bless_aws.py +++ b/blessclient/bless_aws.py @@ -40,7 +40,7 @@ def iam_client(self): try: self.iam = boto3.client('iam') break - except DataNotFoundError as e: + except DataNotFoundError: logging.exception('DataNotFoundError when trying to get the iam client.') t = self.retry_policy(attempt) if t is None: diff --git a/blessclient/bless_cache.py b/blessclient/bless_cache.py index 33a37c2..04a7876 100644 --- a/blessclient/bless_cache.py +++ b/blessclient/bless_cache.py @@ -44,7 +44,7 @@ def loadCache(self): with open(cache_file_path, 'r') as cache: try: self.cache = json.load(cache) - except: + except Exception: logging.error("Corrupted cache, using empty cache") logging.debug("Cache loaded: {}".format(self.cache)) diff --git a/blessclient/bless_config.py b/blessclient/bless_config.py index 82de46e..05ea153 100644 --- a/blessclient/bless_config.py +++ b/blessclient/bless_config.py @@ -7,7 +7,9 @@ class BlessConfig(object): DEFAULT_CONFIG = { 'user_session_length': '64800', 'usebless_role_session_length': '3600', - 'remote_user': None, + 'update_sshagent': 'true', + 'remote_user': '', + 'ca_backend': 'bless', } def __init__(self): @@ -39,8 +41,10 @@ def parse_config_file(self, config_file): 'update_script': config.get('CLIENT', 'update_script'), 'user_session_length': int(config.get('CLIENT', 'user_session_length')), 'usebless_role_session_length': int(config.get('CLIENT', 'usebless_role_session_length')), + 'update_sshagent': config.getboolean('CLIENT', 'update_sshagent'), }, 'BLESS_CONFIG': { + 'ca_backend': config.get('MAIN', 'ca_backend'), 'userrole': config.get('LAMBDA', 'user_role'), 'accountid': config.get('LAMBDA', 'account_id'), 'functionname': config.get('LAMBDA', 'functionname'), @@ -59,6 +63,14 @@ def parse_config_file(self, config_file): 'REGION_ALIAS': {} } + if blessconfig['BLESS_CONFIG']['ca_backend'].lower() == 'hashicorp-vault': + blessconfig['VAULT_CONFIG'] = { + 'vault_addr': config.get('VAULT', 'vault_addr'), + 'auth_mount': config.get('VAULT', 'auth_mount'), + 'ssh_backend_mount': config.get('VAULT', 'ssh_backend_mount'), + 'ssh_backend_role': config.get('VAULT', 'ssh_backend_role'), + } + regions = config.get('MAIN', 'region_aliases').split(",") regions = [region.strip() for region in regions] for region in regions: diff --git a/blessclient/client.py b/blessclient/client.py index df1bd84..e8b2439 100755 --- a/blessclient/client.py +++ b/blessclient/client.py @@ -17,6 +17,8 @@ import copy import subprocess import json +import hvac +import getpass import six @@ -26,6 +28,7 @@ from .user_ip import UserIP from .bless_lambda import BlessLambda from .bless_config import BlessConfig +from .vault_ca import VaultCA from .lambda_invocation_exception import LambdaInvocationException import logging @@ -92,7 +95,7 @@ def get_regions(region, bless_config): List of regions """ regions = [] - aws_regions = tuple(bless_config.get('REGION_ALIAS').values()) + aws_regions = tuple(sorted(bless_config.get('REGION_ALIAS').values())) try: ndx = aws_regions.index(region) except ValueError: @@ -196,7 +199,7 @@ def get_mfa_token_cli(): def get_mfa_token_gui(message): sys.stderr.write( - "Enter your AWS MFA token in the gui dialog. Alternatively, run mfa.sh first.\n") + "Enter your AWS MFA token in the gui dialog.\n") tig = tokengui.TokenInputGUI() if message == 'BLESS': message = None @@ -248,7 +251,7 @@ def get_kmsauth_token(creds, config, username, cache): config['awsregion'], aws_creds=creds, token_lifetime=60 - ).get_token() + ).get_token().decode('US-ASCII') except kmsauth.ServiceConnectionError: logging.debug("Network failure for kmsauth") raise LambdaInvocationException('Connection error getting kmsauth token.') @@ -278,7 +281,7 @@ def setup_logging(): def get_bless_cache(nocache, bless_config): client_config = bless_config.get_client_config() cachedir = os.path.join( - os.getenv('HOME', os.getcwd()), + os.path.expanduser('~'), client_config['cache_dir']) cachemode = BlessCache.CACHEMODE_RECACHE if nocache else BlessCache.CACHEMODE_ENABLED return BlessCache(cachedir, client_config['cache_file'], cachemode) @@ -349,7 +352,7 @@ def save_cached_creds(token_data, bless_config): def ssh_agent_remove_bless(identity_file): DEVNULL = open(os.devnull, 'w') try: - current = subprocess.check_output(['ssh-add', '-l']) + current = subprocess.check_output(['ssh-add', '-l']).decode('UTF-8') match = re.search(re.escape(identity_file), current) if match: subprocess.check_call( @@ -362,7 +365,7 @@ def ssh_agent_remove_bless(identity_file): def ssh_agent_add_bless(identity_file): DEVNULL = open(os.devnull, 'w') subprocess.check_call(['ssh-add', identity_file], stderr=DEVNULL) - current = subprocess.check_output(['ssh-add', '-l']) + current = subprocess.check_output(['ssh-add', '-l']).decode('UTF-8') if not re.search(re.escape(identity_file), current): logging.debug("Could not add '{}' to ssh-agent".format(identity_file)) sys.stderr.write( @@ -386,10 +389,16 @@ def get_username(aws, bless_cache): awsmfautils.unset_token() user = aws.iam_client().get_user()['User'] except ClientError as e: + if e.response.get('Error', {}).get('Code') == 'SignatureDoesNotMatch': + sys.stderr.write( + "Your authentication signature was rejected by AWS; try checking your system " + + "date & timezone settings are correct\n") + raise + sys.stderr.write( "Can't get your user information from AWS! Either you don't have your user" " aws credentials set as [default] in ~/.aws/credentials, or you have another" - " process setting AWS credentials for a service account in your environment.") + " process setting AWS credentials for a service account in your environment.\n") raise username = user['UserName'] bless_cache.set('username', username) @@ -429,6 +438,203 @@ def update_config_from_env(bless_config): bless_config.set_lambda_config('ipcachelifetime', lifetime) +def get_linux_username(username): + """ + Returns a linux safe username. + :param username: Name of the user (could include @domain.com) + :return: Username string that complies with IEEE Std 1003.1-2001 + """ + match = re.search('[.a-zA-Z]+', username) + return match.group(0) + + +def get_cached_auth_token(bless_cache): + """ + Returns cached Vault auth token if available, otherwise None + :param bless_cache: Bless cache object + :return: Vault auth token or None if no valid token cached + """ + vault_creds = bless_cache.get('vault_creds') + if vault_creds is None or vault_creds['expiration'] is None: + return None + else: + expiration = vault_creds['expiration'] + if datetime.datetime.utcnow() < datetime.datetime.strptime(expiration, '%Y%m%dT%H%M%SZ'): + logging.debug( + 'Using cached vault token, good until {}'.format(expiration)) + return vault_creds['token'] + else: + return None + + +def get_credentials(): + print("Enter Vault username:") + username = six.moves.input() + password = getpass.getpass(prompt="Password (will be hidden):") + return username, password + + +def auth_okta(client, auth_mount, bless_cache): + """ + Authenticates a user in HashiCorp Vault using Okta + :param bless_cache: Bless Cache to cache auth token + :param auth_mount: Authentication mount point on Vault + :param client: HashiCorp Vault client + :return: Updated HashiCorp Vault client, and linux username + """ + + vault_auth_token = get_cached_auth_token(bless_cache) + if vault_auth_token is not None: + client.token = vault_auth_token + username = get_linux_username(bless_cache.get('vault_creds')['username']) + return client, get_linux_username(username) + else: + username, password = get_credentials() + auth_params = { + 'password': password + } + current_time = datetime.datetime.utcnow() + auth_url = '/v1/auth/{0}/login/{1}'.format(auth_mount, username) + response = client.auth(auth_url, json=auth_params) + + token = response['auth']['client_token'] + expiration = current_time + datetime.timedelta(seconds=response['auth']['lease_duration']) + username = get_linux_username(response['auth']['metadata']['username']) + vault_credentials_cache = { + "token": token, + "expiration": expiration.strftime('%Y%m%dT%H%M%SZ'), + "username": username + } + bless_cache.set('vault_creds', vault_credentials_cache) + bless_cache.save() + return client, get_linux_username(username) + + +def vault_bless(nocache, bless_config): + + vault_addr = bless_config.get('VAULT_CONFIG')['vault_addr'] + auth_mount = bless_config.get('VAULT_CONFIG')['auth_mount'] + bless_cache = get_bless_cache(nocache, bless_config) + bless_lambda_config = bless_config.get_lambda_config() + + user_ip = UserIP( + bless_cache=bless_cache, + maxcachetime=bless_lambda_config['ipcachelifetime'], + ip_urls=bless_config.get_client_config()['ip_urls'], + fixed_ip=os.getenv('BLESSFIXEDIP', False)) + + # Print feedback? + show_feedback = get_stderr_feedback() + + # Create client to connect to HashiCorp Vault + client = hvac.Client(url=vault_addr) + + # Identify the SSH key to be used + clistring = psutil.Process(os.getppid()).cmdline() + identity_file = get_idfile_from_cmdline( + clistring, + os.getenv('HOME', os.getcwd()) + '/.ssh/blessid' + ) + # Define the certificate to be created + cert_file = identity_file + '-cert.pub' + + logging.debug("Using identity file: {}".format(identity_file)) + + # Check if we can skip asking for MFA code + if nocache is not True: + if check_fresh_cert(cert_file, bless_lambda_config, bless_cache, user_ip): + logging.debug("Already have fresh cert") + sys.exit(0) + + # Print feedback information + if show_feedback: + sys.stderr.write( + "Requesting certificate for your public key" + + " (set BLESSQUIET=1 to suppress these messages)\n" + ) + + # Identify and load the public key to be signed + public_key_file = identity_file + '.pub' + with open(public_key_file, 'r') as f: + public_key = f.read() + + # Only sign public keys in correct format. + if public_key[:8] != 'ssh-rsa ': + raise Exception( + 'Refusing to bless {}. Probably not an identity file.'.format(identity_file)) + + # Authenticate user with HashiCorp Vault + client, linux_username = auth_okta(client, auth_mount, bless_cache) + + payload = { + 'valid_principals': linux_username, + 'public_key': public_key, + 'ttl': bless_config.get('BLESS_CONFIG')['certlifetime'], + 'ssh_backend_mount': bless_config.get('VAULT_CONFIG')['ssh_backend_mount'], + 'ssh_backend_role': bless_config.get('VAULT_CONFIG')['ssh_backend_role'] + } + + vault_ca = VaultCA(client) + try: + cert = vault_ca.getCert(payload) + except hvac.exceptions.Forbidden: + bless_cache = get_bless_cache(True, bless_config) + client, linux_username = auth_okta(client, auth_mount, bless_cache) + + payload = { + 'valid_principals': linux_username, + 'public_key': public_key, + 'ttl': bless_config.get('BLESS_CONFIG')['certlifetime'], + 'ssh_backend_mount': bless_config.get('VAULT_CONFIG')['ssh_backend_mount'], + 'ssh_backend_role': bless_config.get('VAULT_CONFIG')['ssh_backend_role'] + } + + vault_ca = VaultCA(client) + cert = vault_ca.getCert(payload) + + logging.debug("Got back cert: {}".format(cert)) + + # Error handling + if cert[:29] != 'ssh-rsa-cert-v01@openssh.com ': + error_msg = json.loads(cert) + if ('errorType' in error_msg + and error_msg['errorType'] == 'KMSAuthValidationError' + and nocache is False + ): + logging.debug("KMSAuth error with cached token, purging cache.") + # clear_kmsauth_token_cache(kmsauth_config, bless_cache) + raise LambdaInvocationException('KMSAuth validation error') + + if ('errorType' in error_msg and + error_msg['errorType'] == 'ClientError'): + raise LambdaInvocationException( + 'The BLESS lambda experienced a client error. Consider trying in a different region.' + ) + + if ('errorType' in error_msg and + error_msg['errorType'] == 'InputValidationError'): + raise Exception( + 'The input to the BLESS lambda is invalid. ' + 'Please update your blessclient by running `make update` ' + 'in the bless folder.') + + raise LambdaInvocationException( + 'BLESS client did not recieve a valid cert. Instead got: {}'.format(cert)) + + # Remove old certificate, replacing with new certificate + ssh_agent_remove_bless(identity_file) + with open(cert_file, 'w') as cert_file: + cert_file.write(cert) + ssh_agent_add_bless(identity_file) + + # bless_cache.set('certip', my_ip) + # bless_cache.save() + + logging.debug("Successfully issued cert!") + if show_feedback: + sys.stderr.write("Finished getting certificate.\n") + + def bless(region, nocache, showgui, hostname, bless_config): # Setup loggging setup_logging() @@ -447,7 +653,7 @@ def bless(region, nocache, showgui, hostname, bless_config): clistring = psutil.Process(os.getppid()).cmdline() identity_file = get_idfile_from_cmdline( clistring, - os.getenv('HOME', os.getcwd()) + '/.ssh/blessid' + os.path.expanduser('~/.ssh/blessid'), ) cert_file = identity_file + '-cert.pub' @@ -482,7 +688,7 @@ def bless(region, nocache, showgui, hostname, bless_config): role_creds = get_blessrole_credentials( aws.iam_client(), None, bless_config, bless_cache) logging.debug("Default creds used to assume role use-bless") - except: + except Exception: pass # TODO if role_creds is None: @@ -501,7 +707,7 @@ def bless(region, nocache, showgui, hostname, bless_config): role_creds = get_blessrole_credentials( aws.iam_client(), creds, bless_config, bless_cache) logging.debug("Assumed role use-bless using cached creds") - except: + except Exception: pass if role_creds is None: @@ -550,9 +756,7 @@ def bless(region, nocache, showgui, hostname, bless_config): my_ip = userIP.getIP() ip_list = "{},{}".format(my_ip, bless_config.get_aws_config()['bastion_ips']) - remote_user = bless_config.get_aws_config()['remote_user'] - if remote_user is None: - remote_user = username + remote_user = bless_config.get_aws_config()['remote_user'] or username payload = { 'bastion_user': username, 'bastion_user_ip': my_ip, @@ -591,10 +795,18 @@ def bless(region, nocache, showgui, hostname, bless_config): raise LambdaInvocationException( 'BLESS client did not recieve a valid cert. Instead got: {}'.format(cert)) + # Remove RSA identity from ssh-agent (if it exists) ssh_agent_remove_bless(identity_file) with open(cert_file, 'w') as cert_file: cert_file.write(cert) - ssh_agent_add_bless(identity_file) + + # Check if we can skip adding identity into the running ssh-agent + if bless_config.get_client_config()['update_sshagent'] is True: + ssh_agent_add_bless(identity_file) + else: + logging.info( + "Skipping loading identity into the running ssh-agent " + 'because this was disabled in the blessclient config.') bless_cache.set('certip', my_ip) bless_cache.save() @@ -639,22 +851,36 @@ def main(): config_filename = args.config if args.config else get_default_config_filename() with open(config_filename, 'r') as f: bless_config.set_config(bless_config.parse_config_file(f)) + ca_backend = bless_config.get('BLESS_CONFIG')['ca_backend'] if re.match(bless_config.get_client_config()['domain_regex'], args.host) or args.host == 'BLESS': start_region = get_region_from_code(args.region, bless_config) success = False for region in get_regions(start_region, bless_config): try: - bless(region, args.nocache, args.gui, args.host, bless_config) - success = True + if ca_backend.lower() == 'hashicorp-vault': + vault_bless(args.nocache, bless_config) + success = True + elif ca_backend.lower() == 'bless': + bless(region, args.nocache, args.gui, args.host, bless_config) + success = True + else: + sys.stderr.write('{0} is an invalid CA backend'.format(ca_backend)) + sys.exit(1) break - except (ClientError, LambdaInvocationException, ConnectionError, - EndpointConnectionError) as e: + except ClientError as e: + if e.response.get('Error', {}).get('Code') == 'InvalidSignatureException': + sys.stderr.write( + 'Your authentication signature was rejected by AWS; try checking your system ' + + 'date & timezone settings are correct\n') + logging.info( + 'Lambda execution error: {}. Trying again in the alternate region.'.format(str(e))) + except (LambdaInvocationException, ConnectionError, EndpointConnectionError) as e: logging.info( 'Lambda execution error: {}. Trying again in the alternate region.'.format(str(e))) if success: sys.exit(0) else: - sys.stderr.write('Could not connect to BLESS in any configured region.\n') + sys.stderr.write('Could not sign SSH public key.\n') sys.exit(1) else: sys.exit(1) diff --git a/blessclient/tokengui.py b/blessclient/tokengui.py index 99f97b9..5728986 100644 --- a/blessclient/tokengui.py +++ b/blessclient/tokengui.py @@ -1,7 +1,8 @@ from __future__ import absolute_import import platform import os -from Tkinter import Tk, Label, Entry, Button, ACTIVE, W, mainloop +import subprocess +from six.moves.tkinter import Tk, Label, Entry, Button, ACTIVE, W, mainloop class TokenInputGUI(object): @@ -32,19 +33,13 @@ def doGUI(self, hostname=None): self.master.attributes('-topmost', True) self.master.focus_force() self.e1.focus_set() + if platform.system() == 'Darwin': - try: - from Cocoa import ( - NSRunningApplication, - NSApplicationActivateIgnoringOtherApps - ) - - app = NSRunningApplication.runningApplicationWithProcessIdentifier_( - os.getpid() - ) - app.activateWithOptions_(NSApplicationActivateIgnoringOtherApps) - except ImportError: - pass + # Hack to get the GUI dialog focused in OSX + # https://stackoverflow.com/questions/1892339/how-to-make-a-tkinter-window-jump-to-the-front/37235492#37235492 + tmpl = 'tell application "System Events" to set frontmost of every process whose unix id is {} to true' + script = tmpl.format(os.getpid()) + subprocess.check_call(['/usr/bin/osascript', '-e', script]) mainloop() diff --git a/blessclient/user_ip.py b/blessclient/user_ip.py index 714c1c4..fd72037 100644 --- a/blessclient/user_ip.py +++ b/blessclient/user_ip.py @@ -53,12 +53,12 @@ def _fetchIP(self, url): try: with contextlib.closing(urlopen(url, timeout=2)) as f: if f.getcode() == 200: - content = f.read().strip()[:40] + content = f.read().decode().strip()[:40] for c in content: if c not in VALID_IP_CHARACTERS: raise ValueError("Public IP response included invalid character '{}'.".format(c)) return content - except: + except Exception: logging.debug('Could not refresh public IP from {}'.format(url), exc_info=True) return None diff --git a/blessclient/vault_ca.py b/blessclient/vault_ca.py new file mode 100644 index 0000000..3f8aeaa --- /dev/null +++ b/blessclient/vault_ca.py @@ -0,0 +1,22 @@ +class VaultCA(object): + + def __init__(self, client): + self.client = client + + def getCert(self, payload): + client = self.client + ssh_url = '{0}/sign/{1}'.format(payload['ssh_backend_mount'], payload['ssh_backend_role']) + response = client.write( + ssh_url, + valid_principals=payload['valid_principals'], + public_key=payload['public_key'], + ttl=payload['ttl'] + ) + + # EXTRACT PAYLOAD FROM RESPONSE + payload = response['data'] + if 'signed_key' not in payload: + raise Exception('No certificate in response.') + + # RETURN CERTIFICATE IF ALL GOES WELL + return payload['signed_key'] diff --git a/setup.cfg b/setup.cfg index 2d75bb0..31f59ba 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,7 +17,7 @@ format=pylint # The current value is set so that the build doesn't fail. At least we won't # make the software more complex. # We should target 10. Likewise, 90 for line length. -max-complexity = 23 +max-complexity = 25 max-line-length = 126 exclude = .git,__pycache__,venv,tests/ -ignore = E402, E124, W503 +ignore = E402, E124, W503, W504 diff --git a/setup.py b/setup.py index 16d6402..de7dd33 100644 --- a/setup.py +++ b/setup.py @@ -2,13 +2,14 @@ setup( name="blessclient", - version="0.2.0", + version="0.3.0", packages=find_packages(exclude=["test*"]), install_requires=[ 'boto3>=1.4.0,<2.0.0', 'psutil>=4.3', 'kmsauth>=0.1.8', 'six', + 'hvac' ], author="Chris Steipp", author_email="csteipp@lyft.com", diff --git a/tests/blessclient/bless_config_test.py b/tests/blessclient/bless_config_test.py index e0c8c0f..1cf77c7 100644 --- a/tests/blessclient/bless_config_test.py +++ b/tests/blessclient/bless_config_test.py @@ -5,8 +5,17 @@ from blessclient.bless_config import BlessConfig -TEST_CONFIG = """ +BLESS_CONFIG = """ +[MAIN] +ca_backend: bless +""" + +VAULT_CONFIG = """ [MAIN] +ca_backend: hashicorp-vault +""" + +TEST_CONFIG = """ region_aliases: iad, SFO kms_service_name: bless-production bastion_ips: 10.0.0.0/8,192.168.192.1 @@ -21,6 +30,7 @@ ip_urls: http://checkip.amazonaws.com, http://api.ipify.org update_script: autoupdate.sh user_session_length: 3600 +update_sshagent: false [LAMBDA] user_role: use-bless @@ -39,11 +49,57 @@ [REGION_IAD] awsregion: us-east-1 kmsauthkey: zxywvuts-0123-4567-8910-abcdefghijkl + +[VAULT] +vault_addr: https://vault.example.com:1234 +auth_mount: okta +ssh_backend_mount: ssh-client-signer +ssh_backend_role: bless """ +BASE_EXPECTED_CONF = { + 'KMSAUTH_CONFIG_SFO': { + 'kmskey': 'abcdefgh-0123-4567-8910-abcdefghijkl', + 'awsregion': 'us-west-2', + 'context': {'to': 'bless-production', 'user_type': 'user'} + }, + 'KMSAUTH_CONFIG_IAD': { + 'kmskey': 'zxywvuts-0123-4567-8910-abcdefghijkl', + 'awsregion': 'us-east-1', + 'context': {'to': 'bless-production', 'user_type': 'user'} + }, + 'REGION_ALIAS': {'IAD': 'us-east-1', 'SFO': 'us-west-2'}, + 'BLESS_CONFIG': { + 'ca_backend': 'bless', + 'ipcachelifetime': 60, + 'functionname': 'lyft_bless', + 'functionversion': 'PROD-1-2', + 'userrole': 'use-bless', + 'timeoutconfig': {'read': 10, 'connect': 5}, + 'certlifetime': 120, + 'accountid': '111111111111' + }, + 'AWS_CONFIG': { + 'bastion_ips': '10.0.0.0/8,192.168.192.1', + 'remote_user': 'foo' + }, + 'CLIENT_CONFIG': { + 'domain_regex': '(i-.*|.*\\.example\\.com|\\A10\\.0(?:\\.[0-9]{1,3}){2}\\Z)$', + 'cache_file': 'bless_cache.json', + 'mfa_cache_dir': '.aws-mfa/session', + 'cache_dir': '.aws-mfa/session', + 'mfa_cache_file': 'token_cache.json', + 'ip_urls': ['http://checkip.amazonaws.com', 'http://api.ipify.org'], + 'update_script': 'autoupdate.sh', + 'user_session_length': 3600, + 'usebless_role_session_length': 3600, # comes from BlessConfig.DEFAULT_CONFIG + 'update_sshagent': False + } +} + @pytest.fixture def bless_config_test(): - configIO = StringIO(TEST_CONFIG) + configIO = StringIO(BLESS_CONFIG + TEST_CONFIG) config = BlessConfig() config.set_config(config.parse_config_file(configIO)) return config @@ -74,47 +130,25 @@ def test_get_config(): assert config.get_config() == {'foo': 'bar'} -def test_load_config(): +def test_load_bless_config(): + config = BlessConfig() + configIO = StringIO(BLESS_CONFIG + TEST_CONFIG) + conf = config.parse_config_file(configIO) + assert conf == BASE_EXPECTED_CONF + +def test_load_vault_config(): config = BlessConfig() - configIO = StringIO(TEST_CONFIG) + configIO = StringIO(VAULT_CONFIG + TEST_CONFIG) conf = config.parse_config_file(configIO) - assert conf == { - 'KMSAUTH_CONFIG_SFO': { - 'kmskey': 'abcdefgh-0123-4567-8910-abcdefghijkl', - 'awsregion': 'us-west-2', - 'context': {'to': 'bless-production', 'user_type': 'user'} - }, - 'KMSAUTH_CONFIG_IAD': { - 'kmskey': 'zxywvuts-0123-4567-8910-abcdefghijkl', - 'awsregion': 'us-east-1', - 'context': {'to': 'bless-production', 'user_type': 'user'} - }, - 'REGION_ALIAS': {'IAD': 'us-east-1', 'SFO': 'us-west-2'}, - 'BLESS_CONFIG': { - 'ipcachelifetime': 60, - 'functionname': 'lyft_bless', - 'functionversion': 'PROD-1-2', - 'userrole': 'use-bless', - 'timeoutconfig': {'read': 10, 'connect': 5}, - 'certlifetime': 120, - 'accountid': '111111111111' - }, - 'AWS_CONFIG': { - 'bastion_ips': '10.0.0.0/8,192.168.192.1', - 'remote_user': 'foo' - }, - 'CLIENT_CONFIG': { - 'domain_regex': '(i-.*|.*\\.example\\.com|\\A10\\.0(?:\\.[0-9]{1,3}){2}\\Z)$', - 'cache_file': 'bless_cache.json', - 'mfa_cache_dir': '.aws-mfa/session', - 'cache_dir': '.aws-mfa/session', - 'mfa_cache_file': 'token_cache.json', - 'ip_urls': ['http://checkip.amazonaws.com', 'http://api.ipify.org'], - 'update_script': 'autoupdate.sh', - 'user_session_length': 3600, - 'usebless_role_session_length': 3600, # comes from BlessConfig.DEFAULT_CONFIG - } + expectedConf = BASE_EXPECTED_CONF + expectedConf['VAULT_CONFIG'] = { + 'vault_addr': 'https://vault.example.com:1234', + 'auth_mount': 'okta', + 'ssh_backend_mount': 'ssh-client-signer', + 'ssh_backend_role': 'bless' } + expectedConf['BLESS_CONFIG']['ca_backend'] = "hashicorp-vault" + assert conf == expectedConf def test_get_region_alias_from_aws_region(bless_config_test): @@ -126,6 +160,8 @@ def test_get_region_alias_from_aws_region(bless_config_test): def test_get_configs(bless_config_test): client_config = bless_config_test.get_client_config() assert 'domain_regex' in client_config + assert bool(client_config['update_sshagent']) is False + assert type(client_config['update_sshagent']).__name__ == 'bool' lambda_config = bless_config_test.get_lambda_config() assert 'functionname' in lambda_config aws_config = bless_config_test.get_aws_config() diff --git a/tests/blessclient/client_test.py b/tests/blessclient/client_test.py index 8412060..47d6f9b 100644 --- a/tests/blessclient/client_test.py +++ b/tests/blessclient/client_test.py @@ -23,6 +23,7 @@ def bless_config(): 'accountid': '111111111111' }, 'CLIENT_CONFIG': { + 'ip_urls': 'http://api.ipify.org, http://canihazip.com', 'domain_regex': '(i-.*|.*\\.example\\.com|\\A10\\.0(?:\\.[0-9]{1,3}){2}\\Z)$', 'cache_dir': '.aws-mfa/session', 'cache_file': 'bless_cache.json', @@ -39,6 +40,12 @@ def bless_config(): 'kmskey': 'zxywvuts-0123-4567-8910-abcdefghijkl', 'awsregion': 'us-east-1', 'context': {'to': 'bless-production', 'user_type': 'user'} + }, + 'VAULT_CONFIG': { + 'vault_addr': 'https://vault.example.com:1234', + 'auth_mount': 'okta', + 'ssh_backend_mount': 'ssh-client-signer', + 'ssh_backend_role': 'bless' } }) return bc @@ -85,7 +92,7 @@ def test_clear_kmsauth_token_cache(null_bless_cache): def test_get_kmsauth_token(mocker, null_bless_cache): tokenmock = mocker.MagicMock() - tokenmock.get_token.return_value = 'KMSTOKEN' + tokenmock.get_token.return_value = b'KMSTOKEN' genermock = mocker.patch('kmsauth.KMSTokenGenerator') genermock.return_value = tokenmock kmsconfig = {'awsregion': 'us-east-1', 'context': {}, 'kmskey': None} @@ -143,7 +150,7 @@ def test_uncache_creds(): def test_ssh_agent_remove_bless(mocker): outputmock = mocker.patch('subprocess.check_output') - outputmock.return_value = '4096 SHA256:hwnh3ccCcxVUo6T6htWvHdkCx/UsNklwy2uQuiBaTLQ /Users/foobar/.ssh/blessid (RSA-CERT)' + outputmock.return_value = b'4096 SHA256:hwnh3ccCcxVUo6T6htWvHdkCx/UsNklwy2uQuiBaTLQ /Users/foobar/.ssh/blessid (RSA-CERT)' callmock = mocker.patch('subprocess.check_call') client.ssh_agent_remove_bless('blessid') outputmock.assert_called_once() @@ -152,7 +159,7 @@ def test_ssh_agent_remove_bless(mocker): def test_ssh_agent_add_bless(mocker): outputmock = mocker.patch('subprocess.check_output') - outputmock.return_value = '4096 SHA256:hwnh3ccCcxVUo6T6htWvHdkCx/UsNklwy2uQuiBaTLQ /Users/foobar/.ssh/blessid (RSA-CERT)' + outputmock.return_value = b'4096 SHA256:hwnh3ccCcxVUo6T6htWvHdkCx/UsNklwy2uQuiBaTLQ /Users/foobar/.ssh/blessid (RSA-CERT)' callmock = mocker.patch('subprocess.check_call') client.ssh_agent_add_bless('.ssh/blessid') outputmock.assert_called_once() @@ -161,7 +168,7 @@ def test_ssh_agent_add_bless(mocker): def test_ssh_agent_add_bless_failed(mocker): outputmock = mocker.patch('subprocess.check_output') - outputmock.return_value = '' + outputmock.return_value = b'' callmock = mocker.patch('subprocess.check_call') logmock = mocker.patch('logging.debug') writemock = mocker.patch('sys.stderr.write') @@ -254,3 +261,94 @@ def null_bless_cache(): bless_cache = BlessCache(None, None, BlessCache.CACHEMODE_DISABLED) bless_cache.cache = {} return bless_cache + + +def test_get_linux_username_Email(): + username = client.get_linux_username("john.doe@example.com") + assert username == "john.doe" + + +def test_get_linux_username_EmailWithSpecialChars(): + username = client.get_linux_username("john.doe+test!#$%&'*+-/=?^_`{|}~abc@example.com") + assert username == "john.doe" + + +def test_get_cached_auth_token_isEmpty(null_bless_cache): + cache = null_bless_cache + returned = client.get_cached_auth_token(cache) + assert returned == None + + +def test_get_cached_auth_token_isValid(mocker): + cachemock = mocker.MagicMock() + cachemock.get.return_value = { + "token": "test-token", + "expiration": ( + datetime.datetime.utcnow() + + datetime.timedelta(hours=1) + ).strftime('%Y%m%dT%H%M%SZ'), + "username": "john.doe" + } + returned = client.get_cached_auth_token(cachemock) + assert returned == "test-token" + + +def test_get_cached_auth_token_isExpired(mocker): + cachemock = mocker.MagicMock() + cachemock.get.return_value = { + "token": "test-token", + "expiration": ( + datetime.datetime.utcnow() - + datetime.timedelta(hours=1) + ).strftime('%Y%m%dT%H%M%SZ'), + "username": "john.doe" + } + returned = client.get_cached_auth_token(cachemock) + assert returned == None + + +@pytest.fixture(scope='module') +def mock_get_credentials(): + username = "john.doe" + password = "password" + + def mockreturn(): + return username, password + + return mockreturn + + +def test_auth_okta_noCache(mocker, monkeypatch, null_bless_cache, mock_get_credentials): + + clientmock = mocker.MagicMock() + clientmock.auth.return_value = { + "auth": { + "client_token": "test-token", + "lease_duration": 500, + "metadata": { + "username": "john.doe" + } + } + } + + monkeypatch.setattr(client, 'get_credentials', mock_get_credentials) + new_client, new_username = client.auth_okta(clientmock, "test_mount", null_bless_cache) + assert new_username == "john.doe" + + +def test_auth_okta_Cache(mocker): + class MockClient(object): + def __init__(self): + self.token = "test-token" + + cachemock = mocker.MagicMock() + cachemock.get.return_value = { + "token": "test-token", + "expiration": ( + datetime.datetime.utcnow() + + datetime.timedelta(hours=1) + ).strftime('%Y%m%dT%H%M%SZ'), + "username": "john.doe" + } + new_client, new_username = client.auth_okta(MockClient(), "test", cachemock) + assert new_username == "john.doe" diff --git a/tests/blessclient/vault_ca_test.py b/tests/blessclient/vault_ca_test.py new file mode 100644 index 0000000..1c0f6c3 --- /dev/null +++ b/tests/blessclient/vault_ca_test.py @@ -0,0 +1,57 @@ +import pytest +from blessclient.vault_ca import VaultCA +import hvac + + +TESTVAULTCONFIG = { + 'vault_addr': 'https://vault.example.com:1234' +} + + +def test_getCert(mocker): + clientmock = mocker.MagicMock() + clientmock.write.return_value = { + 'StatusCode': 200, + 'data': { + 'signed_key': "The Cert" + } + } + hvacmock = mocker.patch('hvac.Client') + hvacmock.return_value = clientmock + client = hvac.Client(TESTVAULTCONFIG['vault_addr']) + vault_ca = VaultCA(client) + returned = vault_ca.getCert( + { + 'ssh_backend_mount': 'foo', + 'ssh_backend_role': 'bar', + 'valid_principals': 'test', + 'public_key': 'ssh-rsa stuff', + 'ttl': '500' + } + ) + assert returned == 'The Cert' + + +def test_getCert_NoCert(mocker): + clientmock = mocker.MagicMock() + clientmock.write.return_value = { + 'StatusCode': 403, + 'data': { + 'error': "Forbidden" + } + } + hvacmock = mocker.patch('hvac.Client') + hvacmock.return_value = clientmock + client = hvac.Client(TESTVAULTCONFIG['vault_addr']) + vault_ca = VaultCA(client) + with pytest.raises(Exception) as excinfo: + vault_ca.getCert( + { + 'ssh_backend_mount': 'foo', + 'ssh_backend_role': 'bar', + 'valid_principals': 'test', + 'public_key': 'ssh-rsa stuff', + 'ttl': '500' + } + ) + assert 'No certificate in response.' in str(excinfo.value) \ No newline at end of file diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..6e0cb2b --- /dev/null +++ b/tox.ini @@ -0,0 +1,8 @@ +[tox] +envlist = py27,py36 + +[testenv] +deps = -rrequirements-dev.txt +commands = + pytest {posargs:tests} + flake8 blessclient tests setup.py