neenv

A root certificate limited to your own names

The mechanism

Your private certificate authority has a root that every machine and every device trusts. That is what makes your internal https names work without warnings.

Without a limit, the root vouches for any name. Your phone trusts it, so a certificate it signs for yourbank.com is accepted too. If the root's key, or any intermediate under it, is ever stolen or misused, every device that trusts the root can be shown a convincing fake of any website in the world.

With a limit, the root carries a Name Constraints extension: "I vouch only for names under home.arpa and for private addresses." A browser that trusts this root still opens your internal names with no warning. It refuses a certificate the same root signed for any other name.

Think of a notary whose stamp reads "valid for documents of this building only". A forged lease for another building fails, even though the stamp itself is real.

Two conditions apply:

The constraint

name-constraints.txt holds one OpenSSL nameConstraints value:

critical, permitted;DNS:home.arpa, permitted;IP:10.0.0.0/255.0.0.0, permitted;IP:172.16.0.0/255.240.0.0,
          permitted;IP:192.168.0.0/255.255.0.0, permitted;IP:127.0.0.0/255.0.0.0

Before choosing, survey every certificate your CA has issued: each name and address must fall inside the limit.

Moving to it without re-issuing a key

You already have a root, intermediates under it, and certificates on every machine. You do not re-issue them.

1. Mint the new root. Keep its key off ordinary disk: make it in a RAM directory, put it in whatever holds your secrets (a password manager, a vault, an offline USB key), then delete the RAM copy.

d=$(mktemp -d -p /dev/shm); chmod 700 "$d"
printf '[r]\nbasicConstraints=critical,CA:TRUE,pathlen:1\nkeyUsage=critical,keyCertSign,cRLSign\nsubjectKeyIdentifier=hash\nnameConstraints=%s\n' \
  "$(grep -v '^#' name-constraints.txt | tr -d '\n ')" > "$d/root.ext"
openssl ecparam -name prime256v1 -genkey -noout -out "$d/root.key"
openssl req -x509 -new -key "$d/root.key" -subj "/O=Example/CN=Example Root (our names)" -days 7300 \
  -config "$d/root.ext" -extensions r -out new-root.crt
# store $d/root.key where you keep secrets, then: rm -rf "$d"

pathlen must allow every CA level below the root: pathlen:1 permits one intermediate between root and leaf.

2. Cross-sign each intermediate. The new root signs the intermediate's existing certificate again: same public key, same subject, new issuer. Only public data is used; the intermediate's key is not touched.

printf '[c]\nbasicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign\nsubjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid\n' > ca.ext
openssl x509 -in old-intermediate.crt -CA new-root.crt -CAkey "$d/root.key" -set_serial 0x$(openssl rand -hex 16) \
  -days 3650 -extfile ca.ext -extensions c -out cross.crt
for f in old-intermediate.crt cross.crt; do openssl x509 -in $f -noout -subject -ext subjectKeyIdentifier; done   # must match

Every certificate already issued now chains to the new root as well as the old one.

3. Trust both roots on every machine and every device.

4. Present both chains, and keep both roots trusted by every OpenSSL-family client. No single order serves an OpenSSL-family client that trusts only one root (the drill measures it):

Server sends Client trusts the old root only the new root only both
old-issued, then cross opens refused opens
cross, then old-issued refused opens opens

Browsers and Go try every candidate chain, so either order works for them. Send the old-issued certificate first while any machine still trusts only the old root, and install the new root next to the old one everywhere before any client is moved to the new root alone.

5. Retire the old root. Servers present the cross-certificate alone. Every machine's store, and every device, holds the new root alone. Only from here does the limit protect anything.

6. Prove the refusal on a real device with drill-device.sh.

What the clients do

Client Honours the limit How we know
OpenSSL 3.5 command line yes; takes the first chain that matches, no backtracking drill
Go 1.24 yes; tries every candidate chain; never reads the CN drill
Chromium yes drill (refused under the limited root, opened under an otherwise identical root without it)
Firefox (NSS) yes drill (same twin check)
nginx 1.28 verifying client certificates yes; a person certificate with no subjectAltName verifies drill
Java 21 no: ignores a trust anchor's constraint and accepts every name drill
A browser on macOS (which one was not recorded) yes seen by hand: refused the foreign page of drill-device.sh
macOS, iOS opens permitted names with no warning seen by hand; refusal on iOS not yet tried

Libraries built on OpenSSL (Node's bundled copy, Python and curl where they use OpenSSL) behave as OpenSSL does. curl built on GnuTLS, Schannel or Secure Transport, and Python on Windows or macOS, use other verifiers: test them.

What happened on our own move (seen by hand, not drilled):

The drills

Both drills make a throwaway root in a private temporary directory and read no real key. A refusal counts only when it is for the name; any other error fails the drill.

Prerequisites: OpenSSL 3.0 or later (macOS ships LibreSSL, which lacks -no-CAstore), bash, curl and python3. drill-clients.sh runs Go, Java and nginx in docker (images golang:1.24, eclipse-temurin:21-jdk, nginx:1.28, debian:trixie, pulled on first use), and the browsers through playwright-core. drill-device.sh serve needs Linux (ip, setsid, GNU timeout).

./drill-clients.sh                     # OpenSSL, the cross-signing move and chain order always; Go, Java, nginx,
                                       # Chromium and Firefox when their tools are present, otherwise SKIP
ARM=noconstraint ./drill-clients.sh    # same root without the limit: every refused(constraint) case must FAIL

./drill-device.sh check                # proves the two device pages on this machine, then deletes everything
ARM=noconstraint ./drill-device.sh check   # must FAIL
./drill-device.sh serve 192.168.1.10   # serves them on your network for a phone or laptop; prints the steps
./drill-device.sh stop                 # stops the pages and deletes the directory

drill-device.sh serve deletes the root key as soon as the two pages are signed; the certificates stay until stop. It uses sslip.io, a public DNS service, to give the device a foreign name that points at your machine. A resolver with DNS-rebind protection (dnsmasq, OpenWrt, Pi-hole) drops such answers; then the device fails on DNS, which proves nothing. The refusal must be a certificate error. Afterwards, remove the drill root from the device.