Skip to content
Merged
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
14 changes: 7 additions & 7 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ on:

env:
LAMBDAS_DIR: 'lambdas'
UV_VERSION: '0.9.26'
RUFF_VERSION: '0.14.13'
BANDIT_VERSION: '1.9.3'
UV_VERSION: '0.10.7'
RUFF_VERSION: '0.15.4'
BANDIT_VERSION: '1.9.4'
TF_VERSION: '1.12.1'
TF_LINT_VERSION: 'latest'
TF_DOCS_VERSION: 'latest'
TF_WORKING_DIR: 'terraform/'
TRIVY_VERSION: 'v0.68.2'
TRIVY_VERSION: 'v0.69.1'

jobs:
python-lint:
Expand Down Expand Up @@ -111,7 +111,7 @@ jobs:
- uses: actions/checkout@v6

- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
uses: hashicorp/setup-terraform@v4
with:
terraform_version: ${{ env.TF_VERSION }}

Expand Down Expand Up @@ -159,7 +159,7 @@ jobs:
version: ${{ env.TRIVY_VERSION }}

- name: Run Trivy security scan
uses: aquasecurity/trivy-action@0.33.1
uses: aquasecurity/trivy-action@0.34.1
with:
scan-type: 'fs'
scan-ref: '.'
Expand Down Expand Up @@ -194,7 +194,7 @@ jobs:
- uses: actions/checkout@v6

- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
uses: hashicorp/setup-terraform@v4
with:
terraform_version: ${{ env.TF_VERSION }}

Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ repos:
- id: detect-private-key
exclude: ^tests/
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.13
rev: v0.15.4
hooks:
# Run the linter.
- id: ruff-check
Expand Down
49 changes: 35 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Serverless TLS certificate renewal using Let's Encrypt ACME protocol. Runs as an

## Architecture

```
```txt
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ EventBridge │────▶│ Lambda │────▶│ Let's Encrypt │
│ (Schedule) │ │ (Python 3.11) │ │ ACME Server │
Expand All @@ -45,7 +45,7 @@ Serverless TLS certificate renewal using Let's Encrypt ACME protocol. Runs as an

## Lambda Function Logic

```
```text
START
Expand Down Expand Up @@ -121,13 +121,14 @@ START
END
```

**Certificate renewal triggers:**
### Certificate renewal triggers:

- Secret is empty (first run)
- Certificate field is missing or invalid
- Certificate expires within 30 days (configurable via `RENEWAL_DAYS_BEFORE_EXPIRY`)
- `force_renewal: true` in event payload

**Note:** Expiry is always determined by parsing the actual certificate PEM, not the stored `expiry` field. This ensures the certificate itself is the source of truth. If the stored `expiry` field doesn't match the actual certificate expiry, a warning is logged.
> **Note:** Expiry is always determined by parsing the actual certificate PEM, not the stored `expiry` field. This ensures the certificate itself is the source of truth. If the stored `expiry` field doesn't match the actual certificate expiry, a warning is logged.

## Secrets Manager

Expand All @@ -140,7 +141,7 @@ Secrets created by Terraform:
| `{project}-{env}-certificate` | Certificate JSON (see format below) | Stores the TLS certificate, private key, and chain | Always |
| `{project}-{env}-account-key` | PEM-encoded RSA private key | ACME account key for Let's Encrypt registration | Only if `acme_persist_account_key = true` (default) |

**Certificate Secret Tags**
### Certificate Secret Tags

The certificate secret is automatically tagged with metadata on each renewal:

Expand All @@ -152,7 +153,7 @@ The certificate secret is automatically tagged with metadata on each renewal:

These tags enable monitoring and alerting without decrypting the secret value.

**ACME Account Key Persistence**
### ACME Account Key Persistence

You can control whether the ACME account key is persisted using the `acme_persist_account_key` Terraform variable:

Expand All @@ -169,7 +170,7 @@ You can control whether the ACME account key is persisted using the `acme_persis
- Useful for testing or specific use cases
- **Warning**: May hit Let's Encrypt rate limits with frequent renewals

**Certificate JSON format:**
### Certificate JSON format

```json
{
Expand Down Expand Up @@ -197,26 +198,29 @@ You can control whether the ACME account key is persisted using the `acme_persis

The Lambda function requires Python dependencies (`acme`, `cryptography`, `josepy`) packaged as a Lambda layer. Terraform builds this layer locally during `terraform apply` using `uv pip install` with the `--python-platform x86_64-manylinux2014` flag to ensure compatibility with the Lambda runtime.

