Running one's own root Certificate Authority in 2023


Update 2023-09-17: Well, hello Hacker News! (comments) I also added nameConstraints to the cacert.sh to make this even better than before. Yay, constructive feedback!

Problem statement

Anyone wanting their own X509 cert these days has free-beer alternatives like ZeroSSL or Let’s Encrypt.

But, what if it’s just for internal services, some of them even cut off from the ‘Net? And more importantly, what if you don’t wish to be bothered with juggling the renewals every 3 months, or want that sweet wildcard x509 cert with minimum hassle?

Well then… how about the age-old solution of rolling out your own root CA? Ideally one that will be accepted without issue by major browsers, including the ones on iOS.

Let’s dig in.

Background / history lesson

Way back when I was just a little bitty boy… we used to do this whole shindig with sign.sh -- Sign a SSL Certificate Request (CSR) script and a modicum of openssl genrsa, openssl req -new -x509, openssl req -new elbow grease.

If the copyright line1 is to be believed, that thing predates the height of the Y2K mania.

And there isn’t that much wrong with it even now, save for a questionable choice of hash functions.

Or so I thought, as I was running that thing with default_days = 3650 for the past many many years. And while that continues to work fine in Firefox on Linux to this day2, you’re bound to hit a brick wall when trying to use server certificates generated that way with Apple devices3.

Turns out4, Apple really doesn’t want you to use server certs that are valid for longer than 398 days5, along with a plethora of other restrictions. tldr: 2048bit+, SHA2 digest, CN ignored, altnames is king, keyusage=serverAuth.

And with that, here’s what works as of the time of writing… in Firefox and on recent MacOS/iOS.

Solution

These “make your own x509 root cert” guides are a dime a dozen. And if you’re lazy, go use mkcert instead. It might even work out of the box6.

If you’re like me, and simply want to take the road less traveled by (because understanding all this stuff makes all the difference), here’s the requirements:

  1. a x509 cert for the certificate authority
  2. a x509 cert for every internal service, or maybe just a singleton *.int.wejn.org7
  3. a way to serve the CA certificate somewhere

The last one is out of scope, but basically you need a static website that spits out the PEM-encoded certificate with application/x-x509-ca-cert mime type. More on that some other time.

Viewing from the top, I want something like:

# _gen_all.sh
## generate CA key+cert
./cacert.sh
## generate host key+cert for a single host
./hostcert.sh snowflake.int.wejn.org
## generate wildcard host key+cert with two alt names
./hostcert.sh int.wejn.org '*.int.wejn.org'

that in the end produces ca.crt, ca.key, snowflake.int.wejn.org.{crt,key}, int.wejn.org.{crt,key} in working order8.

Generating the CA certificate

The certificate is a two-parter:

  1. an RSA key
  2. x509 certificate with proper extensions

Here’s what worked for me (cacert.sh):

#!/bin/bash

if [ -f "ca.cnf" ]; then
        echo "CA already exists."
        exit 1
fi

umask 066

# Generate a CA password, because openssl (reasonably) wants to protect
# the key material... and dump it to `ca.pass`.
export CAPASS=$(xkcdpass -n 64)

if [ -z "$CAPASS" ]; then
        echo "Error: password empty; no xkcdpass?"
        exit 1
fi

echo "$CAPASS" > "ca.pass"

# Generate the 4096 bit RSA key for the CA
openssl genrsa -aes256 -passout env:CAPASS  -out "ca.key" 4096

# Strip the encryption off it; IOW, now they're are two things worth
# protecting -- the `ca.pass` and `ca.key.unsecure`.
openssl rsa -in "ca.key" -passin env:CAPASS -out "ca.key.unsecure"

# At this point, you can decide whether to memorize `ca.pass` and
# delete it along with `ca.key.unsecure`, or protect `ca.key.unsecure`
# with your life, and maybe forget all about `ca.key` and `ca.pass`.
#
# (I'm sure you would have no trouble rewriting this to do away with
# the `ca.pass` and `xkcdpass` dependency altogether)

# Configure the CSR with necessary fields
cat > "ca.cnf" <<'EOF'
[ req ]
x509_extensions = v3_req
distinguished_name = req_distinguished_name
prompt = no

[ v3_req ]
# This is the money shot -- we are the cert authority (CA:TRUE),
# and there are no other CAs below us in the chain (pathlen:0),
# and the constraint is non-negotiable (critical)
basicConstraints = critical, CA:TRUE, pathlen:0

