This will be the first of a number of posts that I would have made in the past if I’d had a blog at the time. In other words the contents dates back a couple of years. But it should nevertheless (hopefully) still be relevant/interesting!

Why bother with a private PKI at all? Just use Let’s Encrypt…

For publicly-trusted X.509 certificates, Let’s Encrypt has undoubtedly become the go-to solution for most use-cases (pretty much anything that doesn’t require OV or EV certificates). But within an enterprise environment there are plenty of scenarios in which Let’s Encrypt is not an appropriate choice, and instead a locally-managed PKI is called for. I’ll provide one example…

I often find myself frustrated when trying to implement X.509 certificates for client authentication purposes due to the inexplicably limited number of ways in which many applications are able to restrict which certificates to allow. Quite often the only way to control which end-entity certificates are accepted as a valid proof of identity is to restrict which CA certificates to trust, which provides no value unless you only trust CAs that you operate yourself. Even in cases where the authenticating application uses an attribute in the certificate to map it to a user account in an LDAP directory service, and then leverages group memberships defined in that directory to determine what level of access to grant for each certificate (including potentially no access), the whole process remains dependent upon trusting that the value contained in the mapped attribute could never been included in a certificate issued to someone other than the owner of the user account to which it maps… which ultimately means completely trusting the security of your application to the CAs that you trust. While that may be the status-quo for many server certificates (such as those used for public websites), it is usually unacceptable for client certificates. Hence the continued need for private PKIs.

What PKI software to use?

PKIs are complex. That complexity is compounded by the fact that once you have implemented and begun to use it, you can’t easily make changes to the configuration without potentially impacting all already-issued and still-valid certificates.

There are plenty of commercial options available for use in an enterprise. Windows environments are likely to use Microsoft’s Active Directory Certificate Services, as it offers convenient integration with Active Directory and Windows operating systems.

For my home network (and for experimentation / learning purposes), I have looked at numerous open source options over the years. Some time ago I decided to go with EJBCA, as it appeared to be the most feature-rich solution out there. Although it certainly worked, and was a learning experience, in reality it was total overkill as I only needed a small fraction of the functionality, and keeping it in a healthy functioning state proved to be too time-consuming to be worth it. I also wasn’t a fan of it being Java-based.

More recently I looked at some modern solutions such as CFSSL and Vault, both of which have a lot going for them. However I ultimately concluded that I still wanted greater flexibility than they could offer (mainly to satisfy my desire for experimentation). Of course the greatest flexibility of all can be achieved by using OpenSSL directly. However doing so effectively was both beyond my level of expertise and once again likely to be too time-consuming to be worth it.

Then I remembered having used Easy-RSA in the past to issue and manage certificates for use with OpenVPN. It turns out that Easy-RSA is essentially a bunch of wrapper scripts built around OpenSSL, which together implement the basic functionality of a PKI. I liked the fact that I could read those wrapper scripts to understand what it was doing behind the scenes, and then extend its functionality myself without too much difficulty if I wanted. It seemed like the perfect starting point from which to build the PKI solution that I was looking for.

Designing a PKI

Due to the difficulty of changing the structure of a PKI once it is operational, careful planning is required at the outset, starting with the CA hierarchy.

The use of Easy-RSA makes it relatively straightforward to instantiate additional CAs, so I decided not only to have a dedicated non-issuing root CA, but also to have multiple issuing CAs, each issuing certificates for a different purpose (for example I have one issuing CA for server certificates, and a different issuing CA for client certificates).

Next up is certificate profiles, covering the important attributes for different types of certificate (you can’t issue your root CA certificate until you’ve decided what attributes it should have!), and selecting what cryptographic algorithms to use.

Having researched and planned all of this, I then realised that these details are supposed to be documented in a Certification Practice Statement (CPS). So I created such a document, and made it available over HTTP, so that it can itself be referenced by URL in the “Certificate Policy” attribute in the certificates. I made it available here as it doesn’t contain anything I’m not comfortable making public: lspeed.org Public Key Infrastructure Certification Practice Statement. It contains a lot of additional detail on my design choices, including the rationale behind them, so do read it alongside this post if you are interested in those things.

Customising Easy-RSA

Moving on to the implementation, I chose to host all my CAs on a single VM running Gentoo Linux, each in their own sub-directory under /etc/pki. I start by installing easy-rsa (v3.0.5), libfaketime (more on that later), and cfssl (used for its OCSP functionality), and then create a tweaked version of the OpenSSL configuration shipped with Easy-RSA and set up some tweaked x509-types to match the specs I came up with for the certificate profiles (as documented in the CPS):

