shellpki/shellpki
Jérémy Lecour 706608ca4a Use inline pass phrase arguments
It doesn't seem more or less secure to embed the password as an argument
than an environment variable written at the begining of the line.
2020-05-05 10:46:42 +02:00

793 lines
21 KiB
Bash
Executable file

#!/bin/sh
#
# shellpki is a wrapper around OpenSSL to manage a small PKI
#
set -e
VERSION="1.0.0"
show_version() {
cat <<END
shellpki version ${VERSION}
Copyright 2010-2019 Evolix <info@evolix.fr>,
Thomas Martin <tmartin@evolix.fr>,
Gregory Colpart <reg@evolix.fr>,
Romain Dessort <rdessort@evolix.fr>,
Benoit Série <bserie@evolix.fr>,
Victor Laborie <vlaborie@evolix.fr>,
Daniel Jakots <djakots@evolix.fr>,
Patrick Marchand <pmarchand@evolix.fr>,
Jérémy Lecour <jlecour@evolix.fr>
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
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 <<EOF
$(cat "${CONF_FILE}")
commonName_default = ${cn}
EOF
fi
}
ocsp() {
umask 0177
ocsp_uri="${1:-}"
if [ -z "${ocsp_uri}" ]; then
show_usage >&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
fi
"${OPENSSL_BIN}" req \
-batch \
-new \
-key "${OCSP_KEY}" \
-out "${ocsp_csr_file}" \
-config /dev/stdin <<EOF
$(cat "${CONF_FILE}")
commonName_default = ${url}
[ usr_cert ]
authorityInfoAccess = OCSP;URI:http://${ocsp_uri}
EOF
if [ ! -f "${OCSP_CERT}" ]; then
ask_ca_password 0
fi
if [ ! -f "${OCSP_CERT}" ]; then
"${OPENSSL_BIN}" ca \
-extensions v3_ocsp \
-in "${ocsp_csr_file}" \
-out "${OCSP_CERT}" \
-passin pass:${CA_PASSWORD} \
-config "${CONF_FILE}"
fi
exec "${OPENSSL_BIN}" ocsp \
-ignore_err \
-index "${INDEX_FILE}" \
-port "${port}" \
-rsigner "${OCSP_CERT}" \
-rkey "${OCSP_KEY}" \
-CA "${CA_CERT}" \
-text
}
show_usage() {
cat <<EOF
Usage: ${0} <subcommand> [options] [CommonName]
Initialize PKI (create CA key and self-signed cert) :
${0} init <commonName_for_CA>
Run OCSP_D server :
${0} ocsp <ocsp_uri:ocsp_port>
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=<FILE>] <commonName>
Create a client cert from a CSR (doesn't need key) :
${0} create -f <path>
Revoke a client cert with is commonName (CN) :
${0} revoke <commonName>
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_attempt=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_attempt}" ]; then
error "Maximum number of attempts reached (${max_attempt})."
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
}
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 -f -- "${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 -f -- "${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 -f -- "${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 -f -- "${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}
echo "The CRT file is available in ${crt_file}"
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
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
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}
# 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 <<EOF
$(cat "${CONF_FILE}")
commonName_default = ${cn}
EOF
# ca sign and generate cert
"${OPENSSL_BIN}" ca \
-config "${CONF_FILE}" \
-passin pass:${CA_PASSWORD} \
-in "${csr_file}" \
-out "${crt_file}" \
${crt_expiration_arg}
# check if CRT is a valid
"${OPENSSL_BIN}" x509 \
-noout \
-subject \
-in "${crt_file}" \
>/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 [ -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 in ${pkcs12_file}"
# generate openvpn format
if [ -e "${CA_DIR}/ovpn.conf" ]; then
cat "${CA_DIR}/ovpn.conf" - > "${ovpn_file}" <<EOF
<ca>
$(cat "${CA_CERT}")
</ca>
<cert>
$(cat "${crt_file}")
</cert>
<key>
$(cat "${key_file}")
</key>
EOF
chmod 640 "${ovpn_file}"
echo "The OpenVPN config file is available in ${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 "$@"