## This is optional but maybe needed for some platforms
#extendedKeyUsage = serverAuth, clientAuth, emailProtection

# Let's do the nameConstraints thing, because it works on iOS16
# and recent Firefox. So constrain all leaf certs to `int.wejn.org`
# and its subdomains, but not `critical` in case it's not supported
# by some device.
# h/t https://news.ycombinator.com/item?id=37538084
keyUsage = critical, keyCertSign, cRLSign
nameConstraints = permitted;DNS:int.wejn.org

[ req_distinguished_name ]
C = CH
L = Zurich
O = int.wejn.org CA
CN = ca.int.wejn.org
emailAddress = ca@int.wejn.org
EOF

# Do the deed -- generate the `ca.crt`, with 10 year (3650 days) validity
openssl req -new -x509 -days 3650 -sha512 -passin env:CAPASS -config ca.cnf \
        -key ca.key -out ca.crt -text

Generating the host certificates

I’m going to assume we stick with the ca.pass and ca.key above, just to make the sign.sh work as originally written9.

To generate a host certificate signed by the CA, we need:

  1. an RSA key
  2. a certificate signing request (CSR)
  3. sign the CSR with the CA key

Here’s my take on hostcert.sh:

#!/bin/bash

# Read the CA password, used by `sign.sh` later
export CAPASS=$(cat ca.pass)

if [ -f "$1.cnf" ]; then
        echo "Host: $1 already exists."
        exit 1
fi

if [ -z "$1" ]; then
        echo "Error: No hostname given"
        exit 1
fi

umask 066

# Generate the certificate's password, and dump it.
export PASS=$(xkcdpass -n 64)

if [ -z "$PASS" ]; then
        echo "Error: password empty; no xkcdpass?"
        exit 1
fi

echo "$PASS" > "$1.pass"

# Figure out what the hostname / altnames are, and confirm.
echo "$1" | fgrep -q "."
if [ $? -eq 0 ]; then
        CN="$1"
        ALTNAMES="$@"
else
        CN="$1.int.wejn.org"
        ALTNAMES="$1.int.wejn.org"
fi
echo "CN: $CN"
echo "ANs: $ALTNAMES"
echo "Enter to confirm."
read A

# Generate the RSA key, unlock it into the "unsecure" file
openssl genrsa -aes256 -passout env:PASS  -out "$1.key" ${SSL_KEY_SIZE-4096}
openssl rsa -in "$1.key" -passin env:PASS -out "$1.key.unsecure"

# Construct the CSR data
cat > "$1.cnf" <<EOF
[ req ]
req_extensions = v3_req
distinguished_name = req_distinguished_name
prompt = no

[ v3_req ]
# We are NOT a CA, this is for server auth, and these are the altnames
basicConstraints = critical,CA:FALSE
# We are, however, a certificate for server authentication (important!)
extendedKeyUsage=serverAuth
subjectAltName = @alt_names

[alt_names]
EOF

I=1
for AN in $ALTNAMES; do
        echo "DNS.$I = $AN" >> "$1.cnf"
        I=$[$I + 1]
done

cat >> "$1.cnf" <<EOF

[ req_distinguished_name ]
C = CH
L = Zurich
O = int.wejn.org host cert
CN = $CN
EOF

# Create the CSR
openssl req -new -key "$1.key" -sha512 -passin env:PASS -config "$1.cnf" \
        -out "$1.csr"

# Sign the CSR by the CA, resulting in `$1.crt`; needs env;CAPASS
./sign.sh "$1.csr"

# Optional: put both cert and key into a single `$1.pem` file
#ruby -pe 'next unless /BEGIN/../END/' "$1.crt" "$1.key.unsecure" > "$1.pem"

And, of course, the current sign.sh, with a few comments:

#!/bin/sh
##
##  sign.sh -- Sign a SSL Certificate Request (CSR)
##  Copyright (c) 1998-2001 Ralf S. Engelschall, All Rights Reserved.
##

#   argument line handling
CSR=$1
if [ $# -ne 1 ]; then
    echo "Usage: sign.sign <whatever>.csr"; exit 1
fi
if [ ! -f $CSR ]; then
    echo "CSR not found: $CSR"; exit 1
fi
case $CSR in
   *.csr ) CERT="`echo $CSR | sed -e 's/\.csr/.crt/'`" ;;
       * ) CERT="$CSR.crt" ;;