emerge -av easy-rsa libfaketime cfssl
mkdir /etc/pki/x509-types
cp /usr/share/easy-rsa/openssl-1.0.cnf /etc/pki/
cp /usr/share/easy-rsa/x509-types/* /etc/pki/x509-types/

Here are diffs showing the changes I then applied to the openssl-1.0.cnf and x509-types/ca files:

--- /usr/share/easy-rsa/openssl-1.0.cnf 2018-02-15 16:03:28.023402027 +0000
+++ /etc/pki/openssl-1.0.cnf    2018-02-17 21:53:06.010101175 +0000
@@ -18,6 +18,7 @@
 certificate    = $dir/ca.crt           # The CA certificate
 serial         = $dir/serial           # The current serial number
 crl            = $dir/crl.pem          # The current CRL
+crlnumber      = $dir/crlnumber
 private_key    = $dir/private/ca.key   # The private key
 RANDFILE       = $dir/.rand            # private random number file

@@ -114,15 +115,14 @@
 # PKIX recommendations:

 subjectKeyIdentifier=hash
-authorityKeyIdentifier=keyid:always,issuer:always

 # This could be marked critical, but it's nice to support reading by any
 # broken clients who attempt to do so.
-basicConstraints = CA:true
+basicConstraints = critical, CA:true

 # Limit key usage to CA tasks. If you really want to use the generated pair as
 # a self-signed cert, comment this out.
-keyUsage = cRLSign, keyCertSign
+keyUsage = critical, cRLSign, keyCertSign

 # nsCertType omitted by default. Let's try to let the deprecated stuff die.
 # nsCertType = sslCA
@@ -133,5 +133,5 @@
 # Only issuerAltName and authorityKeyIdentifier make any sense in a CRL.

 # issuerAltName=issuer:copy
-authorityKeyIdentifier=keyid:always,issuer:always
+authorityKeyIdentifier=keyid:always
--- /usr/share/easy-rsa/x509-types/ca   2018-12-17 07:17:51.376734773 +0000
+++ /etc/pki/x509-types/ca      2018-02-25 15:55:57.442133868 +0000
@@ -6,8 +6,9 @@
 #
 # basicConstraints = CA:TRUE, pathlen:1

-basicConstraints = CA:TRUE
+basicConstraints = critical, CA:TRUE, pathlen:0
 subjectKeyIdentifier = hash
-authorityKeyIdentifier = keyid:always,issuer:always
-keyUsage = cRLSign, keyCertSign
-
+authorityKeyIdentifier = keyid:always
+keyUsage = critical, cRLSign, keyCertSign
+authorityInfoAccess = OCSP;URI:http://pki.lspeed.org/ocsp/$ENV::CA_NAME
+certificatePolicies = ia5org,@cpAll
--- /usr/share/easy-rsa/x509-types/COMMON       2018-02-16 20:04:29.189438645 +0000
+++ /etc/pki/x509-types/COMMON  2018-02-16 20:03:57.814304078 +0000
@@ -4,4 +4,5 @@
 # It could be used to add values every cert should have, such as a CDP as
 # demonstrated in the following example:

#crlDistributionPoints = URI:http://example.net/pki/my_ca.crl
+crlDistributionPoints = URI:http://pki.lspeed.org/lspeed_$ENV::CA_NAME.crl
+authorityInformationAccess = URI:http://pki.lspeed.org/lspeed_$ENV::CA_NAME.crt
--- /usr/share/easy-rsa/x509-types/client    2018-03-20 20:36:48.182453423 +0000
+++ /etc/pki/x509-types/client    2018-03-20 20:57:09.787860524 +0000
@@ -1,8 +1,9 @@
 # X509 extensions for a client

-basicConstraints = CA:FALSE
+basicConstraints = critical, CA:FALSE
 subjectKeyIdentifier = hash
-authorityKeyIdentifier = keyid,issuer:always
+authorityKeyIdentifier = keyid:always
+authorityInfoAccess = OCSP;URI:http://pki.lspeed.org/ocsp/$ENV::CA_NAME
 extendedKeyUsage = clientAuth
-keyUsage = digitalSignature
-
+keyUsage = critical, digitalSignature
+certificatePolicies = ia5org,@cpStandard
--- /usr/share/easy-rsa/x509-types/server       2018-12-17 07:17:51.377734792 +0000
+++ /etc/pki/x509-types/server  2018-02-25 12:39:32.679873646 +0000
@@ -1,8 +1,9 @@
 # X509 extensions for a server

-basicConstraints = CA:FALSE
+basicConstraints = critical, CA:FALSE
 subjectKeyIdentifier = hash
-authorityKeyIdentifier = keyid,issuer:always
+authorityKeyIdentifier = keyid:always
+authorityInfoAccess = OCSP;URI:http://pki.lspeed.org/ocsp/$ENV::CA_NAME
 extendedKeyUsage = serverAuth
-keyUsage = digitalSignature,keyEncipherment
-
+keyUsage = critical, digitalSignature,keyEncipherment
+certificatePolicies = ia5org,@cpStandard

Besides implementing the specs from the certificate profiles, you’ll notice I have additionally removed the issuer from the authorityKeyIdentifier attribute (see https://v13.gr/2013/04/11/x509v3-authority-key-identifier-authoritykeyidentifier/ for an explanation of why that is important), and added a crlnumber file to keep track of the CRL serial.

Initialising the root CA

Now it’s time to initialise the root CA. This is where libfaketime comes in… to achieve “nice round timestamps” in the validity start and end dates. I also opt for the longest possible validity time whilst avoiding the “Year 2038 Problem” (when 32-bit Unix epoch timestamps wrap), and specify my chosen crypto algorithms. Note that some of these commands are very long, so I have manually split them across multiple lines to avoid the need for excessive scrolling within the code block below:

# Root CA - Setup
CA_NAME=r1
mkdir /etc/pki/${CA_NAME}
EASYRSA=/usr/share/easy-rsa /bin/sh /usr/share/easy-rsa/easyrsa \
    --pki-dir=/etc/pki/${CA_NAME} init-pki
# Calculate the number of days between today and the day before the year 2038 bug hits
TZ=UTC CA_VALIDITY=`echo "( \`date -d 2038-01-18 +%s\` - \`date -d \\\`date +%F \\\` \
    +%s\` ) / (24*3600)" | bc -l | sed 's/\..*//'`
