Not today...

comments

Snippet

Terraform PKI

Tagged terraform

Once upon a time, I tried to setup an infrastructure using Terraform and Docker. Spoiler alert: I reevaluated my setup to go for a Kubernetes approach which is easier and more robust. However, this experiment gave me the opportunity to better understand a really important part of a self hosted infrastructure: how to set up a PKI.

Disclaimer: I may not use the proper terms or be sloppy on some concepts. I am not fully mastering this domain and want to share what I discovered. Take this article with a grain of salt. Now bear with me and let’s start.

The goal

The basic idea of a KPI is to use a root certificate (most of the time self signed) which will be trusted by your infrastructure components. Then sign child certificates for every endpoint for a small period of time.

A good example of this is the documentation of vault. It consists of a long living root certificate (valid for a few years for example). Which will be stored in the most secure manner possible, ideally on a physical device not connected to the internet. This root certificate is your last defense line and is used to invalidate all or part of the infrastructure in case a breach occurs (and some certificates got leaked).

Once you have a this base certificate you will sign an intermediate one with a shorter TTL (Time To Live). Usually, we sign it for one year, at least in the various companies I worked for, but I guess it may vary. However, the basic idea is to have it last long enough to avoid a complicated rotation, but not too long as well.

Now that we have this intermediate certificate we will use it to sign all the child certificates used accross the infrastructure. Those child certificates will need to be rotated on a regular basis (usually a few days).

This last point may be hard to reach and it is not uncommon to have child certificates signed for a few months as well.

The implementation

In my example I will use Terraform because it makes it pretty explicit and avoid a lot of command, we are using the official terraform provider. You can perform all of this using the openssl CLI, I may write a small article in the future to better explain this.

Last but not least: In this example I will use an elliptic curve encryption algorithm instead of the well known RSA one. Both are totally fine and can be used here, I am just using ECDSA for personal reasons and because there is still a lack of examples using it on the internet.


# define a few values for my use case, replace it with your naming
locals {
  cn       = "rulz.xyz"
  org      = "Rulz Corp."
  validity = 87600 # This is approximatively 10 years
}

# To build a certificate you actually need a private key, so we are creating it here
resource "tls_private_key" "rulz_ca" {
  algorithm   = "ECDSA"
  ecdsa_curve = "P384"
}

# Here is our root certificate, using our private key previously created
resource "tls_self_signed_cert" "rulz_ca" {
  key_algorithm     = "ECDSA"
  private_key_pem   = tls_private_key.rulz_ca.private_key_pem  # we use our private key
  is_ca_certificate = true                                     # this is a certificate authority

  subject {
    common_name  = local.cn
    organization = local.org
  }

  validity_period_hours = local.validity

  allowed_uses = [
    "cert_signing",  # this is what will make this certificate able to sign child certificates
  ]
}

Now we want to create our intermediate certificate. Since we made the root one valid for 10 years, we want this one to be valid for only one year:


# We need to create a key for the intermediate cert
resource "tls_private_key" "rulz_int" {
  algorithm   = "ECDSA"
  ecdsa_curve = "P384"
}

# Once we have the key we can create a request
resource "tls_cert_request" "rulz_int" {
  key_algorithm   = "ECDSA"
  private_key_pem = tls_private_key.rulz_int.private_key_pem

  # the wildcard here does not have an impact but will help remind that this
  # is an intermediate signing all *.rulz.xyz certificates
  subject {
    common_name  = "*.${local.cn}"
  }
}

# And sign the request with our root ca
resource "tls_locally_signed_cert" "rulz_int" {
  cert_request_pem   = tls_cert_request.rulz_int.cert_request_pem
  ca_key_algorithm   = "ECDSA"
  ca_private_key_pem = tls_private_key.rulz_ca.private_key_pem
  ca_cert_pem        = tls_self_signed_cert.rulz_ca.cert_pem
  is_ca_certificate  = true

  validity_period_hours = local.validity / 10 # one year of validity

  allowed_uses = [
    "cert_signing",
  ]
}

