"Security concept: digital screen with icon Key, 3d render." Licensed from Shutterstock.

Creating Public Key Pinning headers (HPKP)

In my post two weeks about setting consistent headers in nginx, one of the headers I was concerned with was the Public Key Pinning header (HPKP). This, and the Strict Transport Security header (HSTS) are both defensive mechanisms meant to increase the reliability of secure connections to a given site.

The HPKP header provides a web browser a way to confirm that the certificate presented by a domain is one that the domain issued. It’s a defense against man-in-the-middle attacks targeting SSL certificates, uncovering proxies that rewrite SSL traffic. It’s also a protection against rogue certificates that could be issued for any number of nefarious purposes.

Getting Started

To set up a basic HPKP, two things are needed:

  • the private key for your current SSL certificate;
  • the private key for a new CSR, which you’ll eventually use to renew the domain’s certificate.

As I’m wont to do, I’ll use ethitter.com as the example domain throughout.

Why another CSR?

Requiring the current certificate’s private key is reasonable enough, but the second requirement may seem odd at first. Why create a new certificate signing request if you currently have a valid certificate?

HPKP headers have a 60-day duration, and since a certificate will eventually need to be renewed, there has to be a way to switch to a new certificate without interfering with the protections HPKP provides. Removing the header 60+ days before renewal would be one “approach,” but also one that defeats the purpose of HPKP.

To provide for certificate renewal, an HPKP header contains both a representation of the private key for the current certificate, as well as for the key corresponding to the CSR you’ll use to renew your domain when the time comes.

Accordingly, the CSR generated in the next section should be backed up securely, otherwise you’ll need to remove the HPKP header at least sixty days before renewing with a certificate that isn’t present in the header.

Generating the backup CSR

You’ll need OpenSSL for this. If you don’t have it, Google is your friend.

openssl req -new -sha256 -newkey rsa:4096 -nodes -out ethitter.com.csr -keyout ethitter.com.key

Proceed through the prompts, being sure to use the same common name as your current certificate. Once complete, make appropriate backups of the CSR and key files, as you’ll need those when the current certificate expires.

Generating public keys

Now that we have keys for the current certificate and a CSR, we’re ready to create versions of the keys to be used in the header.

Since the keys must remain secret, they aren’t directly represented in the header. Instead, we again rely on OpenSSL to generate a public key. The following command generates a public version of a private key, either the key for the current certificate, or the key accompanying the CSR generated in the last section.

openssl rsa -in ethitter.com.key -outform der -pubout | openssl dgst -sha256 -binary | openssl enc -base64

The above command is run twice, once for the current certificate’s key, and again for the key generated with the CSR from the last section. Save both outputs for use in the next section.

The MDN article linked in the Resources section includes commands for converting a certificate or CSR, but since keys should always be available, I omitted those examples.

Building the header

Now that we have the basic information needed for an HPKP header, we’re ready to create the header itself.

The most-basic version includes the two public keys created in the last section, along with the header’s life. The default duration of 5,184,000 seconds is equivalent to 60 days, as recommended by the IETF RFC.

Public-Key-Pins: pin-sha256="[CURRENT KEY]"; pin-sha256="[CSR KEY]"; max-age=5184000

If your use includes subdomains that you’d like to secure, append the includeSubDomains directive:

Public-Key-Pins: pin-sha256="[CURRENT KEY]"; pin-sha256="[CSR KEY]"; max-age=5184000; includeSubDomains

Optional reporting

Lastly, the header supports a reporting mechanism to monitor failures resulting from header mismatches. I’m only aware of one provider that receives these reports, but I’m comfortable recommending it nonetheless; it was created by Scott Helme, who’s released a number of excellent security-related tools. One of his services, report-uri.io, provides free HPKP failure reporting, among other benefits.

Appending a report-uri to the header will instruct the browser to report failures to the URL provided.

Public-Key-Pins: pin-sha256="[CURRENT KEY]"; pin-sha256="[CSR KEY]"; max-age=5184000; includeSubDomains; report-uri="https://report-uri.io/report/e15r"

Implementing the headers

Once you’ve generated the necessary data, the headers are added to your webserver just like any other header you’ve already added.

In nginx:

add_header Public-Key-Pins 'pin-sha256="[CURRENT KEY]"; pin-sha256="[CSR KEY]"; max-age=5184000; includeSubDomains; report-uri="https://report-uri.io/report/e15r"' always;

In Apache:

Header always set Public-Key-Pins 'pin-sha256="[CURRENT KEY]"; pin-sha256="[CSR KEY]"; max-age=5184000; includeSubDomains; report-uri="https://report-uri.io/report/e15r"'

The always directive in each example tells the respective webserver to set the header for all status code responses, which ensures the header appears on 4xx and 5xx responses. Without the always directive, webservers only include headers on 2xx and 3xx responses. While that may be appropriate handling for some cases, security headers should always be set.

Testing

Once the headers are in place, you can confirm their existence with two checks. First, use cURL to see that the header is there, followed by Qualys diagnostics to verify that the header is correct:

curl -I http://ethitter.com/

If the Public-Key-Pins header is shown, visit https://www.ssllabs.com/ssltest/analyze.html to check the header’s validity.

Resources