Create Your Own Certificate Authority with Terraform

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.