Sitemap
Better Programming

Advice for programmers.

Secure Services With Let’s Encrypt SSL/TLS Certificates Using DNS-01 Challenges

5 min readSep 10, 2022

--

A padlock drawn above a laptop.
Photo by FLY:D on Unsplash

It’s 2022, and insecure websites are a thing of the past. Most browsers won’t even let you connect to a site served over bare HTTP.

However, that hasn’t changed the fact that dealing with SSL certificates can be cumbersome. You have a couple of options:

  • go the traditional way by buying an SSL certificate from one of the many providers out there
  • or get one for free from Let’s Encrypt

I want to note that free is not always better. Various providers offer different levels of security and other features that Let’s Encrypt might not. But some security is infinitely better than no security!

You might have suspected there’s a catch coming. Let’s Encrypt issues certificates valid for 90 days, while traditional providers for a little bit more than a year. The latter does make it a bit easier to deal with the certificate refresh process (since you only have to do it once every week, it is actually more secure to refresh and expire certificates sooner, as it reduces the time for an attack to take place if the cert’s private key was to be stolen by a third party.

This, however, increases toil as the next refresh is always just around the corner. And it’s not unheard of that you might be serving your assets from more than one server, increasing the amount of manual work required every time certificates expire.

By now, it should be clear that any interaction with Let’s Encrypt should be automated. Luckily, they supported this flow from the start, using the ACME protocol (invented for this very purpose).

If you’re running a standard HTTP-based service, website, etc., you will find plenty of options (tools) to solve this problem:

  • certbot — the reference ACME implementation
  • caddy — a Golang-based HTTP server
  • or tens of other implementations in any programming language you can dream of

There’s one problem — HTTP is the keyword. Most (if not all) of these implementations assume that you are running a web server or can somehow expose a service over HTTP so that the client can confirm that it controls the domain for which the certificate is issued.

But what if you can’t !? What if you can only expose a single port (and it’s not for HTTP), or if you’re running a service in an air-gapped environment, which can only connect out but cannot allow incoming traffic?

Enter the DNS-01 challenge

As per the Let’s Encrypt docs:

this challenge asks you to prove that you control the DNS for your domain name by putting a specific value in a TXT record under that domain name.

I couldn’t immediately find a client that supported this flow. This is in part because DNS is a protocol and not an API. In my case, I manage all my domains using Cloudflare, so I needed an ACME client able to update TXT records on Cloudflare — not the first combination that comes to mind…

Cloudflare is one of many DNS hosting platforms — the cost of building and maintaining a library to cover most/all such platforms is relatively high — I realized I had to build my own!

Write an ACME/Cloudflare client using Go

Lately, I’ve been learning Go. It’s a hugely popular language with a healthy developer ecosystem. I like that it’s usually easy to find various libraries that can solve most problems, such as for this very need:

Let’s start with a simple struct to hold the configuration for our future SSL/TLS certificate.

type Config struct {
CertificatePrivateKeyPath string
CloudflareAPIToken string
}
  • ACMEAccountPrivateKeyPath: you must first register an account with Let’s Encrypt; our code will generate the key and store it at this path
  • CertificatePrivateKeyPath: every certificate needs a private key; we will generate it and store it at this path
  • CertificatePath: we will store the certificate obtained from Let’s Encrypt at this path
  • CloudflareAPIToken: a Cloudflare API token, duh!

We will interact with the library via a straightforward API:

func RequestCertificate(
domains []string,
ownerEmail []string,
cfg *Config
) error {...}
// called as
domains := []string{"example.com"}
emails := []string{"mailto:info@example.com"}
err := RequestCertificate(domains, emails, c)
if err != nil {
...
}

Let’s proceed by implementing RequestCertificate :

ctx := context.Background()// We use Uber's Zap logger, as required by acmez
logger, _ := zap.NewProduction()
defer logger.Sync() // flushes buffer, if any
// Initialize a DNS-01 solver, using Cloudflare APIs
solver := &certmagic.DNS01Solver{
DNSProvider: &cloudflare.Provider{APIToken: cfg.CloudflareAPIToken},
}
// The CA endpoint to use (prod or staging)
// switch to Production once fully tested
// otherwise you might get rate-limited in Production
// before you've had a chance to test that your client
// works as expected
caLocation = certmagic.LetsEncryptStagingCA
//caLocation := certmagic.LetsEncryptProductionCA
// CONTINUED BELOW ...

FYI, the acmez library uses Uber’s Zap logger. You can skip providing a logger, which means you will not get any information from the ACME client, missing out on potential errors.

// Initialize an acmez client
client := acmez.Client{
Client: &acme.Client{
Directory: caLocation,
UserAgent: "[SOMETHING TO IDENTIFY YOUR CLIENT]",
Logger: logger,
},
ChallengeSolvers: map[string]acmez.Solver{
acme.ChallengeTypeDNS01: solver,
},
}
// Generate a private key for your Let's Encrypt account
accountPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("ecdsa.GenerateKey() could not generate an account key: %v", err)
}
// Create a Let's Encrypt account
account := acme.Account{
Contact: ownerEmail,
TermsOfServiceAgreed: true,
PrivateKey: accountPrivateKey,
}
acc, _ := client.NewAccount(ctx, account)
if err != nil {
return fmt.Errorf("client.NewAccount() could not create new account: %w", err)
}

At this point, we are authenticated with Let’s Encrypt and ready to issue certificate requests.

// Generate a private key for the certificate
certPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("generating certificate key: %w", err)
}
// TODO(left to the reader): store this key to a file// obtain certificates from Let's Encrypt
certs, err := client.ObtainCertificate(ctx, acc, certPrivateKey, domains)
if err != nil {
return fmt.Errorf("client.ObtainCertificate() could not obtain certificate: %w", err)
}
// since the client returns more than one cert, it is up to you
// to choose the most appropriate one (such as one which contains
// the full chain, including any intermediate certificates)
for _, cert := range certs {
log.Println(string(cert.ChainPEM))
// TODO(left to the reader): store cert.ChainPEM to a file
}
return nil

One tip I can give you is that to store a private key, you must first convert it to ASN.1 DER form. This is easily achieved; see below:

func EncodeAndStorePrivateKey(privateKey *ecdsa.PrivateKey, filename string, mode fs.FileMode) error {
x509Encoded, err := x509.MarshalECPrivateKey(privateKey)
if err != nil {
return err
}
data := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: x509Encoded})
return os.WriteFile(filename, data, mode)
}

And that’s pretty much it! You can now issue and refresh SSL/TLS certificates with Let’s Encrypt by using DNS-01 challenges (if your domains’ DNS is managed on Cloudflare.

One more thing; here’s a complete list of Go imports to add — this will save you a bit of time when putting all of this together!

import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"fmt"
"io/fs"
"log"
"os"
"github.com/caddyserver/certmagic"
"github.com/libdns/cloudflare"
"github.com/mholt/acmez"
"github.com/mholt/acmez/acme"
"go.uber.org/zap"
)

Don’t forget to go get the external modules listed above!

Thanks for reading. Let me know what you think, on Twitter!

--

--

Mihai Bojin
Mihai Bojin

Written by Mihai Bojin

Software Engineer at heart, Manager by day, Indie Hacker at night. Writing about DevOps, Software engineering, and Cloud computing. Opinions my own.