aboutsummaryrefslogtreecommitdiff
#!/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