# Use faketime to get nice round timestamps (ie 00:00:00) in start and end dates
TZ=UTC EASYRSA_EXTRA_EXTS='basicConstraints = critical,CA:TRUE' \
    EASYRSA_SSL_CONF=/etc/pki/openssl-1.0.cnf EASYRSA=/usr/share/easy-rsa faketime -f \
    "`date --rfc-3339=seconds -d \`date +%F\` | sed s/\+.*//`" /bin/sh \
    /usr/share/easy-rsa/easyrsa --batch --pki-dir=/etc/pki/${CA_NAME} --use-algo=ec \
    --curve=secp384r1 --digest=sha384 --dn-mode=org --req-c=GB --req-st= --req-city= \
    --req-ou= --req-email= --req-org=lspeed --req-cn="lspeed ECC Root CA - R1" \
    --days=$CA_VALIDITY build-ca

Finally the root CA certificate is converted to binary DER format and placed in a separate directory intended to be made available to clients over HTTP:

mkdir /etc/pki/pub
openssl x509 -in /etc/pki/${CA_NAME}/ca.crt -outform DER > \
    /etc/pki/pub/lspeed_${CA_NAME}.crt

CRL for the root CA

Bearing in mind I would like to avoid any unnecessary human involvement in the day-to-day operation of the CA, my approach to CRLs is to pregenerate all CRLs in advance, with the same content but different (future) validity dates, forming a continuous set of CRLs spanning the entire remaining validity period of the CA certificate itself. That way, assuming no additional certificates are revoked, the CRLs can be generated once and then never again. This is important because the CRL is signed by the CA, and therefore manual entry of the password which protects the CA’s private key is required in order to generate a CRL.

If a certificate does need to be revoked (which in my environment I expect to happen extremely infrequently), then human involvement will be required to once again regenerate all future CRLs at that time. Even more so than during certificate issuance, faketime plays an essential role here.

I’ve opted to generate monthly CRLs, but for each one to be valid for 75 days, thereby giving a decent overlap period in which to switch from using one CRL to the next… hopefully sufficient time to spot and fix any issues with the process if they arise.

# Root CA - CRL
CA_NAME=r1
# Temporarily decrypt CA private key in order to issue numerous CRLs
cp /etc/pki/${CA_NAME}/private/ca.key /etc/pki/${CA_NAME}/private/ca.key.orig
EASYRSA_PKI=/etc/pki/${CA_NAME} /bin/sh /usr/share/easy-rsa/easyrsa set-ec-pass ca \
    nopass
# Clear existing pre-generated CRLs
rm -r /etc/pki/${CA_NAME}/crls
mkdir /etc/pki/${CA_NAME}/crls
# Create new pre-generated CRLs
crldate=`date +%Y-%m-01`
crlend="2038-03-01"
until [ "$crldate" == "$crlend" ]; do
  TZ=UTC faketime $crldate date -I | sed s/-//g > /etc/pki/${CA_NAME}/crlnumber
  TZ=UTC EASYRSA_CRL_DAYS=75 EASYRSA=/usr/share/easy-rsa \
      EASYRSA_SSL_CONF=/etc/pki/openssl-1.0.cnf faketime $crldate /bin/sh \
      /usr/share/easy-rsa/easyrsa --pki-dir=/etc/pki/${CA_NAME} --use-algo=ec \
      --curve=secp384r1 --digest=sha384 gen-crl
  openssl crl -in /etc/pki/${CA_NAME}/crl.pem -outform DER > \
      /etc/pki/${CA_NAME}/crls/$crldate.crl
  crldate=`date --date "$crldate 1 month" +%Y-%m-%d`