esac

#   make sure environment exists
if [ ! -d ca.db.certs ]; then
    mkdir ca.db.certs
fi
if [ ! -f ca.db.serial ]; then
    echo '01' >ca.db.serial
fi
if [ ! -f ca.db.index ]; then
    cp /dev/null ca.db.index
fi

#   create an own SSLeay config
cat >ca.config <<EOT
[ ca ]
default_ca              = CA_own
[ CA_own ]
dir                     = .
certs                   = \$dir
new_certs_dir           = \$dir/ca.db.certs
database                = \$dir/ca.db.index
serial                  = \$dir/ca.db.serial
RANDFILE                = \$dir/ca.db.rand
certificate             = \$dir/ca.crt
private_key             = \$dir/ca.key
# all hail our fruity overlords:
default_days            = 365
default_crl_days        = 30
# need sane message digest, too:
default_md              = sha512
preserve                = no
policy                  = policy_anything
copy_extensions         = copy
x509_extensions         = v3
[ v3 ]
basicConstraints = critical, CA:FALSE

[ policy_anything ]
countryName             = optional
stateOrProvinceName     = optional
localityName            = optional
organizationName        = optional
organizationalUnitName  = optional
commonName              = supplied
emailAddress            = optional
EOT

#  sign the certificate
if [ "x$CAPASS" = "x" ]; then
	echo "No \$CAPASS present, will have to specify pass"
	PASSIN=""
else
	echo "Reading pass from \$CAPASS"
	PASSIN="-passin env:CAPASS"
fi

echo "CA signing: $CSR -> $CERT:"
openssl ca -batch -config ca.config $PASSIN -out $CERT -infiles $CSR
echo "CA verifying: $CERT <-> CA cert"
if [ -f ca-chain.pem ]; then
	openssl verify -CAfile ca-chain.pem $CERT
else
	openssl verify -CAfile ca.crt $CERT
fi

#  cleanup after SSLeay
rm -f ca.config
rm -f ca.db.serial.old
rm -f ca.db.index.old

#  die gracefully
exit 0

Running the toy example:

Moment of truth, young lad:

$ ls
cacert.sh  hostcert.sh  sign.sh

$ chmod a+x *.sh

$ ./cacert.sh
Generating RSA private key, 4096 bit long modulus (2 primes)
..++++
..............................++++
e is 65537 (0x010001)
writing RSA key

$ ./hostcert.sh snowflake.int.wejn.org
CN: snowflake.int.wejn.org
ANs: snowflake.int.wejn.org
Enter to confirm.

Generating RSA private key, 4096 bit long modulus (2 primes)
..............................++++
............++++
e is 65537 (0x010001)
writing RSA key
Reading pass from $CAPASS
CA signing: snowflake.int.wejn.org.csr -> snowflake.int.wejn.org.crt:
Using configuration from ca.config
Check that the request matches the signature
Signature ok
The Subject's Distinguished Name is as follows
countryName           :PRINTABLE:'CH'
localityName          :ASN.1 12:'Zurich'
organizationName      :ASN.1 12:'int.wejn.org host cert'
commonName            :ASN.1 12:'snowflake.int.wejn.org'
Certificate is to be certified until Sep 15 13:47:28 2024 GMT (365 days)

Write out database with 1 new entries
Data Base Updated
CA verifying: snowflake.int.wejn.org.crt <-> CA cert
snowflake.int.wejn.org.crt: OK

$ ./hostcert.sh int.wejn.org '*.int.wejn.org'
CN: int.wejn.org
ANs: int.wejn.org *.int.wejn.org
Enter to confirm.

Generating RSA private key, 4096 bit long modulus (2 primes)
.............................++++
.............................................++++
e is 65537 (0x010001)
writing RSA key
Reading pass from $CAPASS
CA signing: int.wejn.org.csr -> int.wejn.org.crt:
Using configuration from ca.config
Check that the request matches the signature
Signature ok
The Subject's Distinguished Name is as follows
countryName           :PRINTABLE:'CH'
localityName          :ASN.1 12:'Zurich'
organizationName      :ASN.1 12:'int.wejn.org host cert'
commonName            :ASN.1 12:'int.wejn.org'
Certificate is to be certified until Sep 15 13:48:05 2024 GMT (365 days)

Write out database with 1 new entries
Data Base Updated
CA verifying: int.wejn.org.crt <-> CA cert
int.wejn.org.crt: OK

