diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..67182cf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +### Changed + +### Fixed + +### Removed + +### Security + +## [22.04] 2022-04-14 + +### Added + +* Create a changelog +* Add a version number and `version` command +* Accept a `password-file` command line option to read password from a file +* Accept `--days` and `--end-date` command line options +* CA key length is configurable (minimum 4096) +* Add `--non-interactive` command line option +* Add `--replace-existing` command line option +* Copy files if destination exists +* Generate the CRL file after initialization of the CA +* `cert-expirations.sh` script to print out certificates expiration dates + +### Changed + +* Rename internal function usage() to show_usage() +* Split show_usage() for each subcommand +* More readable variable names +* verify_ca_password() looks for a previously set password and verifies it +* Extract cert_end_date() function +* Extract is_user() and is_group() functions +* Extract ask_user_password() function +* Extract variables for files +* Use inline pass phrase arguments +* Create files with a human readable date instead of epoch +* Remove "set -e" and add many return code checks +* Prevent use of uninitialized variables + +### Fixed + +* Check on $USER was always true diff --git a/README.md b/README.md index bba6a18..bdce064 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,11 @@ This script is a wrapper around OpenSSL to manage a small [PKI](https://en.wikipedia.org/wiki/Public_key_infrastructure). +## Contribution + +After an update of this repo and if everything is working fine, some files must +be copied to [ansible-roles/openvpn](https://gitea.evolix.org/evolix/ansible-roles/src/branch/unstable/openvpn/files/shellpki) + ## Install ### Debian @@ -49,47 +54,87 @@ proto udp remote ovpn.example.com 1194 +nobind +user nobody +group nogroup persist-key persist-tun -cipher AES-256-CBC +cipher AES-256-GCM ~~~ ## Usage ~~~ -Usage: ./shellpki [options] [CommonName] +Usage: shellpki [options] [CommonName] ~~~ -Initialize PKI (create CA key and self-signed cert) : +Initialize PKI (create CA key and self-signed certificate) : ~~~ - ./shellpki init +shellpki init [options] + +Options + --non-interactive do not prompt the user, and exit if an error occurs ~~~ -Create a client cert with key and CSR directly generated on server -(use -p for set a password on client key) : +Create a client certificate with key and CSR directly generated on server : ~~~ - ./shellpki create [-p] +shellpki create [options] + +Options + -f, --file, --csr-file create a client certificate from a CSR (doesn't need key) + -p, --password prompt the user for a password to set on the client key + --password-file if provided with a path to a readable file, the first line is read and set as password on the client key + --days specify how many days the certificate should be valid + --end-date specify until which date the certificate should be valid, in YYYY/MM/DD hh:mm:ss format, UTC +0 + --non-interactive do not prompt the user, and exit if an error occurs + --replace-existing if the certificate already exists, revoke it before creating a new one ~~~ -Create a client cert from a CSR (doesn't need key) : +Revoke a client certificate : ~~~ - ./shellpki create -f +shellpki revoke [options] + +Options + --non-interactive do not prompt the user, and exit if an error occurs ~~~ -Revoke a client cert with is commonName (CN) : +List all certificates : ~~~ - ./shellpki revoke +shellpki list + +Options + -a, --all list all certificates : valid and revoked ones + -v, --valid list all valid certificates + -r, --revoked list all revoked certificates ~~~ -List all actually valid commonName (CN) : +Check expiration date of valid certificates : ~~~ - ./shellpki list +shellpki check +~~~ + +Run OCSP_D server : + +~~~ +shellpki ocsp +~~~ + +Show version : + +~~~ +shellpki version +~~~ + +Show help : + +~~~ +shellpki help ~~~ ## License diff --git a/cert-expirations.sh b/cert-expirations.sh new file mode 100644 index 0000000..f1b5601 --- /dev/null +++ b/cert-expirations.sh @@ -0,0 +1,28 @@ +#!/bin/sh + +VERSION="22.04" + +carp=$(/sbin/ifconfig carp0 2>/dev/null | grep 'status' | cut -d' ' -f2) + +if [ "$carp" = "backup" ]; then + exit 0 +fi + +echo "Warning : all times are in UTC !\n" + +echo "CA certificate:" +openssl x509 -enddate -noout -in /etc/shellpki/cacert.pem \ + | cut -d '=' -f 2 \ + | sed -e "s/^\(.*\)\ \(20..\).*/- \2 \1/" + +echo "" + +echo "Client certificates:" +cat /etc/shellpki/index.txt \ + | grep ^V \ + | awk -F "/" '{print $1,$5}' \ + | awk '{print $2,$5}' \ + | sed 's/CN=//' \ + | sed -E 's/([[:digit:]]{2})([[:digit:]]{2})([[:digit:]]{2})([[:digit:]]{2})([[:digit:]]{2})([[:digit:]]{2})Z (.*)/- 20\1 \2 \3 \4:\5:\6 \7/' \ + | awk '{if ($3 == "01") $3="Jan"; else if ($3 == "02") $3="Feb"; else if ($3 == "03") $3="Mar"; else if ($3 == "04") $3="Apr"; else if ($3 == "05") $3="May"; else if ($3 == "06") $3="Jun"; else if ($3 == "07") $3="Jul"; else if ($3 == "08") $3="Aug"; else if ($3 == "09") $3="Sep"; else if ($3 == "10") $3="Oct"; else if ($3 == "11") $3="Nov"; else if ($3 == "12") $3="Dec"; print $0;}' \ + | sort -n -k 2 -k 3M -k 4 diff --git a/cn-validation.sh b/cn-validation.sh new file mode 100644 index 0000000..f6710b8 --- /dev/null +++ b/cn-validation.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# +# cn-validation.sh is a client-connect script for OpenVPN server +# When connecting using the PAM plugin, it allow clients to connect only if their CN is equal to their UNIX username +# +# You need this parameters in your's server config : +# +# script-security 2 +# client-connect /cn-validation.sh +# + +set -u + +if [ "${common_name}" = "${username}" ]; then + logger -i -t openvpn-cn-validation -p auth.info "Accepted login for ${common_name} from ${trusted_ip} port ${trusted_port}" + exit 0 +else + logger -i -t openvpn-cn-validation -p auth.notice "Failed login for CN ${common_name} / username ${username} from ${trusted_ip} port ${trusted_port}" +fi + +exit 1 diff --git a/openssl.cnf b/openssl.cnf index 2c87f10..5e1e3c8 100644 --- a/openssl.cnf +++ b/openssl.cnf @@ -1,3 +1,5 @@ +# VERSION="22.04" + [ ca ] default_ca = CA_default diff --git a/shellpki b/shellpki index 9fb1d94..5d13986 100755 --- a/shellpki +++ b/shellpki @@ -1,124 +1,116 @@ #!/bin/sh # -# shellpki is a wrapper around openssl to manage a small PKI +# shellpki is a wrapper around OpenSSL to manage a small PKI # -set -e +set -u -init() { - umask 0177 +VERSION="22.04" - [ -d "${CADIR}" ] || mkdir -m 0750 "${CADIR}" - [ -d "${CRTDIR}" ] || mkdir -m 0750 "${CRTDIR}" - [ -f "${INDEX}" ] || touch "${INDEX}" - [ -f "${CRL}" ] || touch "${CRL}" - [ -f "${SERIAL}" ] || echo "01" > "${SERIAL}" +show_version() { + cat <&2 && exit 1 +Copyright 2010-2022 Evolix , + Thomas Martin , + Gregory Colpart , + Romain Dessort , + Benoit Série , + Victor Laborie , + Daniel Jakots , + Patrick Marchand , + Jérémy Lecour , + Jérémy Dubois + and others. - if [ -f "${CAKEY}" ]; then - printf "%s already exists, do you really want to erase it ? [y/N] " "${CAKEY}" - read -r REPLY - resp=$(echo "${REPLY}"|tr 'Y' 'y') - [ "${resp}" = "y" ] && rm -f "${CAKEY}" "${CACERT}" - fi - - [ ! -f "${CAKEY}" ] && "$OPENSSL" \ - genrsa \ - -out "${CAKEY}" \ - -aes256 4096 >/dev/null 2>&1 - - if [ -f "${CACERT}" ]; then - printf "%s already exists, do you really want to erase it ? [y/N] " "${CACERT}" - read -r REPLY - resp=$(echo "${REPLY}"|tr 'Y' 'y') - [ "${resp}" = "y" ] && rm "${CACERT}" - fi - - [ ! -f "${CACERT}" ] && ask_ca_password 0 - - [ ! -f "${CACERT}" ] && CA_PASSWORD="${CA_PASSWORD}" "${OPENSSL}" \ - req -new \ - -batch -sha512 \ - -x509 -days 3650 \ - -extensions v3_ca \ - -key "${CAKEY}" \ - -out "${CACERT}" \ - -passin env:CA_PASSWORD \ - -config /dev/stdin <&2 && exit 1 - - url=$(echo "${ocsp_uri}"|cut -d':' -f1) - port=$(echo "${ocsp_uri}"|cut -d':' -f2) - - [ ! -f "${OCSPKEY}" ] && "$OPENSSL" \ - genrsa \ - -out "${OCSPKEY}" \ - 2048 >/dev/null 2>&1 - - "$OPENSSL" req \ - -batch -new \ - -key "${OCSPKEY}" \ - -out "${CSRDIR}/ocsp.csr" \ - -config /dev/stdin < [options] [CommonName] +Warning: [options] always must be before [CommonName] and after -Initialize PKI (create CA key and self-signed cert) : +EOF +show_usage_init +show_usage_create +show_usage_revoke +show_usage_list +show_usage_check +show_usage_ocsp - ${0} init + cat < +Show help : -Create a client cert with key and CSR directly generated on server -(use -p for set a password on client key) : + ${0} --help +EOF +} - ${0} create [-p] +show_usage_init() { + cat < - ${0} create -f + Options + --non-interactive do not prompt the user, and exit if an error occurs -Revoke a client cert with is commonName (CN) : +EOF +} - ${0} revoke +show_usage_create() { + cat < - ${0} list [-a|v|r] + Options + -f, --file, --csr-file create a client certificate from a CSR (doesn't need key) + -p, --password prompt the user for a password to set on the client key + --password-file if provided with a path to a readable file, the first line is read and set as password on the client key + --days specify how many days the certificate should be valid + --end-date specify until which date the certificate should be valid, in "YYYY/MM/DD hh:mm:ss" format, UTC +0 + --non-interactive do not prompt the user, and exit if an error occurs + --replace-existing if the certificate already exists, revoke it before creating a new one +EOF +} + +show_usage_revoke() { + cat < + + Options + --non-interactive do not prompt the user, and exit if an error occurs + +EOF +} + +show_usage_list() { + cat < + + Options + -a, --all list all certificates: valid and revoked ones + -v, --valid list all valid certificates + -r, --revoked list all revoked certificates + +EOF +} + +show_usage_check() { + cat < + +EOF +} + error() { echo "${1}" >&2 exit 1 @@ -135,271 +136,840 @@ warning() { echo "${1}" >&2 } -ask_ca_password() { - [ ! -f "${CAKEY}" ] && error "You must initialize your's PKI with shellpki init !" - attempt=$((${1} + 1)) - [ "${attempt}" -gt 1 ] && warning "Invalid password, retry." - trap 'unset CA_PASSWORD' 0 - stty -echo - printf "Password for CA key : " - read -r CA_PASSWORD - stty echo - printf "\n" - [ "${CA_PASSWORD}" != "" ] || ask_ca_password "${attempt}" - CA_PASSWORD="${CA_PASSWORD}" "${OPENSSL}" rsa \ - -in "${CAKEY}" \ - -passin env:CA_PASSWORD \ - >/dev/null 2>&1 \ - || ask_ca_password "${attempt}" +verify_ca_password() { + "${OPENSSL_BIN}" rsa \ + -in "${CA_KEY}" \ + -passin pass:"${CA_PASSWORD}" \ + >/dev/null 2>&1 +} +get_real_path() { + # --canonicalize is supported on Linux + # -f is supported on Linux and OpenBSD + readlink -f -- "${1}" } -create() { - from_csr=1 - with_pass=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 + revoke --non-interactive "${cn}" + else + error "${cn} already exists, use \`--replace-existing' to force" + fi + else + if [ "${replace_existing}" -eq 1 ]; then + revoke "${cn}" + 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') + + if [ "${resp}" = "y" ]; then + revoke "${cn}" + else + error "Aborted" + fi + fi + fi +} + +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 - -f|--file) - shift - [ ! -f "${1}" ] && error "${1} must be a file" - from_csr=0 - csr_file=$(readlink -f "${1}") - shift;; - -p|--password) - with_pass=0 - shift;; + case ${1:-} in + --non-interactive) + non_interactive=1 + ;; --) + # End of all options. shift - break;; + break + ;; -?*) - warning "unknow option ${1} (ignored)" - shift;; + # ignore unknown options + warning "Warning: unknown option (ignored): \`$1'" + ;; *) - break;; + # Default case: If no more options then break out of the loop. + break + ;; esac + + shift done cn="${1:-}" + if [ -z "${cn}" ]; then + show_usage_init >&2 + exit 1 + fi - if [ "${from_csr}" -eq 0 ]; then - [ "${with_pass}" -eq 0 ] && warning "Warning: -p made nothing with -f" + if [ -f "${CA_KEY}" ]; then + if [ "${non_interactive}" -eq 1 ]; then + error "${CA_KEY} already exists, erase it manually if you want to start over." + else + 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 + fi + + passout_arg="" + if [ -n "${CA_PASSWORD:-}" ]; then + passout_arg="-passout pass:${CA_PASSWORD}" + elif [ "${non_interactive}" -eq 1 ]; then + error "In non-interactive mode, you must pass CA_PASSWORD as environment variable." + fi + + if [ ! -f "${CA_KEY}" ]; then + "${OPENSSL_BIN}" genrsa \ + -out "${CA_KEY}" \ + ${passout_arg} \ + -aes256 \ + "${CA_KEY_LENGTH}" \ + >/dev/null 2>&1 + # shellcheck disable=SC2181 + if [ "$?" -ne 0 ]; then + error "Error generating the CA key" + fi + fi + + if [ -f "${CA_CERT}" ]; then + if [ "${non_interactive}" -eq 1 ]; then + error "${CA_CERT} already exists, erase it manually if you want to start over." + else + 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 + 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 + # shellcheck disable=SC2181 + 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 < /dev/null) + # shellcheck disable=SC2181 + if [ "$?" -ne 0 ]; then + error "Invalid end date format: \`${end_date}' can't be parsed by date(1). Expected format: YYYY/MM/DD [hh[:mm[:ss]]]." + else + crt_expiration_arg="-enddate ${cert_end_date}" + fi + elif [ "${SYSTEM}" = "openbsd" ]; then + cert_end_date=$(TZ=:Zulu date -f "%C%y/%m/%d %H:%M:%S" -j "${end_date}" +"%Y%m%d%H%M%SZ" 2> /dev/null) + # shellcheck disable=SC2181 + if [ "$?" -ne 0 ]; then + error "Invalid end date format: \`${end_date}' can't be parsed by date(1). Expected format: YYYY/MM/DD hh:mm:ss." + else + crt_expiration_arg="-enddate ${cert_end_date}" + fi + else + error "System ${SYSTEM} not supported." + 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}" req \ - -noout -subject \ - -in "${csr_file}" \ - >/dev/null 2>&1 \ - || error "${csr_file} is not a valid CSR !" + "${OPENSSL_BIN}" req \ + -noout \ + -subject \ + -in "${csr_file}" \ + >/dev/null 2>&1 + # shellcheck disable=SC2181 + if [ "$?" -ne 0 ]; then + error "${csr_file} is not a valid CSR !" + fi # check if csr_file contain a CN - "${OPENSSL}" req \ - -noout -subject \ - -in "${csr_file}" \ - | grep -Eo "CN\s*=[^,/]*" \ - >/dev/null 2>&1 \ - || error "${csr_file} don't contain a CommonName !" + "${OPENSSL_BIN}" req \ + -noout \ + -subject \ + -in "${csr_file}" \ + | grep -Eo "CN\s*=[^,/]*" \ + >/dev/null 2>&1 + # shellcheck disable=SC2181 + if [ "$?" -ne 0 ]; then + error "${csr_file} doesn't contain a CommonName !" + fi # get CN from CSR - cn=$("${OPENSSL}" req -noout -subject -in "${csr_file}"|grep -Eo "CN\s*=[^,/]*"|cut -d'=' -f2|xargs) + cn=$("${OPENSSL_BIN}" req -noout -subject -in "${csr_file}" | grep -Eo "CN\s*=[^,/]*" | cut -d'=' -f2 | xargs) - # check if CN already exist - [ -f "${CRTDIR}/${cn}.crt" ] && error "${cn} already used !" + # check if CN already exists + if [ -f "${crt_file}" ]; then + replace_existing_or_abort "${cn}" + fi # ca sign and generate cert - CA_PASSWORD="${CA_PASSWORD}" "${OPENSSL}" ca \ - -config "${CONFFILE}" \ - -in "${csr_file}" \ - -passin env:CA_PASSWORD \ - -out "${CRTDIR}/${cn}.crt" - - echo "The CRT file is available in ${CRTDIR}/${cn}.crt" - else - [ -z "${cn}" ] && usage >&2 && exit 1 - - # check if CN already exist - [ -f "${CRTDIR}/${cn}.crt" ] && error "${cn} already used !" - - # ask for client key passphrase - if [ "${with_pass}" -eq 0 ]; then - trap 'unset PASSWORD' 0 - stty -echo - printf "Password for user key : " - read -r PASSWORD - stty echo - printf "\n" + 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} + # shellcheck disable=SC2181 + 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_create >&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" # ask for CA passphrase ask_ca_password 0 - # generate private key - if [ "${with_pass}" -eq 0 ]; then - PASSWORD="${PASSWORD}" "$OPENSSL" genrsa \ - -aes256 -passout env:PASSWORD \ - -out "${KEYDIR}/${cn}-${TIMESTAMP}.key" \ - 2048 >/dev/null 2>&1 - else - "$OPENSSL" genrsa \ - -out "${KEYDIR}/${cn}-${TIMESTAMP}.key" \ - 2048 >/dev/null 2>&1 + if [ "${ask_pass}" -eq 1 ]; then + ask_user_password fi - if [ "${with_pass}" -eq 0 ]; then - # generate csr req - PASSWORD="${PASSWORD}" "$OPENSSL" req \ - -batch -new \ - -key "${KEYDIR}/${cn}-${TIMESTAMP}.key" \ - -passin env:PASSWORD \ - -out "${CSRDIR}/${cn}-${TIMESTAMP}.csr" \ - -config /dev/stdin </dev/null 2>&1 + # shellcheck disable=SC2181 + if [ "$?" -eq 0 ]; then + echo "The KEY file is available at \`${key_file}'" else - # generate csr req - "$OPENSSL" req \ - -batch -new \ - -key "${KEYDIR}/${cn}-${TIMESTAMP}.key" \ - -out "${CSRDIR}/${cn}-${TIMESTAMP}.csr" \ - -config /dev/stdin </dev/null 2>&1 \ - || rm -f "${CRTDIR}/${cn}.crt" - - [ -f "${CRTDIR}/${cn}.crt" ] || error "Error in CSR creation" - - chmod 640 "${CRTDIR}/${cn}.crt" - - echo "The CRT file is available in ${CRTDIR}/${cn}.crt" - - # generate pkcs12 format - if [ "${with_pass}" -eq 0 ]; then - PASSWORD="${PASSWORD}" "${OPENSSL}" pkcs12 -export -nodes -passin env:PASSWORD -passout env:PASSWORD -inkey "${KEYDIR}/${cn}-${TIMESTAMP}.key" -in "${CRTDIR}/${cn}.crt" -out "${PKCS12DIR}/${cn}-${TIMESTAMP}.p12" - else - "${OPENSSL}" pkcs12 -export -nodes -passout pass: -inkey "${KEYDIR}/${cn}-${TIMESTAMP}.key" -in "${CRTDIR}/${cn}.crt" -out "${PKCS12DIR}/${cn}-${TIMESTAMP}.p12" + "${OPENSSL_BIN}" ca \ + ${batch_arg} \ + -config "${CONF_FILE}" \ + -passin pass:${CA_PASSWORD} \ + -in "${csr_file}" \ + -out "${crt_file}" \ + ${crt_expiration_arg} + # shellcheck disable=SC2181 + if [ "$?" -ne 0 ]; then + error "Error generating the certificate" fi - chmod 640 "${PKCS12DIR}/${cn}-${TIMESTAMP}.p12" - echo "The PKCS12 config file is available in ${PKCS12DIR}/${cn}-${TIMESTAMP}.p12" + # check if CRT is a valid + "${OPENSSL_BIN}" x509 \ + -noout \ + -subject \ + -in "${crt_file}" \ + >/dev/null 2>&1 + # shellcheck disable=SC2181 + 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} + # shellcheck disable=SC2181 + 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 "${CADIR}/ovpn.conf" ]; then - cat "${CADIR}/ovpn.conf" - > "${OVPNDIR}/${cn}-${TIMESTAMP}.ovpn" < "${ovpn_file}" < -$(cat "${CACERT}") +$(cat "${CA_CERT}") -$(cat "${CRTDIR}/${cn}.crt") +$(cat "${crt_file}") -$(cat "${KEYDIR}/${cn}-${TIMESTAMP}.key") +$(cat "${key_file}") EOF - chmod 640 "${OVPNDIR}/${cn}-${TIMESTAMP}.ovpn" - echo "The OpenVPN config file is available in ${OVPNDIR}/${cn}-${TIMESTAMP}.ovpn" + 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 + + # shellcheck disable=SC2086 + chown -R ${PKI_USER}:${PKI_USER} "${COPY_DIR}/" + chmod -R u=rwX,g=rwX,o= "${COPY_DIR}/" fi fi } revoke() { - [ "${1}" = "" ] && usage >&2 && exit 1 + non_interactive=0 - # get CN from param - cn="${1}" + # 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_revoke >&2 + exit 1 + fi + + crt_file="${CRT_DIR}/${cn}.crt" # check if CRT exists - [ ! -f "${CRTDIR}/${cn}.crt" ] && error "Unknow CN : ${cn}" + if [ ! -f "${crt_file}" ]; then + error "Unknow CN: ${cn} (\`${crt_file}' not found)" + fi # check if CRT is a valid - "${OPENSSL}" x509 -noout -subject -in "${CRTDIR}/${cn}.crt" >/dev/null 2>&1 || error "${CRTDIR}/${cn}.crt is not a valid CRT, you msust delete it !" + "${OPENSSL_BIN}" x509 \ + -noout \ + -subject \ + -in "${crt_file}" \ + >/dev/null 2>&1 + # shellcheck disable=SC2181 + 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 ${CRTDIR}/${cn}.crt :" - CA_PASSWORD="${CA_PASSWORD}" "$OPENSSL" ca \ - -config "${CONFFILE}" \ - -passin env:CA_PASSWORD \ - -revoke "${CRTDIR}/${cn}.crt" \ - && rm "${CRTDIR}/${cn}.crt" + echo "Revoke certificate ${crt_file} :" + "${OPENSSL_BIN}" ca \ + -config "${CONF_FILE}" \ + -passin pass:"${CA_PASSWORD}" \ + -revoke "${crt_file}" + # shellcheck disable=SC2181 + if [ "$?" -eq 0 ]; then + rm "${crt_file}" + fi - CA_PASSWORD="${CA_PASSWORD}" "$OPENSSL" ca \ - -config "${CONFFILE}" \ - -passin env:CA_PASSWORD \ - -gencrl -out "${CRL}" + "${OPENSSL_BIN}" ca \ + -config "${CONF_FILE}" \ + -passin pass:"${CA_PASSWORD}" \ + -gencrl \ + -out "${CRL}" } list() { - [ -f "${INDEX}" ] || exit 0 + if [ ! -f "${INDEX_FILE}" ]; then + exit 0 + fi - list_valid=0 - list_revoked=1 + if [ -z "${1:-}" ]; then + show_usage_list >&2 + exit 1 + fi while :; do - case "${1}" in + 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;; + break + ;; esac + shift done - [ "${list_valid}" -eq 0 ] && certs=$(grep "^V" "${INDEX}") + if [ "${list_valid}" -eq 0 ]; then + certs=$(grep "^V" "${INDEX_FILE}") + fi - [ "${list_revoked}" -eq 0 ] && certs=$(grep "^R" "${INDEX}") + if [ "${list_revoked}" -eq 0 ]; then + certs=$(grep "^R" "${INDEX_FILE}") + fi - [ "${list_valid}" -eq 0 ] && [ "${list_revoked}" -eq 0 ] && certs=$(cat "${INDEX}") + 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 + # TODO: permit override with parameters min_day=90 cur_epoch=$(date -u +'%s') - for cert in ${CRTDIR}/*; do - end_date=$(openssl x509 -noout -enddate -in "${cert}" | cut -d'=' -f2) + 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)) + 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" @@ -410,43 +980,72 @@ check() { done } +is_user() { + getent passwd "${1}" >/dev/null +} +is_group() { + getent group "${1}" >/dev/null +} + main() { + # Know what system we are on, because OpenBSD and Linux do not implement date(1) in the same way + SYSTEM=$(uname | tr '[:upper:]' '[:lower:]') + # default config - # TODO : override with /etc/default/shellpki - CONFFILE="/etc/shellpki/openssl.cnf" - PKIUSER="shellpki" - [ "$(uname)" = "OpenBSD" ] && PKIUSER="_shellpki" + # TODO: override with /etc/default/shellpki + CONF_FILE="/etc/shellpki/openssl.cnf" - [ "${USER}" != "root" ] || [ "${USER}" != "${PKIUSER}" ] || error "Please become root before running ${0} !" - - # retrieve CA path from config file - CADIR=$(grep -E "^dir" "${CONFFILE}" | cut -d'=' -f2|xargs -n1) - CAKEY=$(grep -E "^private_key" "${CONFFILE}" | cut -d'=' -f2|xargs -n1|sed "s~\$dir~${CADIR}~") - CACERT=$(grep -E "^certificate" "${CONFFILE}" | cut -d'=' -f2|xargs -n1|sed "s~\$dir~${CADIR}~") - OCSPKEY="${CADIR}/ocsp.key" - OCSPCERT="${CADIR}/ocsp.pem" - CRTDIR=$(grep -E "^certs" "${CONFFILE}" | cut -d'=' -f2|xargs -n1|sed "s~\$dir~${CADIR}~") - TMPDIR=$(grep -E "^new_certs_dir" "${CONFFILE}" | cut -d'=' -f2|xargs -n1|sed "s~\$dir~${CADIR}~") - INDEX=$(grep -E "^database" "${CONFFILE}" | cut -d'=' -f2|xargs -n1|sed "s~\$dir~${CADIR}~") - SERIAL=$(grep -E "^serial" "${CONFFILE}" | cut -d'=' -f2|xargs -n1|sed "s~\$dir~${CADIR}~") - CRL=$(grep -E "^crl" "${CONFFILE}" | cut -d'=' -f2|xargs -n1|sed "s~\$dir~${CADIR}~") - - # directories for clients key, csr, crt - KEYDIR="${CADIR}/private" - CSRDIR="${CADIR}/requests" - PKCS12DIR="${CADIR}/pkcs12" - OVPNDIR="${CADIR}/openvpn" - - OPENSSL=$(command -v openssl) - TIMESTAMP=$(/bin/date +"%s") - - if ! getent passwd "${PKIUSER}" >/dev/null || ! getent group "${PKIUSER}" >/dev/null; then - error "You must create ${PKIUSER} user and group !" + if [ "$(uname)" = "OpenBSD" ]; then + PKI_USER="_shellpki" + else + PKI_USER="shellpki" fi - [ -e "${CONFFILE}" ] || error "${CONFFILE} is missing" + if [ "${USER}" != "root" ] && [ "${USER}" != "${PKI_USER}" ]; then + error "Please become root before running ${0} !" + fi - mkdir -p "${CADIR}" "${CRTDIR}" "${KEYDIR}" "${CSRDIR}" "${PKCS12DIR}" "${OVPNDIR}" "${TMPDIR}" + # 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=$(TZ=:Zulu /bin/date +"%Y%m%d%H%M%SZ") + + 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} @@ -481,17 +1080,27 @@ main() { check "$@" ;; + version|--version) + show_version + exit 0 + ;; + + help|--help) + show_usage + exit 0 + ;; + *) - usage >&2 + show_usage >&2 exit 1 ;; esac # fix right - chown -R "${PKIUSER}":"${PKIUSER}" "${CADIR}" - chmod 750 "${CADIR}" "${CRTDIR}" "${KEYDIR}" "${CSRDIR}" "${PKCS12DIR}" "${OVPNDIR}" "${TMPDIR}" - chmod 600 "${INDEX}"* "${SERIAL}"* "${CAKEY}" "${CRL}" - chmod 640 "${CACERT}" + 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 "$@"