done
chmod -R go-rwx /etc/pki/${CA_NAME}/crls
# Create current CRL
date -I | sed s/-//g > /etc/pki/${CA_NAME}/crlnumber
EASYRSA_CRL_DAYS=75 EASYRSA=/usr/share/easy-rsa \
    EASYRSA_SSL_CONF=/etc/pki/openssl-1.0.cnf /bin/sh /usr/share/easy-rsa/easyrsa \
    --pki-dir=/etc/pki/${CA_NAME} --use-algo=ec --curve=secp384r1 --digest=sha384 \
    gen-crl
openssl crl -in /etc/pki/${CA_NAME}/crl.pem -outform DER > \
    /etc/pki/pub/lspeed_${CA_NAME}.crl
# Re-encrypt CA private key
mv /etc/pki/${CA_NAME}/private/ca.key.orig /etc/pki/${CA_NAME}/private/ca.key

OCSP for the root CA

I take a similar approach with OCSP, by pre-generating an OCSP response for every issued certificate (rather than having the OCSP responder do so dynamically whenever an OCSP request is received). Unlike with CRLs, the OCSP responses are signed by a special OCSP certificate. That means it is more reasonable to avoid having to enter the private key’s password during OCSP response pre-generation by simply not password-protecting the OCSP certificate’s private key in the first place), and therefore we can get away with pre-generating just a single OCSP response for each issued certificate, rather than pre-generating as many of them as would be required to last until the root CA’s certificate expires (as future responses can be generated automatically).

Note that by pre-generating OCSP responses, the OCSP responder will not be capable of supporting the OCSP nonce extension. However that appears to be common-practice.

But first we need to issue the OCSP responder’s own certificate. This requires its own certificate profile (and thus x509-type file):

echo '# X509 extensions for an OCSP Responder
basicConstraints = critical, CA:FALSE
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always
extendedKeyUsage = critical, OCSPSigning
keyUsage = critical, digitalSignature
certificatePolicies = ia5org,@cpStandard
noCheck = ignored' > /etc/pki/x509-types/ocsp
# Root CA - Issue OCSP Cert
CA_NAME=r1
EASYRSA=/usr/share/easy-rsa /bin/sh /usr/share/easy-rsa/easyrsa --batch \
    --pki-dir=/etc/pki/${CA_NAME} --use-algo=ec --curve=secp384r1 --dn-mode=org \
    --req-c=GB --req-st= --req-city= --req-ou= --req-email= --req-org=lspeed \
    --req-cn="lspeed ECC OCSP Responder - R1" gen-req "lspeed ECC OCSP Responder \
    - R1" nopass
# Calculate the number of days between today and the day before the year 2038 bug hits
TZ=UTC CA_VALIDITY=`echo "( \`date -d 2038-01-18 +%s\` - \`date -d \\\`date +%F \\\` \
    +%s\` ) / (24*3600)" | bc -l | sed 's/\..*//'`
# Use faketime to get nice round timestamps (ie 00:00:00) in start and end dates
TZ=UTC CA_NAME=${CA_NAME} EASYRSA_EXTRA_EXTS=$'[ cpStandard ]\npolicyIdentifier = \
    1.3.6.1.4.1.51546.1.1.1.1\nCPS = "https://pki.lspeed.org/";' \
    EASYRSA_EXT_DIR=/etc/pki/x509-types EASYRSA=/usr/share/easy-rsa faketime -f \
    "`date --rfc-3339=seconds -d \`date +%F\` | sed s/\+.*//`" /bin/sh \
    /usr/share/easy-rsa/easyrsa --batch --pki-dir=/etc/pki/${CA_NAME} --use-algo=ec \
    --curve=secp384r1 --digest=sha384 --days=$CA_VALIDITY sign-req ocsp "lspeed ECC \
    OCSP Responder - R1"

Next comes the OCSP response pre-generation. Here we build a temporary sqlite3 database containing the all the important attributes of every issued certificate, and then pass that to CFSSL to generate the OCSP responses:

# Root CA - OCSP Response Pre-generation
CA_NAME=r1
CA_NAME_UPPER=`echo $CA_NAME | tr '[:lower:]' '[:upper:]'`
rm -r /etc/pki/${CA_NAME}/ocsp 2>/dev/null
mkdir -p /etc/pki/${CA_NAME}/ocsp
mkdir -p /etc/pki/ocsp
echo '{"driver":"sqlite3","data_source":"/etc/pki/'${CA_NAME}'/ocsp/certs.db"}' > \
    /etc/pki/${CA_NAME}/ocsp/ocsp.json
sqlite3 /etc/pki/${CA_NAME}/ocsp/certs.db <<EOF
  CREATE TABLE certificates (
    serial_number            blob NOT NULL,
    authority_key_identifier blob NOT NULL,
    ca_label                 blob,
    status                   blob NOT NULL,
    reason                   int,
    expiry                   timestamp,
    revoked_at               timestamp,
    pem                      blob NOT NULL,
    PRIMARY KEY(serial_number, authority_key_identifier)
  );
  CREATE TABLE ocsp_responses (
    serial_number            blob NOT NULL,
    authority_key_identifier blob NOT NULL,
    body                     blob NOT NULL,
    expiry                   timestamp,
    PRIMARY KEY(serial_number, authority_key_identifier),
    FOREIGN KEY(serial_number, authority_key_identifier) REFERENCES \
        certificates(serial_number, authority_key_identifier)
  );
