Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*.cache/
.idea/
BLESS.egg-info/
build/
htmlcov/
libs/
publish/
Expand Down
12 changes: 10 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions bless/aws_lambda/bless_lambda_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
4 changes: 2 additions & 2 deletions bless/aws_lambda/bless_lambda_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,15 @@ 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)
schema.context[REMOTE_USERNAMES_BLACKLIST_OPTION] = config.get(BLESS_OPTIONS_SECTION,
REMOTE_USERNAMES_BLACKLIST_OPTION)

try:
request = schema.load(event).data
request = schema.load(event)
except ValidationError as e:
return error_response('InputValidationError', str(e))

Expand Down
10 changes: 2 additions & 8 deletions bless/request/bless_request_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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')
Expand Down
12 changes: 3 additions & 9 deletions bless/request/bless_request_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

Expand Down
18 changes: 18 additions & 0 deletions bless/ssh/protocol/ssh_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 11 additions & 13 deletions bless/ssh/public_keys/ed25519_public_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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.')
Expand All @@ -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()
Expand Down
3 changes: 1 addition & 2 deletions bless/ssh/public_keys/rsa_public_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down
24 changes: 18 additions & 6 deletions lambda_compile.sh
Original file line number Diff line number Diff line change
@@ -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 \
.
52 changes: 23 additions & 29 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
12 changes: 7 additions & 5 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
6 changes: 3 additions & 3 deletions tests/ssh/test_ssh_certificate_rsa.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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


Expand Down
8 changes: 8 additions & 0 deletions tests/ssh/test_ssh_public_key_ed25519.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))