diff --git a/.gitignore b/.gitignore index 640646b9..aeb0ac5f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ *.cache/ .idea/ BLESS.egg-info/ +build/ htmlcov/ libs/ publish/ diff --git a/Makefile b/Makefile index a50356d0..85bd9fe8 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,7 @@ +# The AWS Lambda runtime BLESS is deployed to. Keep this in sync with the +# runtime configured on the lambda itself. +PYTHON_VERSION ?= 3.13 + test: lint @echo "--> Running Python tests" py.test tests || exit 1 @@ -44,6 +48,10 @@ compile: lambda-deps: @echo "--> Compiling lambda dependencies" - docker run --rm -v ${CURDIR}:/src -w /src amazonlinux:2 ./lambda_compile.sh + docker run --rm --entrypoint /bin/sh \ + --user $(shell id -u):$(shell id -g) \ + -e HOME=/tmp \ + -v ${CURDIR}:/src -w /src \ + public.ecr.aws/lambda/python:${PYTHON_VERSION} ./lambda_compile.sh -.PHONY: develop dev-docs clean test lint coverage publish +.PHONY: develop dev-docs clean test lint coverage publish compile lambda-deps diff --git a/README.md b/README.md index 892f86f4..385ce414 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Run the tests: To deploy an AWS Lambda Function, you need to provide a .zip with the code and all dependencies. The .zip must contain your lambda code and configurations at the top level of the .zip. The BLESS Makefile includes a publish target to package up everything into a deploy-able .zip if they are in -the expected locations. You will need to setup your own Python 3.7 lambda to deploy the .zip to. +the expected locations. You will need to setup your own Python 3.13 lambda to deploy the .zip to. Previously the AWS Lambda Handler needed to be set to `bless_lambda.lambda_handler`, and this would generate a user cert. `bless_lambda.lambda_handler` still works for user certs. `bless_lambda_user.lambda_handler_user` is a handler @@ -69,8 +69,12 @@ All three handlers exist in the published .zip. To deploy code as a Lambda Function, you need to package up all of the dependencies. You will need to compile and include your dependencies before you can publish a working AWS Lambda. -BLESS uses a docker container running [Amazon Linux 2](https://hub.docker.com/_/amazonlinux) to package everything up: +BLESS uses a docker container running the [AWS Lambda Python base image](https://gallery.ecr.aws/lambda/python) +to package everything up. Building in that image ensures the compiled dependencies match the +runtime's Python version and glibc, which matters because cryptography ships `manylinux_2_34` +wheels that will not install on Amazon Linux 2. - Execute ```make lambda-deps``` and this will run a container and save all the dependencies in ./aws_lambda_libs +- To build for a different runtime, override the version: ```make lambda-deps PYTHON_VERSION=3.12``` ### Protecting the CA Private Key - Generate a password protected RSA Private Key in the PEM format: diff --git a/bless/aws_lambda/bless_lambda_host.py b/bless/aws_lambda/bless_lambda_host.py index 91ff1abe..34ed7350 100644 --- a/bless/aws_lambda/bless_lambda_host.py +++ b/bless/aws_lambda/bless_lambda_host.py @@ -47,11 +47,11 @@ def lambda_handler_host( ca_private_key = config.getprivatekey() # Process cert request - schema = BlessHostSchema(strict=True) + schema = BlessHostSchema() schema.context[HOSTNAME_VALIDATION_OPTION] = config.get(BLESS_OPTIONS_SECTION, HOSTNAME_VALIDATION_OPTION) try: - request = schema.load(event).data + request = schema.load(event) except ValidationError as e: return error_response('InputValidationError', str(e)) diff --git a/bless/aws_lambda/bless_lambda_user.py b/bless/aws_lambda/bless_lambda_user.py index 166bec9b..691ecb72 100644 --- a/bless/aws_lambda/bless_lambda_user.py +++ b/bless/aws_lambda/bless_lambda_user.py @@ -65,7 +65,7 @@ def lambda_handler_user( certificate_extensions = config.get(BLESS_OPTIONS_SECTION, CERTIFICATE_EXTENSIONS_OPTION) # Process cert request - schema = BlessUserSchema(strict=True) + schema = BlessUserSchema() schema.context[USERNAME_VALIDATION_OPTION] = config.get(BLESS_OPTIONS_SECTION, USERNAME_VALIDATION_OPTION) schema.context[REMOTE_USERNAMES_VALIDATION_OPTION] = config.get(BLESS_OPTIONS_SECTION, REMOTE_USERNAMES_VALIDATION_OPTION) @@ -73,7 +73,7 @@ def lambda_handler_user( REMOTE_USERNAMES_BLACKLIST_OPTION) try: - request = schema.load(event).data + request = schema.load(event) except ValidationError as e: return error_response('InputValidationError', str(e)) diff --git a/bless/request/bless_request_host.py b/bless/request/bless_request_host.py index 5729334a..d45be3b0 100644 --- a/bless/request/bless_request_host.py +++ b/bless/request/bless_request_host.py @@ -7,7 +7,7 @@ from bless.config.bless_config import HOSTNAME_VALIDATION_OPTION, HOSTNAME_VALIDATION_DEFAULT from bless.request.bless_request_common import validate_ssh_public_key -from marshmallow import Schema, fields, validates_schema, ValidationError, post_load, validates +from marshmallow import Schema, fields, post_load, validates from marshmallow.validate import URL HOSTNAME_VALIDATION_OPTIONS = Enum('HostNameValidationOptions', @@ -28,14 +28,8 @@ class BlessHostSchema(Schema): hostnames = fields.Str(required=True) public_key_to_sign = fields.Str(validate=validate_ssh_public_key, required=True) - @validates_schema(pass_original=True) - def check_unknown_fields(self, data, original_data): - unknown = set(original_data) - set(self.fields) - if unknown: - raise ValidationError('Unknown field', unknown) - @post_load - def make_bless_request(self, data): + def make_bless_request(self, data, **kwargs): return BlessHostRequest(**data) @validates('hostnames') diff --git a/bless/request/bless_request_user.py b/bless/request/bless_request_user.py index 1e31d80b..c9855c35 100644 --- a/bless/request/bless_request_user.py +++ b/bless/request/bless_request_user.py @@ -11,7 +11,7 @@ USERNAME_VALIDATION_DEFAULT, REMOTE_USERNAMES_VALIDATION_DEFAULT, REMOTE_USERNAMES_BLACKLIST_OPTION, \ REMOTE_USERNAMES_BLACKLIST_DEFAULT from bless.request.bless_request_common import validate_ssh_public_key -from marshmallow import Schema, fields, post_load, ValidationError, validates_schema +from marshmallow import Schema, fields, post_load, ValidationError from marshmallow import validates from marshmallow.validate import Email @@ -54,7 +54,7 @@ def validate_user(user, username_validation, username_blacklist=None): if username_validation == USERNAME_VALIDATION_OPTIONS.disabled: return elif username_validation == USERNAME_VALIDATION_OPTIONS.email: - Email('Invalid email address.').__call__(user) + Email(error='Invalid email address.').__call__(user) elif username_validation == USERNAME_VALIDATION_OPTIONS.principal: _validate_principal(user) elif len(user) > 32: @@ -91,14 +91,8 @@ class BlessUserSchema(Schema): remote_usernames = fields.Str(required=True) kmsauth_token = fields.Str(required=False) - @validates_schema(pass_original=True) - def check_unknown_fields(self, data, original_data): - unknown = set(original_data) - set(self.fields) - if unknown: - raise ValidationError('Unknown field', unknown) - @post_load - def make_bless_request(self, data): + def make_bless_request(self, data, **kwargs): return BlessUserRequest(**data) @validates('bastion_user') diff --git a/bless/ssh/certificate_authorities/rsa_certificate_authority.py b/bless/ssh/certificate_authorities/rsa_certificate_authority.py index 55ef165f..3acde4cf 100644 --- a/bless/ssh/certificate_authorities/rsa_certificate_authority.py +++ b/bless/ssh/certificate_authorities/rsa_certificate_authority.py @@ -7,7 +7,6 @@ SSHCertificateAuthority from bless.ssh.protocol.ssh_protocol import pack_ssh_mpint, pack_ssh_string from bless.ssh.public_keys.ssh_public_key import SSHPublicKeyType -from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives.serialization import load_pem_private_key @@ -26,8 +25,7 @@ def __init__(self, pem_private_key, private_key_password=None): self.public_key_type = SSHPublicKeyType.RSA self.private_key = load_pem_private_key(pem_private_key, - private_key_password, - default_backend()) + private_key_password) ca_pub_numbers = self.private_key.public_key().public_numbers() diff --git a/bless/ssh/protocol/ssh_protocol.py b/bless/ssh/protocol/ssh_protocol.py index 2fa37c9a..a2254393 100644 --- a/bless/ssh/protocol/ssh_protocol.py +++ b/bless/ssh/protocol/ssh_protocol.py @@ -55,6 +55,24 @@ def pack_ssh_string(string): return struct.pack('>I{}s'.format(str_len), str_len, string) +def unpack_ssh_string(data): + """ + Unpacks a single SSH string from the front of data. + See Section 5 of https://www.ietf.org/rfc/rfc4251.txt for more information. + :param data: Bytes beginning with an SSH String. + :return: Tuple of (the string's bytes, the remaining bytes). + """ + if len(data) < 4: + raise ValueError("Data is too short to contain an SSH string.") + + str_len = struct.unpack('>I', data[:4])[0] + + if len(data) < 4 + str_len: + raise ValueError("Data is shorter than its declared SSH string length.") + + return data[4:4 + str_len], data[4 + str_len:] + + def pack_ssh_uint64(i): """ Packs a 64-bit unsigned integer. diff --git a/bless/ssh/public_keys/ed25519_public_key.py b/bless/ssh/public_keys/ed25519_public_key.py index 5199e14c..c3beb00e 100644 --- a/bless/ssh/public_keys/ed25519_public_key.py +++ b/bless/ssh/public_keys/ed25519_public_key.py @@ -7,7 +7,8 @@ import hashlib from bless.ssh.public_keys.ssh_public_key import SSHPublicKey, SSHPublicKeyType -from cryptography.hazmat.primitives.serialization import ssh +from cryptography.hazmat.primitives.asymmetric import ed25519 +from cryptography.hazmat.primitives import serialization class ED25519PublicKey(SSHPublicKey): @@ -29,8 +30,6 @@ def __init__(self, ssh_public_key): else: self.key_comment = '' - # hazmat does not support ed25519 so we have out own loader based on serialization.load_ssh_public_key - if split_key_len < 2: raise ValueError( 'Key is not in the proper format or contains extra data.') @@ -42,19 +41,18 @@ def __init__(self, ssh_public_key): raise TypeError("Public Key is not the correct type or format") try: - decoded_data = base64.b64decode(key_body) - except TypeError: + base64.b64decode(key_body) + except (TypeError, ValueError): raise ValueError('Key is not in the proper format.') - inner_key_type, rest = ssh._ssh_read_next_string(decoded_data) - - if inner_key_type != key_type.encode("utf-8"): - raise ValueError( - 'Key header and key body contain different key type values.' - ) - # ed25519 public key is a single string https://tools.ietf.org/html/rfc8032#section-5.1.5 - self.a, rest = ssh._ssh_read_next_string(rest) + public_key = serialization.load_ssh_public_key(ssh_public_key.encode('ascii')) + if not isinstance(public_key, ed25519.Ed25519PublicKey): + raise TypeError("Public Key is not the correct type or format") + + self.a = public_key.public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) key_bytes = base64.b64decode(split_ssh_public_key[1]) fingerprint = hashlib.md5(key_bytes).hexdigest() diff --git a/bless/ssh/public_keys/rsa_public_key.py b/bless/ssh/public_keys/rsa_public_key.py index d1afcbde..50768968 100644 --- a/bless/ssh/public_keys/rsa_public_key.py +++ b/bless/ssh/public_keys/rsa_public_key.py @@ -7,7 +7,6 @@ import hashlib from bless.ssh.public_keys.ssh_public_key import SSHPublicKey, SSHPublicKeyType -from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers @@ -57,7 +56,7 @@ def __init__(self, ssh_public_key): else: self.key_comment = '' - public_key = serialization.load_ssh_public_key(ssh_public_key.encode('ascii'), default_backend()) + public_key = serialization.load_ssh_public_key(ssh_public_key.encode('ascii')) ca_pub_numbers = public_key.public_numbers() if not isinstance(ca_pub_numbers, RSAPublicNumbers): raise TypeError("Public Key is not the correct type or format") diff --git a/lambda_compile.sh b/lambda_compile.sh index dd35d836..8db1ec51 100755 --- a/lambda_compile.sh +++ b/lambda_compile.sh @@ -1,8 +1,20 @@ #!/bin/sh +set -e -yum install -y python37 -python3.7 -m venv /tmp/venv -/tmp/venv/bin/pip install --upgrade pip setuptools -/tmp/venv/bin/pip install -e . -cp -r /tmp/venv/lib/python3.7/site-packages/. ./aws_lambda_libs -cp -r /tmp/venv/lib64/python3.7/site-packages/. ./aws_lambda_libs +# Compiles BLESS and its dependencies into ./aws_lambda_libs for packaging. +# +# This is intended to run inside the official AWS Lambda Python base image (see +# the lambda-deps target in the Makefile). Building in that image guarantees +# the compiled wheels match the runtime's Python version and glibc, which +# matters because cryptography ships manylinux_2_34 wheels that will not +# install on Amazon Linux 2. + +PYTHON=${PYTHON:-python3.13} + +rm -rf ./aws_lambda_libs +mkdir -p ./aws_lambda_libs + +$PYTHON -m pip install \ + --upgrade \ + --target ./aws_lambda_libs \ + . diff --git a/requirements.txt b/requirements.txt index e32645e4..ac2e1fc1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,30 +1,24 @@ -e . -attrs==20.1.0 -boto3==1.14.47 -botocore==1.17.47 -cffi==1.14.2 -coverage==5.2.1 -cryptography==2.9.2 -docutils==0.15.2 -flake8==3.8.3 -iniconfig==1.0.1 -ipaddress==1.0.23 -jmespath==0.10.0 -kmsauth==0.6.0 -marshmallow==2.19.2 -mccabe==0.6.1 -more-itertools==8.4.0 -packaging==20.4 -pluggy==0.13.1 -py==1.9.0 -pycodestyle==2.6.0 -pycparser==2.20 -pyflakes==2.2.0 -pyparsing==2.4.7 -pytest==6.0.1 -pytest-mock==3.3.0 -python-dateutil==2.8.1 -s3transfer==0.3.3 -six==1.15.0 -toml==0.10.1 -urllib3==1.25.10 +boto3==1.43.62 +botocore==1.43.62 +cffi==2.1.0 +coverage==7.15.3 +cryptography==50.0.0 +flake8==7.3.0 +iniconfig==2.3.0 +jmespath==1.1.0 +kmsauth==0.6.3 +marshmallow==3.26.2 +mccabe==0.7.0 +packaging==26.2 +pluggy==1.6.0 +pycodestyle==2.14.0 +pycparser==3.0 +pyflakes==3.4.0 +Pygments==2.20.0 +pytest==9.1.1 +pytest-mock==3.15.1 +python-dateutil==2.9.0.post0 +s3transfer==0.19.2 +six==1.17.0 +urllib3==2.7.0 diff --git a/setup.py b/setup.py index f4b949a0..b5191fb4 100644 --- a/setup.py +++ b/setup.py @@ -17,12 +17,14 @@ description=about["__summary__"], license=about["__license__"], packages=find_packages(exclude=["test*"]), + python_requires=">=3.11", install_requires=[ - "boto3==1.14.47", - "cryptography==2.9.2", - "ipaddress==1.0.23", - "marshmallow==2.19.2", - "kmsauth==0.6.0", + "boto3>=1.35", + "cryptography>=44", + # marshmallow 4 removes Schema.context and validate.URL(require_tld=...), + # both of which the request schemas rely on. + "marshmallow>=3.20,<4", + "kmsauth>=0.6.3", ], extras_require={ "tests": ["coverage", "flake8", "pyflakes", "pytest", "pytest-mock"] diff --git a/tests/ssh/test_ssh_certificate_rsa.py b/tests/ssh/test_ssh_certificate_rsa.py index ee7033a2..61a74f9f 100644 --- a/tests/ssh/test_ssh_certificate_rsa.py +++ b/tests/ssh/test_ssh_certificate_rsa.py @@ -1,7 +1,7 @@ import base64 import pytest -from cryptography.hazmat.primitives.serialization.ssh import _ssh_read_next_string +from bless.ssh.protocol.ssh_protocol import unpack_ssh_string from bless.ssh.certificate_authorities.rsa_certificate_authority import RSACertificateAuthority from bless.ssh.certificates.rsa_certificate_builder import RSACertificateBuilder @@ -39,8 +39,8 @@ def get_basic_cert_builder_rsa(cert_type=SSHCertificateType.USER, def extract_nonce_from_cert(cert_file): cert = cert_file.split(' ')[1] - cert_type, cert_remainder = _ssh_read_next_string(base64.b64decode(cert)) - nonce, cert_remainder = _ssh_read_next_string(cert_remainder) + cert_type, cert_remainder = unpack_ssh_string(base64.b64decode(cert)) + nonce, cert_remainder = unpack_ssh_string(cert_remainder) return nonce diff --git a/tests/ssh/test_ssh_public_key_ed25519.py b/tests/ssh/test_ssh_public_key_ed25519.py index 4a4e5d76..e2d76458 100644 --- a/tests/ssh/test_ssh_public_key_ed25519.py +++ b/tests/ssh/test_ssh_public_key_ed25519.py @@ -27,3 +27,11 @@ def test_invalid_keys(): with pytest.raises(ValueError): ED25519PublicKey('bogus') + +def test_key_header_body_type_mismatch(): + # An ssh-ed25519 header in front of a body that encodes some other key type + # must be rejected rather than parsed as an ed25519 key. + body = EXAMPLE_ECDSA_PUBLIC_KEY.split(' ')[1] + with pytest.raises(ValueError): + ED25519PublicKey('ssh-ed25519 {} mismatched'.format(body)) +