EOF
OIFS="$IFS"
IFS=$'\n'
for cert in `ls -1 /etc/pki/${CA_NAME}/issued`; do
  serial_number=`echo "ibase=16;obase=A;\`openssl x509 -serial -noout -in \
      /etc/pki/${CA_NAME}/issued/${cert} | sed s/.*=//\`" | bc`
  authority_key_identifier=`openssl x509 -text -in /etc/pki/${CA_NAME}/issued/${cert} \
      | grep keyid: | sed s/.*keyid// | sed s/://g | tr [:upper:] [:lower:]`
  status="good"
  reason="0"
  expiry=`date --rfc-3339=seconds -d "\`openssl x509 -enddate -noout -in \
      /etc/pki/${CA_NAME}/issued/${cert} | sed s/.*=//\`"`
  revoked_at="0001-01-01 00:00:00+00:00"
  pem=`openssl x509 -in /etc/pki/${CA_NAME}/issued/${cert}`
  sqlite3 /etc/pki/${CA_NAME}/ocsp/certs.db "INSERT INTO \
      certificates(serial_number,authority_key_identifier,ca_label,status,reason,\
      expiry,revoked_at,pem) VALUES('$serial_number','$authority_key_identifier','',\
      '$status','$reason','$expiry','$revoked_at','$pem')"
done
IFS="$OIFS"
openssl pkey -in "/etc/pki/${CA_NAME}/private/lspeed ECC OCSP Responder - \
    ${CA_NAME_UPPER}.key" -out /etc/pki/${CA_NAME}/ocsp/ocsp.key
cp "/etc/pki/${CA_NAME}/issued/lspeed ECC OCSP Responder - ${CA_NAME_UPPER}.crt" \
    /etc/pki/${CA_NAME}/ocsp/ocsp.crt
TZ=UTC faketime `date +%Y-%m-01` cfssl ocsprefresh -db-config \
    /etc/pki/${CA_NAME}/ocsp/ocsp.json -responder /etc/pki/${CA_NAME}/ocsp/ocsp.crt \
    -responder-key /etc/pki/${CA_NAME}/ocsp/ocsp.key -ca /etc/pki/${CA_NAME}/ca.crt \
    -interval 1800h
cfssl ocspdump -db-config /etc/pki/${CA_NAME}/ocsp/ocsp.json > \
    /etc/pki/ocsp/ocsp_${CA_NAME}.dump
chmod 600 /etc/pki/ocsp/ocsp_${CA_NAME}.dump
rm -r /etc/pki/${CA_NAME}/ocsp

You might notice that I don’t actually populate the sqlite3 database with details of revoked certificates, so the OCSP responses are not pre-generated for them. That’s simply because I haven’t had to implement that yet (because I don’t have any revoked certificates), and because I’m lazy. But it shouldn’t be hard to extend the logic to encompass revoked certificates too. Having said that, it should already be the case that the OCSP responder does not provide positive “everything is fine” responses for revoked certificates. I would expect it to respond with some kind of “this certificate does not exist” message instead (although I’ve not tested that).

If/when I get round to implementing that, I’ll update this post. Or alternatively, feel free to do it for me and let me know what needs to be added šŸ™‚

Issuing CAs

The process is very similar for the issuing CAs. Here’s the setup for the first issuing CA (named “S1”)… initialising the CA first, then pre-generating CRLs, issuing an OCSP certificate, and pre-generating OCSP responses:

# Issuing CA - S1 - Setup
CA_NAME=r1
SUBCA_NAME=s1
mkdir -p /etc/pki/${CA_NAME}
EASYRSA=/usr/share/easy-rsa /bin/sh /usr/share/easy-rsa/easyrsa \
    --pki-dir=/etc/pki/${SUBCA_NAME} init-pki
EASYRSA=/usr/share/easy-rsa /bin/sh /usr/share/easy-rsa/easyrsa --batch \
    --pki-dir=/etc/pki/${SUBCA_NAME} --use-algo=ec --curve=secp384r1 --dn-mode=org \
    --req-c=GB --req-st= --req-city= --req-ou= --req-email= --req-org=lspeed \
    --req-cn="lspeed ECC Issuing CA - S1" build-ca subca
EASYRSA=/usr/share/easy-rsa /bin/sh /usr/share/easy-rsa/easyrsa \
    --pki-dir=/etc/pki/${CA_NAME} import-req /etc/pki/${SUBCA_NAME}/reqs/ca.req \
    "lspeed ECC Issuing CA - S1"
# Calculate the number of days between today and 31/12/37
TZ=UTC CA_VALIDITY=`echo "( \`date -d 2037-12-31 +%s\` - \`date -d \\\`date +%F \\\` \
    +%s\` ) / (24*3600)" | bc -l | sed 's/\..*//'`