**Why local building?**
### Why local building?

- Simple setup - no Docker or CI/CD pipeline required
- Automatic rebuild when `pyproject.toml` changes
- Suitable for single-function deployments

**Requirements:**
### Requirements

- Python 3.11 and [uv](https://docs.astral.sh/uv/) installed locally
- Internet access to download packages from PyPI

**Known limitation:** Terraform uses `local-exec` provisioner to build the layer, which runs during `apply` phase. However, Terraform reads `layer.zip` during `plan` phase to compute hashes. If the file doesn't exist (fresh clone, path changes), `terraform plan` will fail.

**Manual build** (when needed):

```bash
# From project root:
test -f uv.lock || uv lock
uv export --package certbot-lambda --no-hashes --no-dev --frozen --no-emit-project -o lambdas/certbot/requirements.txt
cd lambdas/certbot
rm -rf python layer.zip
mkdir -p python
uv pip install -r requirements.txt --target python/ --python-platform x86_64-manylinux2014 --only-binary :all: --python-version 3.11
uv pip install -r requirements.txt --target python/ --python-platform x86_64-manylinux2014 --only-binary :all: --python-version 3.11 # use aarch64-manylinux2014 for arm64
rm requirements.txt
zip -r layer.zip python
```
Expand Down Expand Up @@ -320,7 +324,7 @@ aws logs filter-log-events \

## Configuration Options

### ACME Account Key Persistence
### Configuring ACME Account Key Persistence

Set `acme_persist_account_key = false` in your `terraform.tfvars` to use ephemeral account keys:

Expand All @@ -329,21 +333,34 @@ acme_persist_account_key = false
```

This will:

- Skip creating the account key secret in Secrets Manager
- Generate a new account key on every certificate renewal
- Reduce AWS costs slightly (one less secret)

**When to use ephemeral mode:**
### When to use ephemeral mode

- Testing and development environments
- One-time certificate generation
- When you don't need certificate revocation capabilities

**When to use persistent mode (default):**
### When to use persistent mode (default)

- Production environments
- Frequent certificate renewals
- When you need to revoke certificates
- To avoid Let's Encrypt rate limits

### Lambda Architecture

Set `lambda_architecture` in your `terraform.tfvars` to target arm64 (Graviton) instead of the default x86_64:

```hcl
lambda_architecture = "arm64"
```

Valid values are `x86_64` (default) and `arm64`. When using arm64, Terraform automatically builds the Lambda layer with the correct `aarch64-manylinux2014` platform target.

### SNS Notifications

Enable SNS notifications for certificate renewal events:
Expand All @@ -354,6 +371,7 @@ notification_email = "admin@example.com"
```

Notifications are sent for:

- Successful certificate renewals
- Failed certificate renewals

Expand All @@ -365,9 +383,10 @@ Publish certificate events to EventBridge for integration with other AWS service
eb_bus_name = "default" # or your custom event bus name
```

**Event Details:**
### Event Details

Success event (`Certificate Renewed`):

```json
{
"status": "success",
Expand All @@ -379,6 +398,7 @@ Success event (`Certificate Renewed`):
```

Failure event (`Certificate Renewal Failed`):

```json
{
"status": "failed",
Expand Down Expand Up @@ -426,6 +446,7 @@ uv run pytest tests/ -v --cov=lambdas/certbot --cov-report=term-missing
```

Test coverage includes:

- `CertificateManager` class (initialization, account keys, CSR generation, DNS challenges, certificate issuance/storage/retrieval)
- `retry_with_backoff` decorator
- `_validate_config` function
Expand Down
114 changes: 95 additions & 19 deletions lambdas/certbot/lambda_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,33 +512,33 @@ def issue_certificate(self, domains: list[str]) -> CertificateData:
logger.info("Certificate issued successfully")

# Parse certificate to extract expiry and separate chain
certs = fullchain_pem.split("-----END CERTIFICATE-----")
certificate = certs[0] + "-----END CERTIFICATE-----\n"
chain = "-----END CERTIFICATE-----".join(certs[1:]).strip()
if chain:
chain = chain + "\n"

# Extract expiry from certificate
cert = x509.load_pem_x509_certificate(
certificate.encode(), default_backend()
certs = x509.load_pem_x509_certificates(fullchain_pem.encode())
if not certs:
raise ValueError("No certificates found in fullchain PEM")
certificate = certs[0].public_bytes(serialization.Encoding.PEM).decode()
chain = "".join(
c.public_bytes(serialization.Encoding.PEM).decode() for c in certs[1:]
)
expiry = cert.not_valid_after_utc.isoformat()

# Validate PEM format and consistency before trusting any cert data
cert_data: CertificateData = {
"private_key": private_key_pem.decode(),
"certificate": certificate,
"chain": chain,
"fullchain": fullchain_pem,
"expiry": certs[0].not_valid_after_utc.isoformat(),
"domains": domains,
"issued_at": datetime.now(timezone.utc).isoformat(),
}
self._validate_certificate_data(cert_data)

finally:
# Cleanup DNS records
self.cleanup_errors = []
for domain, validation in validations.items():
self._cleanup_dns_record(domain, validation)

return {
"private_key": private_key_pem.decode(),
"certificate": certificate,
"chain": chain,
"fullchain": fullchain_pem,
"expiry": expiry,
"domains": domains,
"issued_at": datetime.now(timezone.utc).isoformat(),
}
return cert_data

@retry_with_backoff(
max_attempts=2, base_delay=3, exceptions=(ClientError, IOError, ValueError)
Expand Down Expand Up @@ -614,6 +614,82 @@ def get_current_certificate(self) -> Optional[CertificateData]:
logger.error(f"Error parsing certificate data: {e}")
return None

def _validate_certificate_data(self, cert_data: CertificateData) -> None:
"""Validate PEM format and consistency of issued certificate data.

Checks that:
- Private key PEM is loadable and is an RSA key
- Certificate PEM is loadable and contains all requested domains in SANs
- Private key matches the certificate's public key
- Chain PEM (if present) contains valid certificates

Args:
cert_data: CertificateData containing private_key, certificate,
chain, and domains fields.

Raises:
ValueError: If any PEM component is malformed or inconsistent.
"""
private_key_pem = cert_data["private_key"].encode()
certificate = cert_data["certificate"]
chain = cert_data.get("chain", "")
domains = cert_data["domains"]

if not private_key_pem or not certificate or not domains:
raise ValueError(
"cert_data must contain non-empty private_key, certificate, and domains"
)

# Validate private key
try:
key = serialization.load_pem_private_key(
private_key_pem, password=None, backend=default_backend()
)
except (ValueError, TypeError, UnicodeDecodeError) as e:
raise ValueError(f"Invalid private key PEM: {e}") from e

if not isinstance(key, rsa.RSAPrivateKey):
raise ValueError(f"Expected RSA private key, got {type(key).__name__}")

# Validate leaf certificate
try:
cert = x509.load_pem_x509_certificate(certificate.encode())
except (ValueError, TypeError) as e:
raise ValueError(f"Invalid certificate PEM: {e}") from e

# Check private key matches certificate public key
if cert.public_key().public_numbers() != key.public_key().public_numbers():
raise ValueError("Private key does not match certificate public key")

# Check all requested domains are in the certificate SANs
try:
san_ext = cert.extensions.get_extension_for_class(
x509.SubjectAlternativeName
)
cert_domains = set(san_ext.value.get_values_for_type(x509.DNSName))
except x509.ExtensionNotFound:
cert_domains = set()

missing = set(domains) - cert_domains
if missing:
raise ValueError(f"Certificate missing SANs for domains: {missing}")

logger.info(f"Certificate SANs validated for domains: {cert_domains}")

# Validate chain certs if present
if chain.strip():
try:
chain_certs = x509.load_pem_x509_certificates(chain.encode())
except (ValueError, TypeError) as e:
raise ValueError(f"Invalid chain PEM: {e}") from e

if not chain_certs:
raise ValueError("Chain PEM contains no valid certificates")

logger.info(
f"Chain validated: {len(chain_certs)} intermediate certificate(s)"
)

def needs_renewal(self, cert_data: Optional[CertificateData]) -> bool:
"""Determine if certificate requires renewal based on expiry date.

Expand Down
12 changes: 6 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
[project]
name = "aws-certbot-lambda"
version = "0.1.0"
version = "0.1.1"
description = "Serverless TLS certificate renewal using Let's Encrypt ACME protocol"
requires-python = ">=3.11"

[project.optional-dependencies]
test = [
"pytest>=7.4",
"pytest-cov>=4.1",
"pytest-mock>=3.12",
"moto[secretsmanager,route53]>=4.2",
"aws-lambda-powertools>=2.31",
"pytest>=9.0",
"pytest-cov>=7.0",
"pytest-mock>=3.15",
"moto[secretsmanager,route53]>=5.1",
"aws-lambda-powertools>=3.24",
]

[tool.uv.workspace]
Expand Down
Loading