Background
See this blog post for some background on what this is all about.
The recipe
0) Create PKI directory structure
On your CA machine (Linux):
mkdir -p ~/pki/{root,intermediate,certs,csr,tmp}
mkdir -p ~/pki/root/{certs,crl,newcerts,private}
mkdir -p ~/pki/intermediate/{certs,crl,csr,newcerts,private}
chmod 700 ~/pki/root/private ~/pki/intermediate/private
touch ~/pki/root/index.txt ~/pki/intermediate/index.txt
echo 1000 > ~/pki/root/serial
echo 2000 > ~/pki/intermediate/serial
1) Create the OpenSSL config template with profiles
Create ~/pki/openssl-template.cnf:
cat > ~/pki/openssl-template.cnf <<'EOF'
# ~/pki/openssl-template.cnf
# Template: replace __CA_DIR__ and __CN__. SANs are appended dynamically.
[ ca ]
default_ca = ca_default
[ ca_default ]
dir = __CA_DIR__
certs = $dir/certs
crl_dir = $dir/crl
new_certs_dir = $dir/newcerts
database = $dir/index.txt
serial = $dir/serial
private_key = $dir/private/ca.key.pem
certificate = $dir/certs/ca.cert.pem
default_md = sha256
policy = policy_loose
default_days = 825
copy_extensions = copy
[ policy_loose ]
countryName = optional
stateOrProvinceName = optional
localityName = optional
organizationName = optional
organizationalUnitName = optional
commonName = supplied
emailAddress = optional
[ req ]
prompt = no
default_md = sha256
distinguished_name = dn
req_extensions = req_ext
[ dn ]
C = US
ST = Connecticut
L = Wilton
O = Example Org
OU = PKI
CN = __CN__
##########
# CA cert profiles
##########
[ v3_root_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:true, pathlen:1
keyUsage = critical, keyCertSign, cRLSign
[ v3_intermediate_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:true, pathlen:0
keyUsage = critical, keyCertSign, cRLSign
##########
# Leaf cert profiles
##########
[ tls_server ]
basicConstraints = critical, CA:false
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
keyUsage = critical, digitalSignature
extendedKeyUsage = serverAuth
[ tls_client ]
basicConstraints = critical, CA:false
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
keyUsage = critical, digitalSignature
extendedKeyUsage = clientAuth
[ tls_server_client ]
basicConstraints = critical, CA:false
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
keyUsage = critical, digitalSignature
extendedKeyUsage = serverAuth, clientAuth
[ code_signing ]
basicConstraints = critical, CA:false
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
keyUsage = critical, digitalSignature
extendedKeyUsage = codeSigning
[ email_smime ]
basicConstraints = critical, CA:false
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
keyUsage = critical, digitalSignature, keyAgreement
extendedKeyUsage = emailProtection
[ ocsp_responder ]
basicConstraints = critical, CA:false
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
keyUsage = critical, digitalSignature
extendedKeyUsage = OCSPSigning
[ time_stamping ]
basicConstraints = critical, CA:false
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
keyUsage = critical, digitalSignature
extendedKeyUsage = timeStamping
[ req_ext ]
# The script will add "subjectAltName = @alt_names" here when SANs are present
[ alt_names ]
# dynamically generated entries go here
EOF
2) Create the leaf-config generator script
Create ~/pki/make-leaf-config.sh:
cat > ~/pki/make-leaf-config.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
make-leaf-config.sh --cn <CN> --out <path> --profile <profile> [--san <value> ...]
Profiles:
tls_server
tls_client
tls_server_client
code_signing
email_smime
ocsp_responder
time_stamping
USAGE
}
CN=""
OUT=""
PROFILE=""
SANS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--cn) CN="$2"; shift 2 ;;
--out) OUT="$2"; shift 2 ;;
--profile) PROFILE="$2"; shift 2 ;;
--san) SANS+=("$2"); shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
if [[ -z "$CN" || -z "$OUT" || -z "$PROFILE" ]]; then
echo "Missing required args" >&2
usage
exit 2
fi
# Guardrail: browser-facing TLS server certs basically require SANs
if [[ ("$PROFILE" == "tls_server" || "$PROFILE" == "tls_server_client") && ${#SANS[@]} -eq 0 ]]; then
echo "Profile $PROFILE requires at least one --san for browser compatibility" >&2
exit 2
fi
TEMPLATE="$HOME/pki/openssl-template.cnf"
INTERMEDIATE_DIR="$HOME/pki/intermediate"
sed \
-e "s|__CA_DIR__|${INTERMEDIATE_DIR}|g" \
-e "s|__CN__|${CN}|g" \
"${TEMPLATE}" > "${OUT}"
if [[ ${#SANS[@]} -gt 0 ]]; then
# Add SAN request to CSR extensions
awk '
BEGIN {in_req_ext=0}
/^\[ *req_ext *\]$/ {print; in_req_ext=1; print "subjectAltName = @alt_names"; next}
/^\[/ {in_req_ext=0; print; next}
{print}
' "${OUT}" > "${OUT}.tmp" && mv "${OUT}.tmp" "${OUT}"
fi
dns_i=0
ip_i=0
san_lines=""
for san in "${SANS[@]}"; do
if [[ "$san" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
((ip_i++))
san_lines+=$(printf "IP.%d = %s\n" "$ip_i" "$san")
elif [[ "$san" == *:* ]]; then
# treat as IPv6
((ip_i++))
san_lines+=$(printf "IP.%d = %s\n" "$ip_i" "$san")
else
((dns_i++))
san_lines+=$(printf "DNS.%d = %s\n" "$dns_i" "$san")
fi
done
if [[ -n "$san_lines" ]]; then
awk -v insert="$san_lines" '
BEGIN {printed=0}
/^\[ *alt_names *\]$/ {
print
printf "%s", insert
printed=1
next
}
{print}
END {
if (!printed) {
print ""
print "[ alt_names ]"
printf "%s", insert
}
}
' "${OUT}" > "${OUT}.tmp" && mv "${OUT}.tmp" "${OUT}"
fi
echo "PROFILE=${PROFILE}"
EOF
chmod +x ~/pki/make-leaf-config.sh
3) Create the Root CA (ECDSA P-256)
3a) Root key
openssl genpkey -algorithm EC \
-pkeyopt ec_paramgen_curve:P-256 \
-pkeyopt ec_param_enc:named_curve \
-out ~/pki/root/private/ca.key.pem
chmod 400 ~/pki/root/private/ca.key.pem
The above will not encrypt the private key in the keyfile. This is OK if the private keyfile will be protected by other means, and will be helpful with automation since you will not have to supply the password every time you want to use the private key; but - for a more secure setup - you should encrypt the key. This command …
openssl genpkey -algorithm EC \
-aes-256-cbc \
-pkeyopt ec_paramgen_curve:P-256 \
-pkeyopt ec_param_enc:named_curve \
-out ~/pki/root/private/ca.key.pem
chmod 400 ~/pki/root/private/ca.key.pem
… will prompt you for a password and use it to encrypt the key in the keyfile. The password will be required every time you want to access and use the private key. There are various ways to inject the password during automated use in scripts. We won’t go into that here though.
3b) Root self-signed cert
Create root config:
sed \
-e "s|__CA_DIR__|$HOME/pki/root|g" \
-e "s|__CN__|Example Root CA (EC)|g" \
~/pki/openssl-template.cnf > ~/pki/root/openssl-root.cnf
Issue root cert:
openssl req -config ~/pki/root/openssl-root.cnf \
-new -x509 -days 3650 -sha256 \
-key ~/pki/root/private/ca.key.pem \
-extensions v3_root_ca \
-out ~/pki/root/certs/ca.cert.pem
chmod 444 ~/pki/root/certs/ca.cert.pem
If you encrypted the root CA private key then you will be prompted for the password.
4) Create the Intermediate CA (ECDSA P-256), signed by Root
4a) Intermediate key
openssl genpkey -algorithm EC \
-pkeyopt ec_paramgen_curve:P-256 \
-pkeyopt ec_param_enc:named_curve \
-out ~/pki/intermediate/private/ca.key.pem
chmod 400 ~/pki/intermediate/private/ca.key.pem
As with the root CA private key, the above will not encrypt the private key in the keyfile. If you want to encrypt the private key then use this command …
openssl genpkey -algorithm EC \
-aes-256-cbc \
-pkeyopt ec_paramgen_curve:P-256 \
-pkeyopt ec_param_enc:named_curve \
-out ~/pki/intermediate/private/ca.key.pem
chmod 400 ~/pki/intermediate/private/ca.key.pem
4b) Intermediate CSR
Create intermediate config:
sed \
-e "s|__CA_DIR__|$HOME/pki/intermediate|g" \
-e "s|__CN__|Example Intermediate CA (EC)|g" \
~/pki/openssl-template.cnf > ~/pki/intermediate/openssl-intermediate.cnf
Create CSR:
openssl req -config ~/pki/intermediate/openssl-intermediate.cnf \
-new -sha256 \
-key ~/pki/intermediate/private/ca.key.pem \
-out ~/pki/intermediate/csr/ca.csr.pem
4c) Sign intermediate cert with Root
openssl ca -batch \
-config ~/pki/root/openssl-root.cnf \
-extensions v3_intermediate_ca \
-days 3650 -notext -md sha256 \
-in ~/pki/intermediate/csr/ca.csr.pem \
-out ~/pki/intermediate/certs/ca.cert.pem
chmod 444 ~/pki/intermediate/certs/ca.cert.pem
4d) Create CA chain file
cat ~/pki/intermediate/certs/ca.cert.pem \
~/pki/root/certs/ca.cert.pem > ~/pki/intermediate/certs/ca-chain.cert.pem
chmod 444 ~/pki/intermediate/certs/ca-chain.cert.pem
5) Issue a TLS server certificate (ECC) with N SANs
Example hostnames and IP:
- www.example.internal
- example.internal
- api.example.internal
- *192.168.1.50
5a) Server key
mkdir -p ~/pki/certs/server1
openssl genpkey -algorithm EC \
-pkeyopt ec_paramgen_curve:P-256 \
-pkeyopt ec_param_enc:named_curve \
-out ~/pki/certs/server1/server.key.pem
chmod 400 ~/pki/certs/server1/server.key.pem
This private key should generally not be encrypted. It will likely be installed as part of the config of a daemon on the server, and we want the server (and its daemons) to be able to start automatically without someone - or something - having to enter the password to decrypt the server’s private key.
5b) Generate per-leaf OpenSSL config with profile + SANs
PROFILE_LINE=$(~/pki/make-leaf-config.sh \
--cn "www.example.internal" \
--out ~/pki/tmp/server1.cnf \
--profile tls_server \
--san "www.example.internal" \
--san "example.internal" \
--san "api.example.internal" \
--san "192.168.1.50")
PROFILE=${PROFILE_LINE#PROFILE=}
echo "Using profile: $PROFILE"
5c) Create CSR
openssl req -new -sha256 \
-key ~/pki/certs/server1/server.key.pem \
-config ~/pki/tmp/server1.cnf \
-out ~/pki/csr/server1.csr.pem
5d) Sign server cert with Intermediate using the chosen profile
openssl ca -batch \
-config ~/pki/intermediate/openssl-intermediate.cnf \
-extensions "$PROFILE" \
-days 397 -notext -md sha256 \
-in ~/pki/csr/server1.csr.pem \
-out ~/pki/certs/server1/server.cert.pem
chmod 444 ~/pki/certs/server1/server.cert.pem
Verify chain:
openssl verify -CAfile ~/pki/intermediate/certs/ca-chain.cert.pem \
~/pki/certs/server1/server.cert.pem
6) Install on Linux server (nginx), TLS 1.3 only
Copy to the nginx host:
- *~/pki/certs/server1/server.key.pem
- ~/pki/certs/server1/server.cert.pem
- *~/pki/intermediate/certs/ca.cert.pem (intermediate cert)
On the nginx server:
sudo mkdir -p /etc/nginx/tls
sudo chmod 700 /etc/nginx/tls
sudo cp server.key.pem /etc/nginx/tls/
sudo cp server.cert.pem /etc/nginx/tls/
sudo cp ca.cert.pem /etc/nginx/tls/intermediate.cert.pem
sudo chmod 600 /etc/nginx/tls/server.key.pem
sudo chmod 644 /etc/nginx/tls/server.cert.pem /etc/nginx/tls/intermediate.cert.pem
sudo bash -c 'cat /etc/nginx/tls/server.cert.pem /etc/nginx/tls/intermediate.cert.pem > /etc/nginx/tls/fullchain.pem'
sudo chmod 644 /etc/nginx/tls/fullchain.pem
nginx site config:
server {
listen 443 ssl;
server_name www.example.internal example.internal api.example.internal;
ssl_certificate /etc/nginx/tls/fullchain.pem;
ssl_certificate_key /etc/nginx/tls/server.key.pem;
ssl_protocols TLSv1.3;
location / {
root /usr/share/nginx/html;
index index.html;
}
}
Reload:
sudo nginx -t
sudo systemctl reload nginx
Test from a client:
openssl s_client -connect www.example.internal:443 -servername www.example.internal -tls1_3
7) Install on Windows Server (IIS) using PFX
7a) Create PFX on Linux
openssl pkcs12 -export \
-out ~/pki/certs/server1/server1-ec.pfx \
-inkey ~/pki/certs/server1/server.key.pem \
-in ~/pki/certs/server1/server.cert.pem \
-certfile ~/pki/intermediate/certs/ca.cert.pem
7b) Import into LocalMachine personal store
On Windows Server (PowerShell as admin):
$pwd = Read-Host -AsSecureString
Import-PfxCertificate -FilePath C:\path\server1-ec.pfx -CertStoreLocation Cert:\LocalMachine\My -Password $pwd
If needed, import the intermediate cert into: Local Machine / Intermediate Certification Authorities
Then bind in IIS:
IIS Manager / Site / Bindings … / https :443 / select certificate
8) Install Root CA trust on Linux clients
Debian or Ubuntu:
sudo cp ~/pki/root/certs/ca.cert.pem /usr/local/share/ca-certificates/example-root-ec.crt
sudo update-ca-certificates
RHEL or Fedora:
sudo cp ~/pki/root/certs/ca.cert.pem /etc/pki/ca-trust/source/anchors/example-root-ec.crt
sudo update-ca-trust
9) Install Root CA trust on Windows clients
Convert root to DER:
openssl x509 -in ~/pki/root/certs/ca.cert.pem -outform der -out ~/pki/root/certs/root-ec.cer
Import into Trusted Root store:
Import-Certificate -FilePath C:\path\root-ec.cer -CertStoreLocation Cert:\LocalMachine\Root
10) Optional: issuing other cert types with the same recipe
The only differences are:
- Which profile you pick
- Whether you provide SANs
Example: issue an mTLS client cert
mkdir -p ~/pki/certs/client01
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -pkeyopt ec_param_enc:named_curve \
-out ~/pki/certs/client01/client.key.pem
PROFILE_LINE=$(~/pki/make-leaf-config.sh \
--cn "client01" \
--out ~/pki/tmp/client01.cnf \
--profile tls_client)
PROFILE=${PROFILE_LINE#PROFILE=}
openssl req -new -sha256 \
-key ~/pki/certs/client01/client.key.pem \
-config ~/pki/tmp/client01.cnf \
-out ~/pki/csr/client01.csr.pem
openssl ca -config ~/pki/intermediate/openssl-intermediate.cnf \
-extensions "$PROFILE" \
-days 397 -notext -md sha256 \
-in ~/pki/csr/client01.csr.pem \
-out ~/pki/certs/client01/client.cert.pem