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:
- a x509 cert for the certificate authority
- a x509 cert for every internal service, or maybe just a singleton
*.int.wejn.org
7 - 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:
- an RSA key
- 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:
- an RSA key
- a certificate signing request (CSR)
- 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.
-
Copyright (c) 1998-1999 Ralf S. Engelschall, All Rights Reserved. ↩
-
And I mean… on the surface, why wouldn’t it? ↩
-
Spoiler alert: HT210176, Apple support: 102028. ↩
-
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. ↩
-
OTOH, root CA certs valid for 10 years are no problem. Praised be our fruity overlords. ↩
-
Although in light of Apple support: 102028 I’d be worried about that 2 years 3 months expiration used in the source. ↩
-
I’m a guide, not a cop. ↩
-
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 theca.key
… otherwise enjoy your hard-earned dose of fresh mitm. ↩ -
Modulo slight modernization of the hash etc. ↩
-
The irony of the possibility of running that web publicly with an SSL certificate issued by letsencrypt.org is not lost on me. :-D ↩
-
Sort of like your life – or at least your digital life – depended on it. ↩