# Use faketime to get nice round timestamps (ie 00:00:00) in start and end dates
CA_NAME=${CA_NAME} TZ=UTC EASYRSA_EXTRA_EXTS=$'[ cpAll ]\npolicyIdentifier = \
    2.5.29.32.0\nCPS = "https://pki.lspeed.org/";' EASYRSA=/usr/share/easy-rsa \
    EASYRSA_EXT_DIR=/etc/pki/x509-types faketime -f "`date --rfc-3339=seconds -d \
    \`date +%F\` | sed s/\+.*//`" /bin/sh /usr/share/easy-rsa/easyrsa \
    --pki-dir=/etc/pki/${CA_NAME} --digest=sha384 --days=$CA_VALIDITY sign-req ca \
    "lspeed ECC Issuing CA - S1"
cp "/etc/pki/${CA_NAME}/issued/lspeed ECC Issuing CA - S1.crt" \
    /etc/pki/${SUBCA_NAME}/ca.crt
openssl x509 -in /etc/pki/${SUBCA_NAME}/ca.crt -outform DER > \
    /etc/pki/pub/lspeed_${SUBCA_NAME}.crt
# Issuing CA - S1 - CRL
CA_NAME=s1
# Temporarily decrypt CA private key in order to issue numerous CRLs
cp /etc/pki/${CA_NAME}/private/ca.key /etc/pki/${CA_NAME}/private/ca.key.orig
EASYRSA_PKI=/etc/pki/${CA_NAME} /bin/sh /usr/share/easy-rsa/easyrsa set-ec-pass ca \
    nopass
# Clear existing pre-generated CRLs
rm -r /etc/pki/${CA_NAME}/crls
mkdir /etc/pki/${CA_NAME}/crls
# Create new pre-generated CRLs
crldate=`date +%Y-%m-01`
crlend="2038-02-01"
until [ "$crldate" == "$crlend" ]; do
  TZ=UTC faketime $crldate date -I | sed s/-//g > /etc/pki/${CA_NAME}/crlnumber
  TZ=UTC EASYRSA_CRL_DAYS=75 EASYRSA=/usr/share/easy-rsa \
      EASYRSA_SSL_CONF=/etc/pki/openssl-1.0.cnf faketime $crldate /bin/sh \
      /usr/share/easy-rsa/easyrsa --pki-dir=/etc/pki/${CA_NAME} --use-algo=ec \
      --curve=secp384r1 --digest=sha384 gen-crl
  openssl crl -in /etc/pki/${CA_NAME}/crl.pem -outform DER > \
      /etc/pki/${CA_NAME}/crls/$crldate.crl
  crldate=`date --date "$crldate 1 month" +%Y-%m-%d`
done
chmod -R go-rwx /etc/pki/${CA_NAME}/crls
# Create current CRL
date -I | sed s/-//g &gt; /etc/pki/${CA_NAME}/crlnumber
EASYRSA_CRL_DAYS=75 EASYRSA=/usr/share/easy-rsa \
    EASYRSA_SSL_CONF=/etc/pki/openssl-1.0.cnf /bin/sh /usr/share/easy-rsa/easyrsa \
    --pki-dir=/etc/pki/${CA_NAME} --use-algo=ec --curve=secp384r1 --digest=sha384 \
    gen-crl
openssl crl -in /etc/pki/${CA_NAME}/crl.pem -outform DER > \
    /etc/pki/pub/lspeed_${CA_NAME}.crl
# Re-encrypt CA private key
mv /etc/pki/${CA_NAME}/private/ca.key.orig /etc/pki/${CA_NAME}/private/ca.key
# Issuing CA - S1 - Issue OCSP Cert
CA_NAME=s1
# OCSP signing certificate
EASYRSA=/usr/share/easy-rsa /bin/sh /usr/share/easy-rsa/easyrsa --batch \
    --pki-dir=/etc/pki/${CA_NAME} --use-algo=ec --curve=secp384r1 --dn-mode=org \
    --req-c=GB --req-st= --req-city= --req-ou= --req-email= --req-org=lspeed \
    --req-cn="lspeed ECC OCSP Responder - S1" gen-req "lspeed ECC OCSP Responder - \
    S1" nopass
# Calculate the number of days between today and 31/12/37
TZ=UTC CA_VALIDITY=`echo "( \`date -d 2037-12-31 +%s\` - \`date -d \\\`date +%F \\\` \
    +%s\` ) / (24*3600)" | bc -l | sed 's/\..*//'`
# Use faketime to get nice round timestamps (ie 00:00:00) in start and end dates
CA_NAME=${CA_NAME} TZ=UTC EASYRSA_EXTRA_EXTS=$'[ cpStandard ]\npolicyIdentifier = \
    1.3.6.1.4.1.51546.1.1.1.1\nCPS = "https://pki.lspeed.org/";' \
    EASYRSA_EXT_DIR=/etc/pki/x509-types EASYRSA=/usr/share/easy-rsa faketime -f \
    "`date --rfc-3339=seconds -d \`date +%F\` | sed s/\+.*//`" /bin/sh \
    /usr/share/easy-rsa/easyrsa --batch --pki-dir=/etc/pki/${CA_NAME} --use-algo=ec \
    --curve=secp384r1 --digest=sha384 --days=$CA_VALIDITY sign-req ocsp "lspeed ECC \
    OCSP Responder - S1"
