Skip to content

Commit

Permalink
feat: introduce ability to request CA chain (#196)
Browse files Browse the repository at this point in the history
* feat: add support for returning the ca chain to tls_cert lambda

Adds one new parameter to the `tls_cert` lambda request :
* `ca_chain_only`: returns CA chain without generating a certificate

* feat: introduce request and response classes

* feat: update documentation in README for tls_cert lambda to include inputs and outputs.
  • Loading branch information
patdowney authored Aug 2, 2024
1 parent 87e701f commit 1770a4d
Show file tree
Hide file tree
Showing 9 changed files with 363 additions and 52 deletions.
59 changes: 57 additions & 2 deletions modules/terraform-aws-ca-lambda/README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,60 @@
* Deploys AWS Lambda functions forming part of serverless CA solution
* Submodule of terraform-aws-ca

## Lambda functions
* create_root_ca - creates a root CA
* create_issuing_ca - creates an issuing CA
* issuing_ca_crl - creates a CRL for an issuing CA
* root_ca_crl - creates a CRL for a root CA
* tls_cert - creates a TLS certificate

### tls_cert
#### Input

| Property | Type | Default | Description |
|-----------------------|-----------------------|-------------------|--------------------------------------------------------------------------------------|
| `common_name` | `Optional[str]` | `None` | The `CN` part of the certificate subject. Only optional if `ca_chain_only` is `True` |
| `locality` | `Optional[str]` | `None` | The `L` part of the certificate subject |
| `organization` | `Optional[str]` | `None` | The `O` part of the certificate subject |
| `organizational_unit` | `Optional[str]` | `None` | The `OU` part of the certificate subject |
| `country` | `Optional[str]` | `None` | The `C` part of the certificate subject |
| `email_address` | `Optional[str]` | `None` | The `1.2.840.113549.1.9.1` part of the certificate subject |
| `state` | `Optional[str]` | `None` | The `ST` part of the certificate subject |
| `lifetime` | `Optional[int]` | `30` | The lifetime of the generated certificate. In days. |
| `purposes` | `Optional[list[str]]` | `["client_auth"]` | The purposes to use the certificate for, e.g. `server_auth` or `client_auth` |
| `sans` | `Optional[list[str]]` | `None` | Subject Alternative Names. Invalid DNS names will be ignored. |
| `ca_chain_only` | `Optional[bool]` | `False` | Return only the root and issuing CA. |
| `csr_file` | `Optional[str]` | `None` | A path to S3 object, relative to the `INTERNAL_S3_BUCKET` environment variable |
| `force_issue` | `Optional[bool]` | `False` | Force issue of certificate when private key has already been used |
| `cert_bundle` | `Optional[bool]` | `False` | Include Root and Issuing CA in the returned certificate |
| `base64_csr_data` | `Optional[str]` | `None` | Base64 encoded CSR that will be used to generate the certificate |


#### Output
If `ca_chain_only` is `True`:

| Property | Type | Description |
|------------------------------|-------|------------------------------------------------------------------------------------------|
| `Base64RootCaCertificate` | `str` | Base64 encoded PEM containing the Root CA certificate |
| `Base64IssuingCaCertificate` | `str` | Base64 encoded PEM containing the Issuing CA certificate |
| `Base64CaChain` | `str` | Base64 encoded PEM containing the Root and Issuing CA certificates. Issuing CA is first. |

Otherwise:

| Property | Type | Description |
|--------------------------------|--------|----------------------------------------------------------------------------------------------------------------------------|
| `CertificateInfo` | `dict` | Contains the certificate information |
| `CertificateInfo.CommonName` | `str` | The common name used to generate the certificate |
| `CertificateInfo.SerialNumber` | `str` | The serial number of the certificate |
| `CertificateInfo.Issued` | `str` | The date the certificate was issued, in the format `2021-01-01 00:00:00` |
| `CertificateInfo.Expires` | `str` | The date the certificate expires in the format `2022-01-01 00:00:00` |
| `Base64Certificate` | `str` | Base64 encoded PEM containing the certificate. If `cert_bundle` was `True` then contents also contains issuing and root CA |
| `Subject` | `str` | The subject string of the generated certificate |
| `Base64RootCaCertificate` | `str` | Base64 encoded PEM containing the Root CA certificate |
| `Base64IssuingCaCertificate` | `str` | Base64 encoded PEM containing the Issuing CA certificate |
| `Base64CaChain` | `str` | Base64 encoded PEM containing the Root and Issuing CA certificates. Issuing CA is first. |


## Local Python development - MacOS / Linux
* create virtual environment
```bash
Expand Down Expand Up @@ -34,7 +88,7 @@ export PUBLIC_CRL="disabled"
export ROOT_CRL_DAYS="1"
export ROOT_CRL_SECONDS="600"
```
* copy and paste AWS MacOS / Linux CLI variables to terminal
* copy and paste AWS macOS / Linux CLI variables to terminal

* test Lambda function locally
* enter `python`
Expand Down Expand Up @@ -70,6 +124,7 @@ lambda_handler({"common_name": "test-client-cert","lifetime": 1,"csr_file": "cli
```
* similar test with customised org and org unit
```python
from lambda_code.tls_cert.tls_cert import lambda_handler
lambda_handler({"common_name": "test-client-cert", "organization": "Acme Inc.", "organizational_unit": "Animation Department","lifetime": 1,"csr_file": "client-cert-request.csr","force_issue": True},{})
```
* unit testing, format and linting checks
Expand All @@ -78,7 +133,6 @@ pytest -v .
black --line-length 120 .
prospector
bandit -r lambda_code utils
```

## Local Python development - Windows
Expand Down Expand Up @@ -144,6 +198,7 @@ lambda_handler({"common_name": "test-client-cert","lifetime": 1,"csr_file": "cli
```
* similar test with customised org and org unit
```python
from lambda_code.tls_cert.tls_cert import lambda_handler
lambda_handler({"common_name": "test-client-cert", "organization": "Acme Inc.", "organizational_unit": "Animation Department","lifetime": 1,"csr_file": "client-cert-request.csr","force_issue": True},{})
```
* unit testing, format and linting checks
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
cryptography == 43.0.0
dataclasses-json == 0.6.7
validators == 0.33.0
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .tls_cert import create_csr_info, create_csr_subject
from .tls_cert import create_csr_info, create_csr_subject, CaChainResponse, CertificateResponse, Request


def test_create_csr_info():
Expand Down Expand Up @@ -52,3 +52,87 @@ def test_create_csr_subject():
"ST=England,OU=Animation,O=Acme Inc,L=London,[email protected],C=GB,CN=blah.example.com"
)
assert subject.x509_name().rfc4514_string() == expected


def test_request_deserialise_basic():
event = {"common_name": "test.example.com"}

request = Request.from_dict(event)

assert request.common_name == "test.example.com"
assert request.lifetime == 30


def test_request_deserialise_full():
event = {
"common_name": "test.example.com",
"locality": "London",
"organization": "Example",
"organizational_unit": "IT",
"country": "GB",
"email_address": "[email protected]",
"state": "London",
"lifetime": 365,
"purposes": ["server_auth"],
"sans": ["test2.example.com"],
"ca_chain_only": True,
"force_issue": True,
"csr_file": "csr.pem",
"cert_bundle": True,
"base64_csr_data": "base64data",
}

request = Request(**event)

assert request.common_name == "test.example.com"
assert request.lifetime == 365
assert request.purposes == ["server_auth"]
assert request.csr_file == "csr.pem"


def test_response_serialise_as_dict():
response = CertificateResponse(
certificate_info={
"CommonName": "test.example.com",
"SerialNumber": "123456",
"Issued": "2021-01-01 00:00:00",
"Expires": "2022-01-01 00:00:00",
},
base64_certificate="base64data",
subject="test.example.com",
base64_issuing_ca_certificate="base64data",
base64_root_ca_certificate="base64data",
base64_ca_chain="base64data",
)

serialised = response.to_dict()

assert serialised == {
"CertificateInfo": {
"CommonName": "test.example.com",
"SerialNumber": "123456",
"Issued": "2021-01-01 00:00:00",
"Expires": "2022-01-01 00:00:00",
},
"Base64Certificate": "base64data",
"Subject": "test.example.com",
"Base64IssuingCaCertificate": "base64data",
"Base64RootCaCertificate": "base64data",
"Base64CaChain": "base64data",
}


def test_ca_chain_response_serialise_as_dict():
response = CaChainResponse(
base64_issuing_ca_certificate="base64data",
base64_root_ca_certificate="base64data",
base64_ca_chain="base64data",
)

serialised = response.to_dict()

assert serialised == {
"Base64IssuingCaCertificate": "base64data",
"Base64RootCaCertificate": "base64data",
"Base64CaChain": "base64data",
}
108 changes: 88 additions & 20 deletions modules/terraform-aws-ca-lambda/lambda_code/tls_cert/tls_cert.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,50 @@
from utils.certs.s3 import s3_download
from cryptography.x509 import load_pem_x509_certificate, load_pem_x509_csr
from cryptography.hazmat.primitives import serialization
from dataclasses import dataclass, field
from dataclasses_json import dataclass_json, LetterCase
from typing import Optional

# TODO: Request and Response classes use different naming convention


@dataclass_json
@dataclass
class Request:
common_name: Optional[str] = None
locality: Optional[str] = None
organization: Optional[str] = None
organizational_unit: Optional[str] = None
country: Optional[str] = None
email_address: Optional[str] = None
state: Optional[str] = None
lifetime: Optional[int] = 30
purposes: Optional[list[str]] = field(default_factory=lambda: ["client_auth"])
sans: Optional[list[str]] = None
csr_file: Optional[str] = None
base64_csr_data: Optional[str] = None
force_issue: Optional[bool] = False
cert_bundle: Optional[bool] = False
ca_chain_only: Optional[bool] = False


@dataclass_json(letter_case=LetterCase.PASCAL)
@dataclass
class CertificateResponse:
certificate_info: dict
base64_certificate: str
subject: str
base64_issuing_ca_certificate: str
base64_root_ca_certificate: str
base64_ca_chain: str


@dataclass_json(letter_case=LetterCase.PASCAL)
@dataclass
class CaChainResponse:
base64_issuing_ca_certificate: str
base64_root_ca_certificate: str
base64_ca_chain: str


# pylint:disable=too-many-arguments
Expand Down Expand Up @@ -103,12 +147,10 @@ def is_invalid_certificate_request(project, env_name, ca_name, common_name, csr,
return None


def create_cert_bundle_from_certificate(project, env_name, base64_certificate):
def create_cert_bundle_from_certificate(project, env_name, root_ca_name, issuing_ca_name, base64_certificate):
"""
Creates a certificate bundle in PEM format containing Client Issuing CA and Root CA Certificates
"""
root_ca_name = ca_name(project, env_name, "root")
issuing_ca_name = ca_name(project, env_name, "issuing")
cert_bundle = ""
return cert_bundle.join(
[
Expand Down Expand Up @@ -146,6 +188,24 @@ def create_csr_info(event) -> CsrInfo:
return csr_info


def create_ca_chain_response(project: str, env_name: str, root_ca_name: str, issuing_ca_name: str):
root_ca_b64 = db_list_certificates(project, env_name, root_ca_name)[0]["Certificate"]["B"]
issuing_ca_b64 = db_list_certificates(project, env_name, issuing_ca_name)[0]["Certificate"]["B"]

# Need to decode base64 so we can append them together
root_ca = base64.b64decode(root_ca_b64).decode("utf-8")
issuing_ca = base64.b64decode(issuing_ca_b64).decode("utf-8")
ca_chain = "\n".join([issuing_ca.strip(), root_ca.strip()])
ca_chain_b64_bytes = base64.b64encode(ca_chain.encode("utf-8"))
ca_chain_b64 = ca_chain_b64_bytes.decode("utf-8")

return CaChainResponse(
base64_issuing_ca_certificate=issuing_ca_b64,
base64_root_ca_certificate=root_ca_b64,
base64_ca_chain=ca_chain_b64,
)


def lambda_handler(event, context): # pylint:disable=unused-argument,too-many-locals
project = os.environ["PROJECT"]
env_name = os.environ["ENVIRONMENT_NAME"]
Expand All @@ -161,24 +221,27 @@ def lambda_handler(event, context): # pylint:disable=unused-argument,too-many-l

# get Issuing CA name
issuing_ca_name = ca_name(project, env_name, "issuing")
root_ca_name = ca_name(project, env_name, "root")

request = Request.from_dict(event)

# process input
print(f"Input: {event}")

csr_info = create_csr_info(event)
ca_chain_response = create_ca_chain_response(project, env_name, root_ca_name, issuing_ca_name)

csr_file = event.get("csr_file") # string, reference to static file
force_issue = event.get("force_issue") # boolean, force certificate generation even if one already exists
create_cert_bundle = event.get("cert_bundle") # boolean, include Root CA and Issuing CA with client certificate
base64_csr_data = event.get("base64_csr_data") # base64 encoded CSR PEM file
if request.ca_chain_only:
return ca_chain_response.to_dict()

if csr_file:
csr_file_contents = s3_download(external_s3_bucket_name, internal_s3_bucket_name, f"csrs/{csr_file}")[
csr_info = create_csr_info(event)

if request.csr_file:
csr_file_contents = s3_download(external_s3_bucket_name, internal_s3_bucket_name, f"csrs/{request.csr_file}")[
"Body"
].read()
csr = load_pem_x509_csr(csr_file_contents)
else:
csr = load_pem_x509_csr(base64.standard_b64decode(base64_csr_data))
csr = load_pem_x509_csr(base64.standard_b64decode(request.base64_csr_data))

validation_error = is_invalid_certificate_request(
project,
Expand All @@ -187,7 +250,7 @@ def lambda_handler(event, context): # pylint:disable=unused-argument,too-many-l
csr_info.subject.common_name,
csr,
csr_info.lifetime,
force_issue,
request.force_issue,
)
if validation_error:
return validation_error
Expand All @@ -198,14 +261,19 @@ def lambda_handler(event, context): # pylint:disable=unused-argument,too-many-l

db_tls_cert_issued(project, env_name, cert_info, base64_certificate)

if create_cert_bundle:
cert_bundle = create_cert_bundle_from_certificate(project, env_name, base64_certificate)
if request.cert_bundle:
cert_bundle = create_cert_bundle_from_certificate(
project, env_name, root_ca_name, issuing_ca_name, base64_certificate
)
base64_certificate = base64.b64encode(cert_bundle.encode("utf-8"))

response_data = {
"CertificateInfo": cert_info,
"Base64Certificate": base64_certificate,
"Subject": load_pem_x509_certificate(base64.b64decode(base64_certificate)).subject.rfc4514_string(),
}
response = CertificateResponse(
certificate_info=cert_info,
base64_certificate=base64_certificate.decode("utf-8"),
subject=load_pem_x509_certificate(base64.b64decode(base64_certificate)).subject.rfc4514_string(),
base64_root_ca_certificate=ca_chain_response.base64_root_ca_certificate,
base64_issuing_ca_certificate=ca_chain_response.base64_issuing_ca_certificate,
base64_ca_chain=ca_chain_response.base64_ca_chain,
)

return response_data
return response.to_dict()
1 change: 1 addition & 0 deletions modules/terraform-aws-ca-lambda/requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ bandit == 1.7.9
black == 24.4.2
boto3 == 1.34.145
cryptography == 43.0.0
dataclasses-json == 0.6.7
prospector == 1.10.3
pytest == 8.3.1
requests == 2.31.0
Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ black == 24.4.2
boto3 == 1.34.145
certvalidator == 0.11.1
cryptography == 43.0.0
dataclasses-json == 0.6.7
prospector == 1.10.3
pytest == 8.3.1
requests == 2.31.0
Expand Down
Loading

0 comments on commit 1770a4d

Please sign in to comment.