├── .coveragerc ├── .gitignore ├── .travis.yml ├── AUTHORS ├── ISSUES.md ├── LICENSE ├── Makefile ├── NOTICE ├── OSSMETADATA ├── README.md ├── bless ├── __about__.py ├── __init__.py ├── aws_lambda │ ├── __init__.py │ ├── bless_lambda.py │ ├── bless_lambda_common.py │ ├── bless_lambda_host.py │ └── bless_lambda_user.py ├── cache │ ├── __init__.py │ └── bless_lambda_cache.py ├── config │ ├── __init__.py │ ├── bless_config.py │ └── bless_deploy_example.cfg ├── request │ ├── __init__.py │ ├── bless_request_common.py │ ├── bless_request_host.py │ └── bless_request_user.py └── ssh │ ├── __init__.py │ ├── certificate_authorities │ ├── __init__.py │ ├── rsa_certificate_authority.py │ ├── ssh_certificate_authority.py │ └── ssh_certificate_authority_factory.py │ ├── certificates │ ├── __init__.py │ ├── ed25519_certificate_builder.py │ ├── rsa_certificate_builder.py │ ├── ssh_certificate_builder.py │ └── ssh_certificate_builder_factory.py │ ├── protocol │ ├── __init__.py │ └── ssh_protocol.py │ └── public_keys │ ├── __init__.py │ ├── ed25519_public_key.py │ ├── rsa_public_key.py │ ├── ssh_public_key.py │ └── ssh_public_key_factory.py ├── bless_client ├── __init__.py ├── bless_client.py └── bless_client_host.py ├── bless_logo.png ├── lambda_compile.sh ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── aws_lambda ├── __init__.py ├── bless-test-broken.cfg ├── bless-test-kmsauth-different-remote.cfg ├── bless-test-kmsauth-iam-group-validation.cfg ├── bless-test-kmsauth.cfg ├── bless-test-with-certificate-extensions-empty.cfg ├── bless-test-with-certificate-extensions.cfg ├── bless-test-with-test-user.cfg ├── bless-test.cfg ├── only-use-for-unit-tests.pem ├── only-use-for-unit-tests.pem.bz2 ├── only-use-for-unit-tests.zlib ├── test_bless_lambda_host.py └── test_bless_lambda_user.py ├── config ├── __init__.py ├── full-with-default.cfg ├── full-with-kmsauth.cfg ├── full-zlib.cfg ├── full.cfg ├── minimal.cfg └── test_bless_config.py ├── request ├── __init__.py ├── test_bless_request_host.py └── test_bless_request_user.py └── ssh ├── __init__.py ├── test_ssh_certificate_authority_factory.py ├── test_ssh_certificate_builder_factory.py ├── test_ssh_certificate_rsa.py ├── test_ssh_protocol.py ├── test_ssh_public_key_ed25519.py ├── test_ssh_public_key_factory.py ├── test_ssh_public_key_rsa.py └── vectors.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | include = 3 | bless/*.py 4 | omit = 5 | bless/__about__.py 6 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | addons: 4 | 5 | matrix: 6 | include: 7 | - python: "3.7" 8 | - python: "3.8" 9 | 10 | install: 11 | - pip install coveralls 12 | - make develop 13 | 14 | before_script: 15 | 16 | script: 17 | - make lint 18 | - make coverage 19 | 20 | after_success: 21 | - coveralls 22 | - coverage report 23 | 24 | notifications: 25 | email: 26 | russelll@netflix.com 27 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | - Russell Lewis 2 | -------------------------------------------------------------------------------- /ISSUES.md: -------------------------------------------------------------------------------- 1 | This project is deprecated and will no longer be addressing issues. 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /OSSMETADATA: -------------------------------------------------------------------------------- 1 | osslifecycle=archived 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Archived 2 | With the existence of more SSH certificate tools since the release of BLESS, and better SSH access management from AWS, we're moving BLESS to the archived OSS project state. This means we no longer plan to maintain the project, but will be keeping it public for others who may still use it. 3 | 4 | ![alt text](bless_logo.png "BLESS") 5 | # BLESS - Bastion's Lambda Ephemeral SSH Service 6 | [![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)]() 7 | 8 | BLESS is an SSH Certificate Authority that runs as an AWS Lambda function and is used to sign SSH 9 | public keys. 10 | 11 | SSH Certificates are an excellent way to authorize users to access a particular SSH host, 12 | as they can be restricted for a single use case, and can be short lived. Instead of managing the 13 | authorized_keys of a host, or controlling who has access to SSH Private Keys, hosts just 14 | need to be configured to trust an SSH CA. 15 | 16 | BLESS should be run as an AWS Lambda in an isolated AWS account. Because BLESS needs access to a 17 | private key which is trusted by your hosts, an isolated AWS account helps restrict who can access 18 | that private key, or modify the BLESS code you are running. 19 | 20 | AWS Lambda functions can use an AWS IAM Policy to limit which IAM Roles can invoke the Lambda 21 | Function. If properly configured, you can restrict which IAM Roles can request SSH Certificates. 22 | For example, your SSH Bastion (aka SSH Jump Host) can run with the only IAM Role with access to 23 | invoke a BLESS Lambda Function configured with the SSH CA key trusted by the instances accessible 24 | to that SSH Bastion. 25 | 26 | ## Getting Started 27 | These instructions are to get BLESS up and running in your local development environment. 28 | ### Installation Instructions 29 | Clone the repo: 30 | 31 | $ git clone git@github.com:Netflix/bless.git 32 | 33 | Cd to the bless repo: 34 | 35 | $ cd bless 36 | 37 | Create a virtualenv if you haven't already: 38 | 39 | $ python3.8 -m venv venv 40 | 41 | Activate the venv: 42 | 43 | $ source venv/bin/activate 44 | 45 | Install package and test dependencies: 46 | 47 | (venv) $ make develop 48 | 49 | Run the tests: 50 | 51 | (venv) $ make test 52 | 53 | 54 | ## Deployment 55 | To deploy an AWS Lambda Function, you need to provide a .zip with the code and all dependencies. 56 | The .zip must contain your lambda code and configurations at the top level of the .zip. The BLESS 57 | Makefile includes a publish target to package up everything into a deploy-able .zip if they are in 58 | the expected locations. You will need to setup your own Python 3.7 lambda to deploy the .zip to. 59 | 60 | Previously the AWS Lambda Handler needed to be set to `bless_lambda.lambda_handler`, and this would generate a user 61 | cert. `bless_lambda.lambda_handler` still works for user certs. `bless_lambda_user.lambda_handler_user` is a handler 62 | that can also be used to issue user certificates. 63 | 64 | A new handler `bless_lambda_host.lambda_handler_host` has been created to allow for the creation of host SSH certs. 65 | 66 | All three handlers exist in the published .zip. 67 | 68 | ### Compiling BLESS Lambda Dependencies 69 | To deploy code as a Lambda Function, you need to package up all of the dependencies. You will need to 70 | compile and include your dependencies before you can publish a working AWS Lambda. 71 | 72 | BLESS uses a docker container running [Amazon Linux 2](https://hub.docker.com/_/amazonlinux) to package everything up: 73 | - Execute ```make lambda-deps``` and this will run a container and save all the dependencies in ./aws_lambda_libs 74 | 75 | ### Protecting the CA Private Key 76 | - Generate a password protected RSA Private Key in the PEM format: 77 | ``` 78 | $ ssh-keygen -t rsa -b 4096 -m PEM -f bless-ca- -C "SSH CA Key" 79 | ``` 80 | - **Note:** OpenSSH Private Key format is not supported. 81 | - Use KMS to encrypt your password. You will need a KMS key per region, and you will need to 82 | encrypt your password for each region. You can use the AWS Console to paste in a simple lambda 83 | function like this: 84 | ``` 85 | import boto3 86 | import base64 87 | import os 88 | 89 | 90 | def lambda_handler(event, context): 91 | region = os.environ['AWS_REGION'] 92 | client = boto3.client('kms', region_name=region) 93 | response = client.encrypt( 94 | KeyId='alias/your_kms_key', 95 | Plaintext='Do not forget to delete the real plain text when done' 96 | ) 97 | 98 | ciphertext = response['CiphertextBlob'] 99 | return base64.b64encode(ciphertext) 100 | ``` 101 | 102 | - Manage your Private Keys .pem files and passwords outside of this repo. 103 | - Update your bless_deploy.cfg with your Private Key's filename and encrypted passwords. 104 | - Provide your desired ./lambda_configs/ca_key_name.pem prior to Publishing a new Lambda .zip 105 | - Set the permissions of ./lambda_configs/ca_key_name.pem to 444. 106 | 107 | You can now provide your private key and/or encrypted private key password via the lambda environment or config file. 108 | In the `[Bless CA]` section, you can set `ca_private_key` instead of the `ca_private_key_file` with a base64 encoded 109 | version of your .pem (e.g. `cat key.pem | base64` ). 110 | 111 | Because every config file option is supported in the environment, you can also just set `bless_ca_default_password` 112 | and/or `bless_ca_ca_private_key`. Due to limits on AWS Lambda environment variables, you'll need to compress RSA 4096 113 | private keys, which you can now do by setting `bless_ca_ca_private_key_compression`. For example, set 114 | `bless_ca_ca_private_key_compression = bz2` and `bless_ca_ca_private_key` to the output of 115 | `cat ca-key.pem | bzip2 | base64`. 116 | 117 | ### BLESS Config File 118 | - Refer to the the [Example BLESS Config File](bless/config/bless_deploy_example.cfg) and its 119 | included documentation. 120 | - Manage your bless_deploy.cfg files outside of this repo. 121 | - Provide your desired ./lambda_configs/bless_deploy.cfg prior to Publishing a new Lambda .zip 122 | - The required [Bless CA] option values must be set for your environment. 123 | - Every option can be changed in the environment. The environment variable name is constructed 124 | as section_name_option_name (all lowercase, spaces replaced with underscores). 125 | 126 | ### Publish Lambda .zip 127 | - Provide your desired ./lambda_configs/ca_key_name.pem prior to Publishing 128 | - Provide your desired [BLESS Config File](bless/config/bless_deploy_example.cfg) at 129 | ./lambda_configs/bless_deploy.cfg prior to Publishing 130 | - Provide the [compiled dependencies](#compiling-bless-lambda-dependencies) at ./aws_lambda_libs 131 | - run: 132 | ``` 133 | (venv) $ make publish 134 | ``` 135 | 136 | - deploy ./publish/bless_lambda.zip to AWS via the AWS Console, 137 | [AWS SDK](http://boto3.readthedocs.io/en/latest/reference/services/lambda.html), or 138 | [S3](https://aws.amazon.com/blogs/compute/new-deployment-options-for-aws-lambda/) 139 | - remember to deploy it to all regions. 140 | 141 | 142 | ### Lambda Requirements 143 | You should deploy this function into its own AWS account to limit who has access to modify the 144 | code, configs, or IAM Policies. An isolated account also limits who has access to the KMS keys 145 | used to protect the SSH CA Key. 146 | 147 | The BLESS Lambda function should run as its own IAM Role and will need access to an AWS KMS Key in 148 | each region where the function is deployed. The BLESS IAMRole will also need permissions to obtain 149 | random from kms (kms:GenerateRandom) and permissions for logging to CloudWatch Logs 150 | (logs:CreateLogGroup,logs:CreateLogStream,logs:PutLogEvents). 151 | 152 | ## Using BLESS 153 | After you have [deployed BLESS](#deployment) you can run the sample [BLESS Client](bless_client/bless_client.py) 154 | from a system with access to the required [AWS Credentials](http://boto3.readthedocs.io/en/latest/guide/configuration.html). 155 | This client is really just a proof of concept to validate that you have a functional lambda being called with valid 156 | IAM credentials. 157 | 158 | (venv) $ ./bless_client.py region lambda_function_name bastion_user bastion_user_ip remote_usernames bastion_source_ip bastion_command 159 | 160 | 161 | ## Verifying Certificates 162 | You can inspect the contents of a certificate with ssh-keygen directly: 163 | 164 | $ ssh-keygen -L -f your-cert.pub 165 | 166 | ## Enabling BLESS Certificates On Servers 167 | Add the following line to `/etc/ssh/sshd_config`: 168 | 169 | TrustedUserCAKeys /etc/ssh/cas.pub 170 | 171 | Add a new file, owned by and only writable by root, at `/etc/ssh/cas.pub` with the contents: 172 | 173 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQ… #id_rsa.pub of an SSH CA 174 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQ… #id_rsa.pub of an offline SSH CA 175 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQ… #id_rsa.pub of an offline SSH CA 2 176 | 177 | To simplify SSH CA Key rotation you should provision multiple CA Keys, and leave them offline until 178 | you are ready to rotate them. 179 | 180 | Additional information about the TrustedUserCAKeys file is [here](https://www.freebsd.org/cgi/man.cgi?sshd_config(5)) 181 | 182 | ## Project resources 183 | - Source code 184 | - Issue tracker 185 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /bless/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/bless/f3714d549d79c5e0a36b7467d8b7680ce9fe2e61/bless/__init__.py -------------------------------------------------------------------------------- /bless/aws_lambda/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/bless/f3714d549d79c5e0a36b7467d8b7680ce9fe2e61/bless/aws_lambda/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/cache/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/bless/f3714d549d79c5e0a36b7467d8b7680ce9fe2e61/bless/cache/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /bless/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/bless/f3714d549d79c5e0a36b7467d8b7680ce9fe2e61/bless/config/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/request/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/bless/f3714d549d79c5e0a36b7467d8b7680ce9fe2e61/bless/request/__init__.py -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /bless/ssh/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/bless/f3714d549d79c5e0a36b7467d8b7680ce9fe2e61/bless/ssh/__init__.py -------------------------------------------------------------------------------- /bless/ssh/certificate_authorities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/bless/f3714d549d79c5e0a36b7467d8b7680ce9fe2e61/bless/ssh/certificate_authorities/__init__.py -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /bless/ssh/certificates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/bless/f3714d549d79c5e0a36b7467d8b7680ce9fe2e61/bless/ssh/certificates/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/protocol/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/bless/f3714d549d79c5e0a36b7467d8b7680ce9fe2e61/bless/ssh/protocol/__init__.py -------------------------------------------------------------------------------- /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/ssh/public_keys/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/bless/f3714d549d79c5e0a36b7467d8b7680ce9fe2e61/bless/ssh/public_keys/__init__.py -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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_client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/bless/f3714d549d79c5e0a36b7467d8b7680ce9fe2e61/bless_client/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /bless_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/bless/f3714d549d79c5e0a36b7467d8b7680ce9fe2e61/bless_logo.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | attrs==20.1.0 3 | boto3==1.14.47 4 | botocore==1.17.47 5 | cffi==1.14.2 6 | coverage==5.2.1 7 | cryptography==2.9.2 8 | docutils==0.15.2 9 | flake8==3.8.3 10 | iniconfig==1.0.1 11 | ipaddress==1.0.23 12 | jmespath==0.10.0 13 | kmsauth==0.6.0 14 | marshmallow==2.19.2 15 | mccabe==0.6.1 16 | more-itertools==8.4.0 17 | packaging==20.4 18 | pluggy==0.13.1 19 | py==1.9.0 20 | pycodestyle==2.6.0 21 | pycparser==2.20 22 | pyflakes==2.2.0 23 | pyparsing==2.4.7 24 | pytest==6.0.1 25 | pytest-mock==3.3.0 26 | python-dateutil==2.8.1 27 | s3transfer==0.3.3 28 | six==1.15.0 29 | toml==0.10.1 30 | urllib3==1.25.10 31 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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', 25 | 'kmsauth' 26 | ], 27 | extras_require={ 28 | 'tests': [ 29 | 'coverage', 30 | 'flake8', 31 | 'pyflakes', 32 | 'pytest', 33 | 'pytest-mock' 34 | ] 35 | } 36 | ) 37 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/bless/f3714d549d79c5e0a36b7467d8b7680ce9fe2e61/tests/__init__.py -------------------------------------------------------------------------------- /tests/aws_lambda/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/bless/f3714d549d79c5e0a36b7467d8b7680ce9fe2e61/tests/aws_lambda/__init__.py -------------------------------------------------------------------------------- /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-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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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, -------------------------------------------------------------------------------- /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.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/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 | -------------------------------------------------------------------------------- /tests/aws_lambda/only-use-for-unit-tests.pem.bz2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/bless/f3714d549d79c5e0a36b7467d8b7680ce9fe2e61/tests/aws_lambda/only-use-for-unit-tests.pem.bz2 -------------------------------------------------------------------------------- /tests/aws_lambda/only-use-for-unit-tests.zlib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/bless/f3714d549d79c5e0a36b7467d8b7680ce9fe2e61/tests/aws_lambda/only-use-for-unit-tests.zlib -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/bless/f3714d549d79c5e0a36b7467d8b7680ce9fe2e61/tests/config/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /tests/request/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/bless/f3714d549d79c5e0a36b7467d8b7680ce9fe2e61/tests/request/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/ssh/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/bless/f3714d549d79c5e0a36b7467d8b7680ce9fe2e61/tests/ssh/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_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 | --------------------------------------------------------------------------------