# Issuing CA - S1 - OCSP Response Pre-generation
CA_NAME=s1
CA_NAME_UPPER=`echo $CA_NAME | tr '[:lower:]' '[:upper:]'`
rm -r /etc/pki/${CA_NAME}/ocsp 2>/dev/null
mkdir -p /etc/pki/${CA_NAME}/ocsp
mkdir -p /etc/pki/ocsp
echo '{"driver":"sqlite3","data_source":"/etc/pki/'${CA_NAME}'/ocsp/certs.db"}' > \
    /etc/pki/${CA_NAME}/ocsp/ocsp.json
sqlite3 /etc/pki/${CA_NAME}/ocsp/certs.db <<EOF
  CREATE TABLE certificates (
    serial_number            blob NOT NULL,
    authority_key_identifier blob NOT NULL,
    ca_label                 blob,
    status                   blob NOT NULL,
    reason                   int,
    expiry                   timestamp,
    revoked_at               timestamp,
    pem                      blob NOT NULL,
    PRIMARY KEY(serial_number, authority_key_identifier)
  );
  CREATE TABLE ocsp_responses (
    serial_number            blob NOT NULL,
    authority_key_identifier blob NOT NULL,
    body                     blob NOT NULL,
    expiry                   timestamp,
    PRIMARY KEY(serial_number, authority_key_identifier),
    FOREIGN KEY(serial_number, authority_key_identifier) REFERENCES \
        certificates(serial_number, authority_key_identifier)
  );
EOF
OIFS="$IFS"
IFS=$'\n'
for cert in `ls -1 /etc/pki/${CA_NAME}/issued`; do
  serial_number=`echo "ibase=16;obase=A;\`openssl x509 -serial -noout -in \
      /etc/pki/${CA_NAME}/issued/${cert} | sed s/.*=//\`" | bc`
  authority_key_identifier=`openssl x509 -text -in /etc/pki/${CA_NAME}/issued/${cert} \
      | grep keyid: | sed s/.*keyid// | sed s/://g | tr [:upper:] [:lower:]`
  status="good"
  reason="0"
  expiry=`date --rfc-3339=seconds -d "\`openssl x509 -enddate -noout -in \
      /etc/pki/${CA_NAME}/issued/${cert} | sed s/.*=//\`"`
  revoked_at="0001-01-01 00:00:00+00:00"
  pem=`openssl x509 -in /etc/pki/${CA_NAME}/issued/${cert}`
  sqlite3 /etc/pki/${CA_NAME}/ocsp/certs.db "INSERT INTO \
      certificates(serial_number,authority_key_identifier,ca_label,status,reason,\
      expiry,revoked_at,pem) VALUES('$serial_number','$authority_key_identifier','',\
      '$status','$reason','$expiry','$revoked_at','$pem')"
done
IFS="$OIFS"
openssl pkey -in "/etc/pki/${CA_NAME}/private/lspeed ECC OCSP Responder - \
    ${CA_NAME_UPPER}.key" -out /etc/pki/${CA_NAME}/ocsp/ocsp.key
cp "/etc/pki/${CA_NAME}/issued/lspeed ECC OCSP Responder - ${CA_NAME_UPPER}.crt" \
    /etc/pki/${CA_NAME}/ocsp/ocsp.crt
TZ=UTC faketime `date +%Y-%m-01` cfssl ocsprefresh -db-config \
    /etc/pki/${CA_NAME}/ocsp/ocsp.json -responder /etc/pki/${CA_NAME}/ocsp/ocsp.crt \
    -responder-key /etc/pki/${CA_NAME}/ocsp/ocsp.key -ca /etc/pki/${CA_NAME}/ca.crt \
    -interval 1800h
cfssl ocspdump -db-config /etc/pki/${CA_NAME}/ocsp/ocsp.json > \
    /etc/pki/ocsp/ocsp_${CA_NAME}.dump
chmod 600 /etc/pki/ocsp/ocsp_${CA_NAME}.dump
rm -r /etc/pki/${CA_NAME}/ocsp

This process is easily repeatable for other issuing CAs.

Publishing CDP and AIA locations

During the setup of the CAs, we have been placing DER-encoded CA certificates and CRLs into the /etc/pki/pub/ directory. This needs to be made available over HTTP so that clients can access the CDP and AIA locations. In my case this is not made available over the internet, as I don’t use any certificates issued by this PKI for internet-facing services (that’s what I use Let’s Encrypt for). I also serve the CPS document from the same web server.

