https://wejn.org/2023/09/running-ones-own-root-certificate-authority-in-2023/ [favicon-32] Wejn.org * Posts * Tags * About Written on 2023-09-16 Running one's own root Certificate Authority in 2023 internet security sysadm unix x509 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 line^1 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 day^2, you're bound to hit a brick wall when trying to use server certificates generated that way with Apple devices^3. Turns out^4, Apple really doesn't want you to use server certs that are valid for longer than 398 days^5, 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 box^6. 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.org^7 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 order^8. 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 #keyUsage = nonRepudiation, digitalSignature, keyEncipherment #extendedKeyUsage = serverAuth, clientAuth, emailProtection [ 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 written^9. 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" <> "$1.cnf" I=$[$I + 1] done cat >> "$1.cnf" < "$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 .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 < $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 process^10, especially on iOS. :) 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 secrets^11 and the need to rotate the host certificates yearly - because Apple says so. 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. - - - | (c) 2023 Michal Jirku. I speak only for myself.