#!/bin/sh # # shellpki is a wrapper around OpenSSL to manage a small PKI # VERSION="1.0.0" show_version() { cat <, Thomas Martin , Gregory Colpart , Romain Dessort , Benoit Série , Victor Laborie , Daniel Jakots , Patrick Marchand , Jérémy Lecour and others. shellpki comes with ABSOLUTELY NO WARRANTY. This is free software, and you are welcome to redistribute it under certain conditions. See the MIT Licence for details. END } init() { umask 0177 [ -d "${CA_DIR}" ] || mkdir -m 0750 "${CA_DIR}" [ -d "${CRT_DIR}" ] || mkdir -m 0750 "${CRT_DIR}" [ -f "${INDEX_FILE}" ] || touch "${INDEX_FILE}" [ -f "${CRL}" ] || touch "${CRL}" [ -f "${SERIAL}" ] || echo "01" > "${SERIAL}" cn="${1:-}" if [ -z "${cn}" ]; then show_usage >&2 exit 1 fi if [ -f "${CA_KEY}" ]; then printf "%s already exists, do you really want to erase it ? [y/N] " "${CA_KEY}" read -r REPLY resp=$(echo "${REPLY}" | tr 'Y' 'y') if [ "${resp}" = "y" ]; then rm -f "${CA_KEY}" "${CA_CERT}" fi fi if [ ! -f "${CA_KEY}" ]; then "${OPENSSL_BIN}" genrsa \ -out "${CA_KEY}" \ -aes256 ${CA_KEY_LENGTH} \ >/dev/null 2>&1 if [ "$?" -ne 0 ]; then error "Error generating the CA key: $?" fi fi if [ -f "${CA_CERT}" ]; then printf "%s already exists, do you really want to erase it ? [y/N] " "${CA_CERT}" read -r REPLY resp=$(echo "${REPLY}" | tr 'Y' 'y') if [ "${resp}" = "y" ]; then rm "${CA_CERT}" fi fi if [ ! -f "${CA_CERT}" ]; then ask_ca_password 0 fi if [ ! -f "${CA_CERT}" ]; then "${OPENSSL_BIN}" req \ -new \ -batch \ -sha512 \ -x509 \ -days 3650 \ -extensions v3_ca \ -passin pass:${CA_PASSWORD} \ -key "${CA_KEY}" \ -out "${CA_CERT}" \ -config /dev/stdin <&2 exit 1 fi ocsp_csr_file="${CSR_DIR}/ocsp.csr" url=$(echo "${ocsp_uri}" | cut -d':' -f1) port=$(echo "${ocsp_uri}" | cut -d':' -f2) if [ ! -f "${OCSP_KEY}" ]; then "${OPENSSL_BIN}" genrsa \ -out "${OCSP_KEY}" \ ${KEY_LENGTH} \ >/dev/null 2>&1 if [ "$?" -ne 0 ]; then error "Error generating the OCSP key: $?" fi fi "${OPENSSL_BIN}" req \ -batch \ -new \ -key "${OCSP_KEY}" \ -out "${ocsp_csr_file}" \ -config /dev/stdin < [options] [CommonName] Initialize PKI (create CA key and self-signed cert) : ${0} init Run OCSP_D server : ${0} ocsp Create a client cert with key and CSR directly generated on server (use -p or --password-file to set a password on the client key) : ${0} create [-p|--password-file=] Create a client cert from a CSR (doesn't need key) : ${0} create -f Revoke a client cert with is commonName (CN) : ${0} revoke List all actually valid commonName (CN) : ${0} list [-a|v|r] Check expiration date of valid certificates : ${0} check EOF } error() { echo "${1}" >&2 exit 1 } warning() { echo "${1}" >&2 } verify_ca_password() { "${OPENSSL_BIN}" rsa \ -in "${CA_KEY}" \ -passin pass:${CA_PASSWORD} \ >/dev/null 2>&1 } ask_ca_password() { attempt=${1:-0} max_attempts=3 trap 'unset CA_PASSWORD' 0 if [ ! -f "${CA_KEY}" ]; then error "You must initialize your PKI with \`shellpki init' !" fi if [ "${attempt}" -gt 0 ]; then warning "Invalid password, retry." fi if [ "${attempt}" -ge "${max_attempts}" ]; then error "Maximum number of attempts reached (${max_attempts})." fi if [ -z "${CA_PASSWORD}" ]; then stty -echo printf "Password for CA key: " read -r CA_PASSWORD stty echo printf "\n" fi if [ -z "${CA_PASSWORD}" ] || ! verify_ca_password; then unset CA_PASSWORD attempt=$(( attempt + 1 )) ask_ca_password "${attempt}" fi } ask_user_password() { trap 'unset PASSWORD' 0 stty -echo printf "Password for user key: " read -r PASSWORD stty echo printf "\n" if [ -z "${PASSWORD}" ]; then warning "Warning: empty password from input" fi } create() { from_csr=0 ask_pass=0 # Parse options # based on https://gist.github.com/deshion/10d3cb5f88a21671e17a while :; do case $1 in -f|--file|--csr-file) # csr-file option, with value separated by space if [ -n "$2" ]; then from_csr=1 csr_file=$(readlink --canonicalize --verbose -- "${2}") shift else printf 'ERROR: "--csr-file" requires a non-empty option argument.\n' >&2 exit 1 fi ;; --file=?*|--csr-file=?*) from_csr=1 # csr-file option, with value separated by = csr_file=$(readlink --canonicalize --verbose -- "${1#*=}") ;; --file=|--csr-file=) # csr-file options, without value printf 'ERROR: "--csr-file" requires a non-empty option argument.\n' >&2 exit 1 ;; -p|--password) ask_pass=1 ;; --password-file) # password-file option, with value separated by space if [ -n "$2" ]; then password_file=$(readlink --canonicalize --verbose -- "${2}") shift else printf 'ERROR: "--password-file" requires a non-empty option argument.\n' >&2 exit 1 fi ;; --password-file=?*) # password-file option, with value separated by = password_file=$(readlink --canonicalize --verbose -- "${1#*=}") ;; --password-file=) # password-file options, without value printf 'ERROR: "--password-file" requires a non-empty option argument.\n' >&2 exit 1 ;; --days) # days option, with value separated by space if [ -n "$2" ]; then days=${2} shift else printf 'ERROR: "--days" requires a non-empty option argument.\n' >&2 exit 1 fi ;; --days=?*) # days option, with value separated by = days=${1#*=} ;; --days=) # days options, without value printf 'ERROR: "--days" requires a non-empty option argument.\n' >&2 exit 1 ;; --end-date) # end-date option, with value separated by space if [ -n "$2" ]; then end_date=${2} shift else printf 'ERROR: "--end-date" requires a non-empty option argument.\n' >&2 exit 1 fi ;; --end-date=?*) # end-date option, with value separated by = end_date=${1#*=} ;; --end-date=) # end-date options, without value printf 'ERROR: "--end-date" requires a non-empty option argument.\n' >&2 exit 1 ;; --) # End of all options. shift break ;; -?*) # ignore unknown options printf 'WARN: Unknown option (ignored): %s\n' "$1" >&2 ;; *) # Default case: If no more options then break out of the loop. break ;; esac shift done # The name of the certificate cn="${1:-}" # Set expiration argument crt_expiration_arg="" if [ -n "${days}" ] && [ "${days}" -gt 0 ]; then crt_expiration_arg="-days ${days}" fi if [ -n "${end_date}" ]; then cert_end_date=$(TZ=:Zulu date --date "${end_date}" +"%Y%m%d%H%M%SZ" 2> /dev/null) if [ "$?" -ne 0 ]; then error "Invalid end date format : \`${end_date}' can't be parsed by date(1)" else crt_expiration_arg="-enddate ${cert_end_date}" fi fi if [ "${from_csr}" -eq 1 ]; then if [ "${ask_pass}" -eq 1 ]; then warning "Warning: -p|--password is ignored with -f|--file|--crt-file" fi if [ -n "${password_file}" ]; then warning "Warning: --password-file is ignored with -f|--file|--crt-file" fi crt_file="${CRT_DIR}/${cn}.crt" # ask for CA passphrase ask_ca_password 0 # check if csr_file is a CSR "${OPENSSL_BIN}" req \ -noout \ -subject \ -in "${csr_file}" \ >/dev/null 2>&1 if [ "$?" -ne 0 ]; then error "${csr_file} is not a valid CSR !" fi # check if csr_file contain a CN "${OPENSSL_BIN}" req \ -noout \ -subject \ -in "${csr_file}" \ | grep -Eo "CN\s*=[^,/]*" \ >/dev/null 2>&1 if [ "$?" -ne 0 ]; then error "${csr_file} doesn't contain a CommonName !" fi # get CN from CSR cn=$("${OPENSSL_BIN}" req -noout -subject -in "${csr_file}" | grep -Eo "CN\s*=[^,/]*" | cut -d'=' -f2 | xargs) # check if CN already exist if [ -f "${CRT_DIR}/${cn}.crt" ]; then printf "%s already exists, do you revoke and recreate it ? [y/N] " "${cn}" read -r REPLY resp=$(echo "${REPLY}" | tr 'Y' 'y') if [ "${resp}" = "y" ]; then revoke "${cn}" else error "Abort" fi fi # ca sign and generate cert "${OPENSSL_BIN}" ca \ -config "${CONF_FILE}" \ -in "${csr_file}" \ -passin pass:${CA_PASSWORD} \ -out "${crt_file}" \ ${crt_expiration_arg} if [ "$?" -ne 0 ]; then error "Error generating the certificate: $?" else echo "The certificate file is available at \`${crt_file}'" fi else if [ -z "${cn}" ]; then show_usage >&2 exit 1 fi csr_file="${CSR_DIR}/${cn}-${SUFFIX}.csr" crt_file="${CRT_DIR}/${cn}.crt" key_file="${KEY_DIR}/${cn}-${SUFFIX}.key" ovpn_file="${OVPN_DIR}/${cn}-${SUFFIX}.ovpn" pkcs12_file="${PKCS12_DIR}/${cn}-${SUFFIX}.p12" # check if CN already exist if [ -f "${crt_file}" ]; then printf "%s already exists, do you revoke and recreate it ? [y/N] " "${cn}" read -r REPLY resp=$(echo "${REPLY}" | tr 'Y' 'y') if [ "${resp}" = "y" ]; then revoke "${cn}" else error "Abort" fi fi # ask for CA passphrase ask_ca_password 0 if [ "${ask_pass}" -eq 1 ]; then ask_user_password fi # generate private key PASS_ARGS="" if [ -n "${password_file}" ]; then PASS_ARGS="-aes256 -passout file:${password_file}" elif [ -n "${PASSWORD}" ]; then PASS_ARGS="-aes256 -passout pass:${PASSWORD}" fi "${OPENSSL_BIN}" genrsa \ -out "${key_file}" \ ${PASS_ARGS} \ ${KEY_LENGTH} \ >/dev/null 2>&1 if [ "$?" -ne 0 ]; then error "Error generating the private key: $?" fi # generate csr req PASS_ARGS="" if [ -n "${password_file}" ]; then PASS_ARGS="-passin file:${password_file}" elif [ -n "${PASSWORD}" ]; then PASS_ARGS="-passin pass:${PASSWORD}" fi "${OPENSSL_BIN}" req \ -batch \ -new \ -key "${key_file}" \ -out "${csr_file}" \ ${PASS_ARGS} \ -config /dev/stdin </dev/null 2>&1 if [ "$?" -ne 0 ]; then rm -f "${crt_file}" fi if [ ! -f "${crt_file}" ]; then error "Error in CSR creation" fi chmod 640 "${crt_file}" echo "The CRT file is available in ${crt_file}" # generate pkcs12 format PASS_ARGS="" if [ -n "${password_file}" ]; then # Hack for pkcs12 : # If passin and passout files are the same path, it expects 2 lines # so we make a temporary copy of the password file password_file_out=$(mktemp) cp "${password_file}" "${password_file_out}" PASS_ARGS="-passin file:${password_file} -passout file:${password_file_out}" elif [ -n "${PASSWORD}" ]; then PASS_ARGS="-passin pass:${PASSWORD} -passout pass:${PASSWORD}" else PASS_ARGS="-passout pass:" fi "${OPENSSL_BIN}" pkcs12 \ -export \ -nodes \ -inkey "${key_file}" \ -in "${crt_file}" \ -out "${pkcs12_file}" \ ${PASS_ARGS} if [ "$?" -ne 0 ]; then error "Error generating the pkcs12 file: $?" fi if [ -n "${password_file_out}" ]; then # Hack for pkcs12 : # Destroy the temporary file rm -f "${password_file_out}" fi chmod 640 "${pkcs12_file}" echo "The PKCS12 config file is available at \`${pkcs12_file}'" # generate openvpn format if [ -e "${CA_DIR}/ovpn.conf" ]; then cat "${CA_DIR}/ovpn.conf" - > "${ovpn_file}" < $(cat "${CA_CERT}") $(cat "${crt_file}") $(cat "${key_file}") EOF chmod 640 "${ovpn_file}" echo "The OpenVPN config file is available at \`${ovpn_file}'" fi fi } revoke() { if [ "${1}" = "" ]; then show_usage >&2 exit 1 fi crt_file="${CRT_DIR}/${cn}.crt" # get CN from param cn="${1}" # check if CRT exists if [ ! -f "${crt_file}" ]; then error "Unknow CN : ${cn} (\`${crt_file}' not found)" fi # check if CRT is a valid "${OPENSSL_BIN}" x509 \ -noout \ -subject \ -in "${crt_file}" \ >/dev/null 2>&1 if [ "$?" -ne 0 ]; then error "${crt_file} is not a valid CRT, you must delete it !" fi # ask for CA passphrase ask_ca_password 0 echo "Revoke certificate ${crt_file} :" "${OPENSSL_BIN}" ca \ -config "${CONF_FILE}" \ -passin pass:${CA_PASSWORD} \ -revoke "${crt_file}" if [ "$?" -eq 0 ]; then rm "${crt_file}" fi "${OPENSSL_BIN}" ca \ -config "${CONF_FILE}" \ -passin pass:${CA_PASSWORD} \ -gencrl \ -out "${CRL}" } list() { if [ ! -f "${INDEX_FILE}" ]; then exit 0 fi list_valid=0 list_revoked=1 while :; do case "${1}" in -a|--all) list_valid=0 list_revoked=0 shift;; -v|--valid) list_valid=0 list_revoked=1 shift;; -r|--revoked) list_valid=1 list_revoked=0 shift;; --) shift break;; -?*) warning "unknow option ${1} (ignored)" shift;; *) break;; esac done if [ "${list_valid}" -eq 0 ]; then certs=$(grep "^V" "${INDEX_FILE}") fi if [ "${list_revoked}" -eq 0 ]; then certs=$(grep "^R" "${INDEX_FILE}") fi if [ "${list_valid}" -eq 0 ] && [ "${list_revoked}" -eq 0 ]; then certs=$(cat "${INDEX_FILE}") fi echo "${certs}" | grep -Eo "CN\s*=[^,/]*" | cut -d'=' -f2 | xargs -n1 } cert_end_date() { "${OPENSSL_BIN}" x509 -noout -enddate -in "${1}" | cut -d'=' -f2 } check() { # default expiration alert # TODO : permit override with parameters min_day=90 cur_epoch=$(date -u +'%s') for cert in ${CRT_DIR}/*; do end_date=$(cert_end_date "${cert}") end_epoch=$(date -ud "${end_date}" +'%s') diff_epoch=$(( end_epoch - cur_epoch )) diff_day=$(( diff_epoch / 60 / 60 / 24 )) if [ "${diff_day}" -lt "${min_day}" ]; then if [ "${diff_day}" -le 0 ]; then echo "${cert} has expired" else echo "${cert} expire in ${diff_day} days" fi fi done } is_user() { getent passwd "${1}" >/dev/null } is_group() { getent group "${1}" >/dev/null } main() { # default config # TODO : override with /etc/default/shellpki CONF_FILE="/etc/shellpki/openssl.cnf" if [ "$(uname)" = "OpenBSD" ]; then PKI_USER="_shellpki" else PKI_USER="shellpki" fi if [ "${USER}" != "root" ] && [ "${USER}" != "${PKI_USER}" ]; then error "Please become root before running ${0} !" fi # retrieve CA path from config file CA_DIR=$(grep -E "^dir" "${CONF_FILE}" | cut -d'=' -f2 | xargs -n1) CA_KEY=$(grep -E "^private_key" "${CONF_FILE}" | cut -d'=' -f2 | xargs -n1 | sed "s~\$dir~${CA_DIR}~") CA_CERT=$(grep -E "^certificate" "${CONF_FILE}" | cut -d'=' -f2 | xargs -n1 | sed "s~\$dir~${CA_DIR}~") OCSP_KEY="${CA_DIR}/ocsp.key" OCSP_CERT="${CA_DIR}/ocsp.pem" CRT_DIR=$(grep -E "^certs" "${CONF_FILE}" | cut -d'=' -f2 | xargs -n1 | sed "s~\$dir~${CA_DIR}~") TMP_DIR=$(grep -E "^new_certs_dir" "${CONF_FILE}" | cut -d'=' -f2 | xargs -n1 | sed "s~\$dir~${CA_DIR}~") INDEX_FILE=$(grep -E "^database" "${CONF_FILE}" | cut -d'=' -f2 | xargs -n1 | sed "s~\$dir~${CA_DIR}~") SERIAL=$(grep -E "^serial" "${CONF_FILE}" | cut -d'=' -f2 | xargs -n1 | sed "s~\$dir~${CA_DIR}~") CRL=$(grep -E "^crl" "${CONF_FILE}" | cut -d'=' -f2 | xargs -n1 | sed "s~\$dir~${CA_DIR}~") # directories for clients key, csr, crt KEY_DIR="${CA_DIR}/private" CSR_DIR="${CA_DIR}/requests" PKCS12_DIR="${CA_DIR}/pkcs12" OVPN_DIR="${CA_DIR}/openvpn" CA_KEY_LENGTH=4096 if [ "${CA_KEY_LENGTH}" -lt 4096 ]; then error "CA key must be at least 4096 bits long." fi KEY_LENGTH=2048 if [ "${KEY_LENGTH}" -lt 2048 ]; then error "User key must be at least 2048 bits long." fi OPENSSL_BIN=$(command -v openssl) SUFFIX=$(/bin/date +"%s") if ! is_user "${PKI_USER}" || ! is_group "${PKI_USER}"; then error "You must create ${PKI_USER} user and group !" fi if [ ! -e "${CONF_FILE}" ]; then error "${CONF_FILE} is missing" fi mkdir -p "${CA_DIR}" "${CRT_DIR}" "${KEY_DIR}" "${CSR_DIR}" "${PKCS12_DIR}" "${OVPN_DIR}" "${TMP_DIR}" command=${1:-help} case "${command}" in init) shift init "$@" ;; ocsp) shift ocsp "$@" ;; create) shift create "$@" ;; revoke) shift revoke "$@" ;; list) shift list "$@" ;; check) shift check "$@" ;; version) show_version exit 0 ;; help) show_usage exit 0 ;; *) show_usage >&2 exit 1 ;; esac # fix right chown -R "${PKI_USER}":"${PKI_USER}" "${CA_DIR}" chmod 750 "${CA_DIR}" "${CRT_DIR}" "${KEY_DIR}" "${CSR_DIR}" "${PKCS12_DIR}" "${OVPN_DIR}" "${TMP_DIR}" chmod 600 "${INDEX_FILE}"* "${SERIAL}"* "${CA_KEY}" "${CRL}" chmod 640 "${CA_CERT}" } main "$@"