#!/bin/sh
#
# Utility to manage an internal certificate authority using OpenSSL.
set -eu
PROGNAME=pki
USAGE="<init|cert|client-cert|renew|pkcs12|show>"
BOXCONF_ROOT=$(dirname "$(readlink -f "$0")")
BOXCONF_CA_PASSWORD_FILE="${BOXCONF_ROOT}/.ca_password"
CA_VALID_DAYS=3650
DEFAULT_VALID_DAYS=3650
EC_CURVE=prime256v1
DIGEST=sha256
CIPHER=aes256
usage(){
printf 'usage: %s %s\n' "$PROGNAME" "$USAGE" 2>&1
exit 2
}
_pki_get_ca_password(){
# Acquire the CA password.
# If the BOXCONF_CA_PASSWORD environment variable is set, use that.
# Next, try reading the password from the .ca_password file.
# If all else fails, prompt interactively.
if [ -n "${BOXCONF_CA_PASSWORD:-}" ]; then
return
elif [ -f "$BOXCONF_CA_PASSWORD_FILE" ]; then
BOXCONF_CA_PASSWORD=$(cat "$BOXCONF_CA_PASSWORD_FILE")
else
_boxconf_read_password 'Enter CA password: ' BOXCONF_CA_PASSWORD
fi
}
_pki_dn2cnf() {
# Convert an LDAP DN to its OpenSSL .cnf file representation.
echo "$1" \
| tr ',' '\n' \
| awk '{a[i++]=$0} END {for (j=i-1; j>=0;) print a[j--] }' \
| sed \
-e 's/^[Cc][Nn]=/commonName=/' \
-e 's/^[Oo]=/organizationName=/' \
-e 's/^[Oo][Uu]=/organizationalUnitName=/' \
-e 's/^[Dd][Cc]=/domainComponent=/' \
-e 's/^[Cc]=/countryName=/' \
-e 's/^[Ss][Tt]=/stateOrProvinceName=/' \
-e 's/^[Ll]=/locality=/' \
-e 's/^[Ss][Nn]=/surName=/' \
-e 's/^[Gg][Nn]=/givenName=/' \
-e 's/^[Uu][Ii][Dd]=/userId=/' \
| awk '{print NR-1 "." $0}'
}
_pki_postsign(){
# Create symlinks and delete temp files after signing a certificate.
# $1 = certificate path
# Create symlink with human-readable name.
serial=$(awk 'END{print $3}' "${BOXCONF_CA_DIR}/index.txt")
ln -sf "../certs/${serial}.pem" "${BOXCONF_CA_DIR}/${1}.crt"
# Create fullchain certificate.
cat "${BOXCONF_CA_DIR}/${1}.crt" "${BOXCONF_CA_DIR}/ca.crt" > "${BOXCONF_CA_DIR}/${1}.fullchain.crt"
# Delete useless files.
rm -f \
"${BOXCONF_CA_DIR}/index.txt.old" \
"${BOXCONF_CA_DIR}/index.txt.attr.old" \
"${BOXCONF_CA_DIR}/serial.old"
}
_pki_sign(){
# Given an OpenSSL config file, generate a signed certificate keypair.
# $1 = certificate path
# $2 = validity time (days)
# Generate encrypted private key for the server certificate.
PASS="$BOXCONF_VAULT_PASSWORD" openssl genpkey \
-algorithm ec \
-pkeyopt "ec_paramgen_curve:${EC_CURVE}" \
"-${CIPHER}" \
-pass env:PASS \
-out "${BOXCONF_CA_DIR}/${1}.key"
# Generate the CSR.
PASS="$BOXCONF_VAULT_PASSWORD" openssl req -new \
-key "${BOXCONF_CA_DIR}/${1}.key" \
"-${DIGEST}" \
-passin env:PASS \
-config "${BOXCONF_CA_DIR}/${1}.cnf" \
-out "${BOXCONF_CA_DIR}/${1}.csr"
# Sign the certificate.
PASS="$BOXCONF_CA_PASSWORD" openssl ca -batch \
-config "${BOXCONF_CA_DIR}/ca.cnf" \
-passin env:PASS \
${2:+-days $2} \
-notext \
-out /dev/null \
-outdir "${BOXCONF_CA_DIR}/certs" \
-infiles "${BOXCONF_CA_DIR}/${1}.csr"
_pki_postsign "$1"
}
_pki_renew(){
# Re-sign an existing certificate.
# $1 = certificate path
# $2 = validity time (time)
_pki_get_ca_password
# Sign the certificate.
PASS="$BOXCONF_CA_PASSWORD" openssl ca -batch \
-config "${BOXCONF_CA_DIR}/ca.cnf" \
-passin env:PASS \
${2:+-days $2} \
-notext \
-out /dev/null \
-outdir "${BOXCONF_CA_DIR}/certs" \
-infiles "${BOXCONF_CA_DIR}/${1}.csr"
_pki_postsign "$1"
}
pki_init(){
# Initialize the CA. Create CA cert, private key, and OpenSSL configuration.
USAGE='init [-c CONSTRAINT]... DOMAIN'
constraints=''
while getopts :c: opt; do
case $opt in
c) constraints="${constraints}, permitted;${OPTARG}" ;;
:) usage ;;
?) usage ;;
esac
done
shift $((OPTIND - 1 ))
[ $# -eq 1 ] || usage
domain=$1
[ -d "$BOXCONF_CA_DIR" ] && die 'CA already exists'
_pki_get_ca_password
mkdir -p "${BOXCONF_CA_DIR}/certs"
# Generate encrypted private key for CA.
PASS="$BOXCONF_CA_PASSWORD" openssl genpkey \
-algorithm ec \
-pkeyopt "ec_paramgen_curve:${EC_CURVE}" \
"-${CIPHER}" \
-pass env:PASS \
-out "${BOXCONF_CA_DIR}/ca.key"
# Create a config file for the CA certificate.
cat > "${BOXCONF_CA_DIR}/ca.cnf" <<EOF
[ req ]
x509_extensions = v3_req
distinguished_name = req_distinguished_name
prompt = no
[ v3_req ]
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, keyCertSign, cRLSign
nameConstraints = permitted;DNS:${domain}, permitted;DNS:.${domain}, permitted;email:.${domain}${constraints}
[ req_distinguished_name ]
O = ${domain}
CN = Certificate Authority
[ ca ]
preserve = yes
default_ca = CA_own
[ CA_own ]
dir = .${BOXCONF_CA_DIR#${BOXCONF_ROOT}}
new_certs_dir = \$dir/certs
database = \$dir/index.txt
rand_serial = yes
unique_subject = no
certificate = \$dir/ca.crt
private_key = \$dir/ca.key
default_days = ${DEFAULT_VALID_DAYS}
default_crl_days = 30
default_md = ${DIGEST}
preserve = yes
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 = optional
emailAddress = optional
EOF
# Self-sign the CA certificate.
PASS="$BOXCONF_CA_PASSWORD" openssl req -new -x509 \
-days "$CA_VALID_DAYS" \
"-${DIGEST}" \
-passin env:PASS \
-config "${BOXCONF_CA_DIR}/ca.cnf" \
-key "${BOXCONF_CA_DIR}/ca.key" \
-out "${BOXCONF_CA_DIR}/ca.crt"
# Create empty index db.
[ -f "${BOXCONF_CA_DIR}/index.txt" ] || touch "${BOXCONF_CA_DIR}/index.txt"
}
pki_server(){
# Create a server certificate keypair.
USAGE='server-cert [-d DAYS] HOSTNAME CERTNAME SAN...'
while getopts :d: opt; do
case $opt in
d) days=$OPTARG ;;
:|?) usage ;;
esac
done
shift $((OPTIND - 1 ))
[ $# -ge 3 ] || usage
hostname=$1; certname=$2; cn=${3#*:}
shift 2
[ -e "${BOXCONF_CA_DIR}/${hostname}/${certname}.cnf" ] && die "certificate already exists: ${hostname}/${certname}"
_pki_get_ca_password
_boxconf_get_vault_password
# Generate the SAN list. If the arg contains a ':', pass along as-is.
# If no ':' is present, assume type 'DNS:'.
while [ $# -gt 0 ]; do
if [ "${1#*:}" = "$1" ]; then
sans="${sans:+${sans},}DNS:${1}"
else
sans="${sans:+${sans},}${1}"
fi
shift
done
# Create host directory.
mkdir -p "${BOXCONF_CA_DIR}/${hostname}"
# Create a config file for the server certificate.
cat > "${BOXCONF_CA_DIR}/${hostname}/${certname}.cnf" <<EOF
[ req ]
req_extensions = v3_req
distinguished_name = req_distinguished_name
prompt = no
[ v3_req ]
basicConstraints = critical,CA:FALSE
extendedKeyUsage = serverAuth
subjectAltName = ${sans}
[ req_distinguished_name ]
CN = ${cn}
EOF
# Generate and sign the certificate.
_pki_sign "${hostname}/${certname}" "${days:-}"
}
pki_client(){
# Create a client certificate keypair.
USAGE='client-cert HOSTNAME CERTNAME DN [SAN...]'
while getopts :d: opt; do
case $opt in
d) days=$OPTARG ;;
:|?) usage ;;
esac
done
shift $((OPTIND - 1 ))
[ $# -ge 3 ] || usage
hostname=$1; certname=$2; dn=$3
shift 3
[ -e "${BOXCONF_CA_DIR}/${hostname}/${certname}.cnf" ] && die "certificate already exists: ${hostname}/${certname}"
_pki_get_ca_password
_boxconf_get_vault_password
# Generate the SAN list.
if [ $# -gt 0 ]; then
sans=$1; shift
while [ $# -gt 0 ]; do
sans="${sans}, ${1}"
shift
done
fi
# Create host directory.
mkdir -p "${BOXCONF_CA_DIR}/${hostname}"
# Create a config file for the client certificate.
cat > "${BOXCONF_CA_DIR}/${hostname}/${certname}.cnf" <<EOF
[ req ]
req_extensions = v3_req
distinguished_name = req_distinguished_name
prompt = no
[ v3_req ]
basicConstraints = critical,CA:FALSE
extendedKeyUsage = clientAuth
${sans:+subjectAltName = $sans}
[ req_distinguished_name ]
$(_pki_dn2cnf "$dn")
EOF
# Generate and sign the certificate.
_pki_sign "${hostname}/${certname}" "${days:-}"
}
pki_renew(){
# Renew an existing certificate.
USAGE='renew [-d DAYS] HOSTNAME CERTNAME'
while getopts :d: opt; do
case $opt in
d) days=$OPTARG ;;
:|?) usage ;;
esac
done
shift $((OPTIND - 1 ))
[ $# -eq 2 ] || usage
[ -f "${BOXCONF_CA_DIR}/${1}/${2}.csr" ] || die "CSR does not exist: ${1}/${2}.csr"
_pki_renew "${1}/${2}" "${days:-}"
}
pki_pkcs12(){
# Generate a pkcs12 bundle.
USAGE='pkcs12 HOSTNAME CERTNAME PATH'
[ $# -eq 3 ] || usage
[ -f "${BOXCONF_CA_DIR}/${1}/${2}.crt" ] || die "certificate does not exist: ${1}/${2}.crt"
[ -f "${BOXCONF_CA_DIR}/${1}/${2}.key" ] || die "key does not exist: ${1}/${2}.key"
_boxconf_get_vault_password
PASS="$BOXCONF_VAULT_PASSWORD" openssl pkcs12 -legacy -export \
-out "$3" \
-inkey "${BOXCONF_CA_DIR}/${1}/${2}.key" \
-in "${BOXCONF_CA_DIR}/${1}/${2}.crt" \
-name "$2" \
-passin env:PASS
}
pki_show(){
# Show a certificate and decrypted private key.
USAGE='show HOSTNAME CERTNAME'
[ -f "${BOXCONF_CA_DIR}/${1}/${2}.crt" ] || die "certificate does not exist: ${1}/${2}.crt"
[ -f "${BOXCONF_CA_DIR}/${1}/${2}.key" ] || die "key does not exist: ${1}/${2}.key"
_boxconf_get_vault_password
cat "${BOXCONF_CA_DIR}/${1}/${2}.crt"
_boxconf_decrypt_key "${BOXCONF_CA_DIR}/${1}/${2}.key"
}
[ $# -ge 1 ] || usage
action=$1; shift
for _bc_lib in "${BOXCONF_ROOT}/lib"/*; do
. "$_bc_lib"
done
case $action in
init) pki_init "$@" ;;
server-cert|server|cert) pki_server "$@" ;;
client-cert|client) pki_client "$@" ;;
renew) pki_renew "$@" ;;
pkcs12) pki_pkcs12 "$@" ;;
show) pki_show "$@" ;;
*) usage ;;
esac