I did this for an EC2 Client VPN Endpoint and certificate based authentication in a continuous integration environment. It might also be suitable for localhost certificates and is pretty much what Minica or Easy RSA does. But I do a lot of infrastructure work with Terraform, so here we are.
Another important note: AWS has a Private Certificate Authority solution in their ACM product, but it can be pricey and was cost prohibitive for my use case.
Terraform Version & Providers
terraform {
required_version = ">= 1.3.9"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 4.58"
}
tls = {
source = "hashicorp/tls"
version = "4.0.4"
}
local = {
source = "hashicorp/local"
version = "2.4.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
variable "app" {
default = "example"
}
variable "env" {
default = "dev"
}
Private Keys
I chose to generate these “out of band” — not in terraform, but via openssl genrsa
. As mentioned above, my use case here was the AWS EC2 Client VPN Endpoint, which supports 1024 and 2048 bit RSA private keys only.
For our example, we’ll generate two private keys: one for the root certificate authority and another for a client.
openssl genrsa -out keys/ca.pem 2048
openssl genrsa -out keys/client.pem 2048
Now we need somewhere to store these keys. I use a lot of SSM parameter store and ended up goign with that. Secrets Manager would also be an excellent choice.
We’ll register these parameters with terraform and CHANGEME
values and then manually put in the private keys via the CLI or UI.
resource "aws_kms_key" "ca" {
description = "${var.app}/${var.env} certificate authority"
tags = {
Application = var.app
Environment = var.env
}
}
resource "aws_kms_alias" "ca" {
name = "alias/${var.app}-${var.env}-ca"
target_key_id = aws_kms_key.ca.key_id
}
resource "aws_ssm_parameter" "ca-private-key" {
name = "/${lower(var.app)}/${var.env}/ca_private_key"
type = "SecureString"
key_id = aws_kms_key.vpn-pki.id
value = "CHANGEME"
lifecycle {
ignore_changes = [value]
}
}
resource "aws_ssm_parameter" "client-private-key" {
name = "/${lower(var.app)}/${var.env}/client_private_key"
type = "SecureString"
key_id = aws_kms_key.vpn-pki.id
value = "CHANGEME"
lifecycle {
ignore_changes = [value]
}
}
Because were nice devops people, we’ll provide scripts for future engineers to download and upload keys.
#!/usr/bin/env bash
set -e
export AWS_PAGER=""
pushd "$(git rev-parse --show-toplevel)"
uploadPrivateKey() {
local name=$1
aws ssm put-parameter \
--name "/example/dev/${name}_private_key" \
--type SecureString\
--key-id alias/example-dev-ca \
--overwrite \
--value file://keys/${name}.pem
}
uploadPrivateKey ca
uploadPrivateKey client
#!/usr/bin/env bash
set -e
export AWS_PAGER=""
pushd "$(git rev-parse --show-toplevel)"
getPrivateKey() {
local name=$1
aws ssm get-parameter \
--name "/example/dev/${name}_private_key" \
--with-decryption \
--output text \
--query Parameter.Value > "keys/$name.pem"
}
getPrivateKey ca
getPrivateKey client
Creating the Root Certificate
To do this we’ll use the TLS provider and a self signed, root certificate. A big advantage here is that if someone terraform apply
‘s during the certificate’s early renewal hours or after its expiration, the TLS provider will regenerate the certificate.
Very important note: browsers have rules about how long of a certificate expiration they tolerate (like Chromium). You may want to note down your certificate validatity period here.
locals {
ten_years = 87600
five_years = 43830
ninety_days = 2160
}
resource "tls_self_signed_cert" "ca" {
private_key_pem = aws_ssm_parameter.ca-private-key.value
is_ca_certificate = true
subject {
common_name = "vpn.example.com"
organization = "Acme"
organizational_unit = "Engineering"
country = "USA"
}
validity_period_hours = local.ten_years
early_renewal_hours = local.ninety_days
allowed_uses = [
"cert_signing",
"crl_signing",
"code_signing",
"server_auth",
"client_auth",
"digital_signature",
"key_encipherment",
]
}
In order to make this available to the EC2 VPN Endpoint, we also need to import it into ACM.
resource "aws_acm_certificate" "ca" {
private_key = aws_ssm_parameter.ca-private-key.value
certificate_body = tls_self_signed_cert.vpn-ca.cert_pem
tags = {
Application = var.app
Environment = var.env
}
}
And to make things easy, we’ll also export the certificate file. This file is not confidential and can be commited to the repository.
resource "local_file" "ca-certificate" {
content = tls_self_signed_cert.ca.cert_pem
filename = "${path.module}/certificates/ca.pem"
file_permission = "0666"
}
Client Certificates
For this we need a certificate signing request and a locally signed certificate both of which can be done via the TLS provider. We’ll output the certificate to a local file as well.
Client certificate could be a terraform module. Need another client cert? Add another declartion of the module.
resource "tls_cert_request" "client" {
private_key_pem = aws_ssm_parameter.client-private-key.value
subject {
common_name = "client1.vpn.example.com"
organization = "Acme"
organizational_unit = "Engineering"
country = "USA"
}
}
resource "tls_locally_signed_cert" "client" {
cert_request_pem = tls_cert_request.client.cert_request_pem
ca_private_key_pem = aws_ssm_parameter.ca-private-key.value
ca_cert_pem = tls_self_signed_cert.ca.cert_pem
validity_period_hours = local.five_years
early_renewal_hours = local.ninety_days
allowed_uses = [
"client_auth",
]
}
resource "local_file" "client-certificate" {
content = tls_locally_signed_cert.client.cert_pem
filename = "${path.module}/certificates/client.pem"
file_permission = "0666"
}
Example Code
This post is part of a larger set of examples around an EC2 Client VPN Endpoint, but the certificate authority stuff can be found on github.