We are now ready to create the child certificate. It will not have the signing capability and will only be able to authenticate. The creation is the same as our intermediate certificate, only the capabilities and the fact that it is not a certificate authority vary.

resource "tls_private_key" "rulz_child" {
  algorithm   = "ECDSA"
  ecdsa_curve = "P384"
}

resource "tls_cert_request" "rulz_child" {

  key_algorithm   = "ECDSA"
  private_key_pem = tls_private_key.rulz_child.private_key_pem
  dns_names       = [ "child.${local.cn}" ]

  subject {
    common_name = "child.${local.cn}"
  }
}

resource "tls_locally_signed_cert" "rulz_child" {
  cert_request_pem   = tls_cert_request.rulz_child.cert_request_pem
  ca_key_algorithm   = "ECDSA"
  ca_private_key_pem = tls_private_key.rulz_int.private_key_pem
  ca_cert_pem        = tls_locally_signed_cert.rulz_int.cert_pem

  validity_period_hours = local.validity / 40 # valid for ~ 3 months

  # this will only allow to be used as a server
  allowed_uses = [
    "server_auth",
  ]
}

Deploy and Test

To properly use what we generated we need to output it to files to use it with other softwares. Here I will use the local_file resource to output the certificates and keys to my filesystem:

First we need to output our root CA to a file which will be distributed across the whole infrastructure.

locals {
  path = "${path.module}/ca_certificate.pem"
}

resource "local_file" "rulz_ca" {
  filename        = local.path
  content         = tls_self_signed_cert.rulz_ca.cert_pem
  file_permission = "0644"

  provisioner "local-exec" {  # I want to remove the certificate when I destroy the infra
    when    = destroy
    command = "rm ${self.filename}"
  }
}

Now we need to output our child certificate to a file as well. This child certificate will need to contain the full chain of trust.

Why should I need the full chain of trust: This come down to the way certificates are implemented. On a network infrastructure you may have as many intermediate signers as you want. The only thing is each intermediate signer certificate is signed by the previous signer private key. However, your client only has the root certificate. So, when checking the child it must ensure that the whole hierarchy matches and goes to the root certificate which is trusted. This is why the certificate handed over by your child server must contain this full chain to let the client check it.

The order in which each certificate appear is important, the root is at the bottom, the child at the top. Then each intermediate must appear in order between those two. The extension does not have any impact you can use .crt, .key, .pem without any repercussion.

resource "local_file" "rulz_child_key" {
  filename        = "${path.module}/child.key.pem"
  content         = tls_private_key.rulz_child.private_key_pem
  file_permission = "0600" # we want to protect the private key from others reading

  provisioner "local-exec" {
    when    = destroy
    command = "rm ${self.filename}"
  }
}

resource "local_file" "rulz_child_cert" {
  filename        = "${path.module}/child.crt.pem"
  file_permission = "0644"

  content = join("", [
    tls_locally_signed_cert.rulz_child.cert_pem,
    tls_locally_signed_cert.rulz_int.cert_pem,
    tls_self_signed_cert.rulz_ca.cert_pem,
  ])

  provisioner "local-exec" {
    when    = destroy
    command = "rm ${self.filename}"
  }
}

We are now ready to start our server, I will use socat following one of my previous post setup:

socat "ssl-l:8443,cert=child.crt.pem,key=child.key.pem,verify=0,fork,reuseaddr" \
        SYSTEM:"echo HTTP/1.0 200; echo Content-Type\: text/plain; echo; echo Hello World\!;"

And now we can use curl in another terminal to check if our setup is valid:

curl --cacert ca_certificate.pem --resolve "child.rulz.xyz:8443:127.0.0.1" https://child.rulz.xyz:8443

That’s it! It should be a good introduction to KPI and also explain just enough to have a basic understanding of certificate chain trust. You can find the code of this post on the github repository, and launch it with terraform init && terraform apply. Happy deployment!