Looks like it worked:

$ ls -w 80
cacert.sh             int.wejn.org.crt
ca.cnf                int.wejn.org.csr
ca.crt                int.wejn.org.key
ca.db.certs           int.wejn.org.key.unsecure
ca.db.index           int.wejn.org.pass
ca.db.index.attr      sign.sh
ca.db.index.attr.old  snowflake.int.wejn.org.cnf
ca.db.serial          snowflake.int.wejn.org.crt
ca.key                snowflake.int.wejn.org.csr
ca.key.unsecure       snowflake.int.wejn.org.key
ca.pass               snowflake.int.wejn.org.key.unsecure
hostcert.sh           snowflake.int.wejn.org.pass
int.wejn.org.cnf

$ openssl verify -CAfile ca.crt int.wejn.org.crt snowflake.int.wejn.org.crt
int.wejn.org.crt: OK
snowflake.int.wejn.org.crt: OK

$ egrep '(Public|bit|Alternative|DNS|v3.e|Sign|Vali|Not)' snow*.crt
        Signature Algorithm: sha512WithRSAEncryption
        Validity
            Not Before: Sep 16 13:47:28 2023 GMT
            Not After : Sep 15 13:47:28 2024 GMT
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                RSA Public-Key: (4096 bit)
        X509v3 extensions:
            X509v3 Subject Alternative Name:
                DNS:snowflake.int.wejn.org
    Signature Algorithm: sha512WithRSAEncryption

$ grep -A1 'Alternative' int.wejn.org.crt
            X509v3 Subject Alternative Name:
                DNS:int.wejn.org, DNS:*.int.wejn.org

Note: 4096 bit RSA with SHA512 (SHA2 family), with TLS Web Server Authentication key usage, correct Subject Alternative Name, and one year validity. ✅

Whether it actually works on the device I can’t show here. But it does on mine. Swearsies.

Obviously the ca.crt needs to be imported on every device as a root CA, and a guide explaining how to is different for each OS / browser. But having the static website outlined above goes a long way to simplify the import process10, especially on iOS. :)

Going rogue with the CA

Thanks to the addition of nameConstraints (which I discovered via Hacker News comments), it’s no longer possible to go rogue and issue certificates for other unrelated domains. That makes it significantly safer.

Check this out:

$ ./hostcert.sh int2.wejn.org '*.int2.wejn.org'
CN: int2.wejn.org
ANs: int2.wejn.org *.int2.wejn.org
Enter to confirm.

[...]

$ openssl verify -CAfile ca.crt int2.wejn.org.crt
C = CH, L = Zurich, O = int.wejn.org host cert, CN = int2.wejn.org
error 47 at 0 depth lookup: permitted subtree violation
error int2.wejn.org.crt: verification failed

Certificate issued, but the path validation fails. Here in openssl, but it would fail the same way on the end user devices. Neat!

Closing words

This was my brief foray into the wonderful world of running your own root certificate authority in 2023… one that gets accepted by both Apple devices and Linux browsers.

An obvious downside of this is having to guard a bunch of secrets11 and the need to rotate the host certificates yearly – because Apple says so.

But thanks to the nameConstraints it should be at least tad safer in case the CA keys were compromised.

  1. Copyright (c) 1998-1999 Ralf S. Engelschall, All Rights Reserved.

  2. And I mean… on the surface, why wouldn’t it?

  3. Spoiler alert: HT210176, Apple support: 102028.

  4. Which you can read as: I spent several hours banging my head against the wall staring at “Zis koneksn isn’t sikjure” screen… until I’ve cracked it.

  5. OTOH, root CA certs valid for 10 years are no problem. Praised be our fruity overlords.

  6. Although in light of Apple support: 102028 I’d be worried about that 2 years 3 months expiration used in the source.

  7. I’m a guide, not a cop.

  8. If you choose to do this, and then import the ca.crt into a root store on your device(s)… I hope you at least understand the risks and/or know how to mitigate them. Hint: low bar is an air-gapped host to generate this, and a safe way to long-term store the ca.key… otherwise enjoy your hard-earned dose of fresh mitm.

  9. Modulo slight modernization of the hash etc.

  10. The irony of the possibility of running that web publicly with an SSL certificate issued by letsencrypt.org is not lost on me. :-D

  11. Sort of like your life – or at least your digital life – depended on it.