#!/bin/sh # # shellpki is a wrapper around OpenSSL to manage a small PKI # set -u 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}" non_interactive=0 # Parse options # based on https://gist.github.com/deshion/10d3cb5f88a21671e17a while :; do case $1 in --non-interactive) non_interactive=1 ;; --) # End of all options. shift break ;; -?*) # ignore unknown options warning "Warning: unknown option (ignored): \`$1'" ;; *) # Default case: If no more options then break out of the loop. break ;; esac shift done 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|--all|-v|--valid|-r|--revoked] 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 if [ "${non_interactive}" -eq 1 ]; then error "In non-interactive mode, you must pass CA_PASSWORD as environment variable" fi 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 if [ -z "${PASSWORD:-}" ]; then if [ "${non_interactive}" -eq 1 ]; then error "In non-interactive mode, you must pass PASSWORD as environment variable or use --password-file" fi stty -echo printf "Password for user key: " read -r PASSWORD stty echo printf "\n" fi if [ -z "${PASSWORD:-}" ]; then warning "Warning: empty password from input" fi } replace_existing_or_abort() { cn=${1:?} if [ "${non_interactive}" -eq 1 ]; then if [ "${replace_existing}" -eq 1 ]; then resp="y" else error "${cn} already exists, use \`--replace-existing' to force" fi else if [ "${replace_existing}" -eq 1 ]; then resp="y" else printf "%s already exists, do you want to revoke and recreate it ? [y/N] " "${cn}" read -r REPLY resp=$(echo "${REPLY}" | tr 'Y' 'y') fi fi if [ "${resp}" = "y" ]; then revoke "${cn}" else error "Aborted" fi } create() { from_csr=0 ask_pass=0 non_interactive=0 replace_existing=0 days="" end_date="" # 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 -- "${2}") if [ "$?" -ne 0 ]; then error "Error accessing file \`${2}'" fi shift else error "Argument error: \`--csr-file' requires a value" fi ;; --file=?*|--csr-file=?*) from_csr=1 # csr-file option, with value separated by = csr_file=$(readlink --canonicalize -- "${1#*=}") if [ "$?" -ne 0 ]; then error "Error accessing file \`${1#*=}'" fi ;; --file=|--csr-file=) # csr-file options, without value error "Argument error: \`--csr-file' requires a value" ;; -p|--password) ask_pass=1 ;; --password-file) # password-file option, with value separated by space if [ -n "$2" ]; then password_file=$(readlink --canonicalize -- "${2}") if [ "$?" -ne 0 ]; then error "Error accessing file \`${2}'" fi shift else error "Argument error: \`--password-file' requires a value" fi ;; --password-file=?*) # password-file option, with value separated by = password_file=$(readlink --canonicalize -- "${1#*=}") if [ "$?" -ne 0 ]; then error "Error accessing file \`${1#*=}'" fi ;; --password-file=) # password-file options, without value error "Argument error: \`--password-file' requires a value" ;; --days) # days option, with value separated by space if [ -n "$2" ]; then days=${2} shift else error "Argument error: \`--days' requires a value" fi ;; --days=?*) # days option, with value separated by = days=${1#*=} ;; --days=) # days options, without value error "Argument error: \`--days' requires a value" ;; --end-date) # end-date option, with value separated by space if [ -n "$2" ]; then end_date=${2} shift else error "Argument error: \`--end-date' requires a value" fi ;; --end-date=?*) # end-date option, with value separated by = end_date=${1#*=} ;; --end-date=) # end-date options, without value error "Argument error: \`--end-date' requires a value" ;; --non-interactive) non_interactive=1 ;; --replace-existing) replace_existing=1 ;; --) # End of all options. shift break ;; -?*) # ignore unknown options warning "Warning: unknown option (ignored): \`$1'" ;; *) # 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 [ "${non_interactive}" -eq 1 ]; then batch_arg="-batch" else batch_arg="" 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_file}" ]; then replace_existing_or_abort "${cn}" fi # ca sign and generate cert if [ "${non_interactive}" -eq 1 ]; then batch_arg="-batch" else batch_arg="" fi "${OPENSSL_BIN}" ca \ ${batch_arg} \ -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 replace_existing_or_abort "${cn}" 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 [ "$?" -eq 0 ]; then echo "The KEY file is available at \`${key_file}'" else 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 # Copy files if destination exists if [ -d "${COPY_DIR}" ]; then for file in "${crt_file}" "${key_file}" "${pkcs12_file}" "${ovpn_file}"; do if [ -f "${file}" ]; then new_file="${COPY_DIR}/$(basename "${file}")" if [ "${replace_existing}" -eq 1 ]; then cp -f "${file}" "${COPY_DIR}/" else if [ "${non_interactive}" -eq 1 ]; then if [ -f "${new_file}" ]; then echo "File \`${file}' has not been copied to \`${new_file}', it already exists" >&2 continue else cp "${file}" "${COPY_DIR}/" fi else cp -i "${file}" "${COPY_DIR}/" fi fi echo "File \`${file}' has been copied to \`${new_file}'" fi done chown -R ${PKI_USER}:${PKI_USER} "${COPY_DIR}/" chmod -R u=rwX,g=rwX,o= "${COPY_DIR}/" fi fi } revoke() { non_interactive=0 # Parse options # based on https://gist.github.com/deshion/10d3cb5f88a21671e17a while :; do case $1 in --non-interactive) non_interactive=1 ;; --) # End of all options. shift break ;; -?*) # ignore unknown options warning "Warning: unknown option (ignored): \`$1'" ;; *) # Default case: If no more options then break out of the loop. break ;; esac shift done # The name of the certificate cn="${1:-}" if [ -z "${cn}" ]; then show_usage >&2 exit 1 fi crt_file="${CRT_DIR}/${cn}.crt" # 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 if [ -z "${1}" ]; then show_usage >&2 exit 1 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" COPY_DIR="$(dirname "${CONF_FILE}")/copy_output" 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 "$@"