I opted to use NGINX to serve these static files, although any web server should work. I’ll not bother covering the web server configuration in detail here as it is trivial and off-topic. Except I will mention two things…

  • I configured the CRL files to expire in 1 day (ie from an HTTP content perspective), so that they won’t be cached for too long
  • I changed the MIME type used for serving certificates (.der, .pem, and .crt extensions) to be “application/x-x509-ca-cert”

OCSP responder

I have opted to use CFSSL’s OCSP responder. One instance is required for each CA. I am using the following init script to run the OCSP responder daemons (note that my Gentoo systems are using openrc rather than systemd):

#!/sbin/runscript

depend() {
    after net
}

start() {
    ebegin "Starting ocspserve"
    start-stop-daemon --start --background --exec /usr/bin/cfssl -- ocspserve -port \
        8000 -responses /etc/pki/ocsp/ocsp_r1.dump
    start-stop-daemon --start --background --exec /usr/bin/cfssl -- ocspserve -port \
        8001 -responses /etc/pki/ocsp/ocsp_s1.dump
    eend $?
}

stop() {
    start-stop-daemon --stop --name cfssl
    eend $?
}

Once the OCSP responder daemons are up and running, this additional config can be added to NGINX to pass OCSP requests through to the appropriate responder. Iā€™m sure something similar can be achieved if you are using a different web server:

location /ocsp/r1 {
        proxy_pass http://127.0.0.1:8000;
}
location /ocsp/s1 {
        proxy_pass http://127.0.0.1:8001;
}

Issuing end-entity certificates

With all the initial setup complete, issuing new end-entity certificates is fairly straightforward. Here’s an example for a web server certificate (to allow HTTPS access to the CPS):

# Issuing CA - S1 - PKI Web Server Certificate
CA_NAME=s1
EASYRSA=/usr/share/easy-rsa /bin/sh /usr/share/easy-rsa/easyrsa --batch \
    --pki-dir=/etc/pki/${CA_NAME} --use-algo=ec --curve=secp384r1 --dn-mode=org \
    --req-c=GB --req-st= --req-city= --req-ou= --req-email= --req-org=lspeed \
    --req-cn="PKI Web Server" gen-req "PKI Web Server"
CA_NAME=${CA_NAME} EASYRSA_EXTRA_EXTS=$'subjectAltName = DNS:pki.lspeed.org\n[ \
    cpStandard ]\npolicyIdentifier = 1.3.6.1.4.1.51546.1.1.1.1\nCPS = \
    "https://pki.lspeed.org/";' EASYRSA_EXT_DIR=/etc/pki/x509-types \
    EASYRSA=/usr/share/easy-rsa faketime -f "`date --rfc-3339=seconds -d \`date +%F\` \
    | sed s/\+.*//`" /bin/sh /usr/share/easy-rsa/easyrsa --batch \
    --pki-dir=/etc/pki/${CA_NAME} --use-algo=ec --curve=secp384r1 --digest=sha384 \
    --days=3650 sign-req server "PKI Web Server"

Note that at this point it is also necessary to regenerate the OCSP responses for the issuing CA that you used to issue the new certificate (otherwise no OCSP response would be available for that new certificate). Just re-run the same commands as were used to generate the initial set of OCSP responses for that same CA (shown above).

Certificate revocation

A certificate can be revoked as follows (I’m revoking the same end-entity certificate that was just issued above for this example):

CA_NAME=s1
EASYRSA=/usr/share/easy-rsa /bin/sh /usr/share/easy-rsa/easyrsa \
    --pki-dir=/etc/pki/${CA_NAME} revoke "PKI Web Server"

After revoking a certificate, it is necessary to regenerate CRLs and OCSP responses for the certificate’s issuing CA. Again, that’s just a case of re-running the same commands that were used to generate the initial CRLs and OCSP responses in the first place.

Automation

The CRLs need to be rotated once per month, and fresh OCSP responses need to be generated for each CA too. CRL rotation is simple… it just involves copying the new file into place at the right time:

#!/bin/sh
crldate=`date +%Y-%m-01`
crlnumber=`date -I | sed s/-//g`
for CA_NAME in `ls /etc/pki/*/ca.crt | sed s,/etc/pki/,, | sed s,/ca.crt,,`; do
  cp /etc/pki/${CA_NAME}/crls/${crldate}.crl /etc/pki/pub/lspeed_${CA_NAME}.crl
  openssl crl -inform DER -in /etc/pki/${CA_NAME}/crls/${crldate}.crl -outform PEM > \
      /etc/pki/${CA_NAME}/crl.pem
done

OCSP response regeneration is a case of re-running the same commands as shown above for generating the initial responses.

Both these things can be run from cron on a monthly basis.

High Availability

Although the CAs themselves don’t need to be highly-available (at least in my small environment), the CDP and OCSP responder are more important, and would benefit from increased resiliency.

This is easily achieved by replicating the contents of the /etc/pki/pub/ and /etc/pki/ocsp/ directories to some additional web servers, and then fronting them with a load-balancer of some kind (I have done this using HAproxy).