/home/alex/dev/ca-tools.sh (1)
#!/bin/bash -
# ------------------------------------------------------------------------------
# ca-tools.sh
# ===========
#
# Project Mini CA
# Scope macOS
# Copyright (C) 2024 by RaySoft, Zurich, Switzerland
# License GNU General Public License (GPL) 2.0
# https://www.gnu.org/licenses/gpl2.txt
#
# This script provide the most important functionality to build a CA and sign
# certificates. The whole CA is stored and protected by an encrypted DMG file.
#
# Use the command 'help' to see all commands & subcommands and their description.
#
# WARNING: This script uses functions from the 'nf.sh' and 'os.sh' libraries!
#
# ------------------------------------------------------------------------------
set -o 'noglob' -o 'nounset' -o 'pipefail' # -o 'errexit' -o 'xtrace'
# ------------------------------------------------------------------------------
# Configuration
# -------------
# Path to the global libraries
CA_DE_GLOBAL_LIB="${HOME}/dev/lib"
# Global libraries to be loaded
CA_GLOBAL_LIBS=('nf' 'os')
CA_DE_IMAGES="${HOME}/etc/ca"
CA_FE_CONFIG="${HOME}/etc/ca/openssl.cnf"
CA_CTRY='CH'
CA_LOC='Zurich'
CA_ORG='RaySoft'
CA_USER='alex'
CA_GROUP='users'
# ------------------------------------------------------------------------------
# Binary declaration
# ------------------
CA_X_CAT=('/usr/local/bin/gcat')
CA_X_CP=('/usr/local/bin/gcp')
CA_X_DISKUTIL=('/usr/sbin/diskutil')
CA_X_FIND=('/usr/local/bin/gfind')
CA_X_HDIUTIL=('/usr/bin/hdiutil')
CA_X_MDUTIL=('/usr/bin/mdutil')
CA_X_OPEN=('/usr/bin/open' '-a' 'Finder')
CA_X_OPENSSL=('/usr/local/opt/openssl/bin/openssl')
CA_X_RM=('/usr/local/bin/grm')
CA_X_SUDO=('/usr/bin/sudo')
CA_X_UMASK=('/usr/bin/umask')
# ------------------------------------------------------------------------------
# Public functions
# ----------------
ca::create_image() {
# This public function creates an encrypted DMG image.
#
# Globals:
# CA_DE_IMAGES
#
# Arguments:
# $1: Name of CA
if [[ "$#" -ne 1 ]]; then
ca::_usage "Error calling subcommand 'image create': $*!"
fi
local ca_name="$1"
local ca_image_name="CA-${ca_name}"
local ca_image_file="${CA_DE_IMAGES}/${ca_image_name,,}.dmg"
nf::print_heading '-' 'ba' "Create DMG image: ${ca_image_name}"
if ! ca::_test_overwrite "${ca_image_file}"; then
exit 1
fi
ca::_set_env
ca::_set_passwd 'DMG image'
if ! echo -n "${passwd}" \
| "${CA_X_HDIUTIL[@]}" create -encryption 'AES-256' -fs 'APFS' -size '2m' \
-stdinpass -volname "${ca_image_name}" "${ca_image_file}" 2>'/dev/null'
then
nf::carp -e "Error creating image: ${ca_image_file}!"
fi
ca::_reset_env
ca::attach_image "${ca_name}"
ca::_reset_passwd
}
ca::attach_image() {
# This public function attaches an encrypted DMG image. It sets the ownership
# flag of the image, disables Spotlight and Time Machine and sets the file
# ownership and permissions.
#
# Globals:
# CA_DE_IMAGES
# CA_USER
# CA_GROUP
#
# Arguments:
# $1: Name of CA
if [[ "$#" -ne 1 ]]; then
ca::_usage "Error calling subcommand 'image attach': $*!"
fi
local ca_name="$1"
local ca_image_name="CA-${ca_name}"
local ca_image_file="${CA_DE_IMAGES}/${ca_image_name,,}.dmg"
local ca_path="/Volumes/${ca_image_name}"
if [[ -d "${ca_path}" ]]; then
return 0
fi
nf::print_heading '-' 'ba' "Attach DMG image: ${ca_image_name}"
if [[ ! -f "${ca_image_file}" ]]; then
nf::carp -e "Error finding image file: ${ca_image_file}!"
fi
os::set_permissions "${CA_USER}" "${CA_GROUP}" 0600 0700 "${ca_image_file}"
ca::_set_passwd 'DMG image'
if ! echo -n "${passwd}" \
| "${CA_X_HDIUTIL[@]}" attach -stdinpass "${ca_image_file}" 2>'/dev/null'
then
nf::carp -e 'Error attaching image!'
fi
ca::_reset_passwd
# Enable image's ownership
if ! "${CA_X_SUDO[@]}" "${CA_X_DISKUTIL[@]}" enableOwnership "${ca_path}" \
2>'/dev/null'
then
nf::carp 'Error enabling ownership!'
fi
# Disable Spotlight
if ! "${CA_X_SUDO[@]}" "${CA_X_MDUTIL[@]}" -d -E -i 'off' "${ca_path}" \
2>'/dev/null'
then
nf::carp 'Error disabling Spotlight!'
fi
# Disable Time Machine
if [[ ! -f "${ca_path}/.com.apple.timemachine.donotpresent" ]]; then
ca::_set_env "${ca_path}"
if ! echo -n >"${ca_path}/.com.apple.timemachine.donotpresent" \
2>'/dev/null'
then
nf::carp 'Error disabling Time Machine!'
fi
ca::_reset_env
fi
os::set_permissions "${CA_USER}" "${CA_GROUP}" 0600 0700 "${ca_path}"
if ! "${CA_X_OPEN[@]}" "${ca_path}" >'/dev/null' 2>&1; then
nf::carp "Error opening directory in Finder: ${ca_path}!"
fi
if ! cd "${ca_path}" >'/dev/null' 2>&1; then
nf::carp "Error changing directory: ${ca_path}!"
fi
}
ca::detach_image() {
# This public function detaches a DMG image.
#
# Arguments:
# $1: Name of CA
if [[ "$#" -ne 1 ]]; then
ca::_usage "Error calling subcommand 'image detach': $*!"
fi
local ca_name="$1"
local ca_image_name="CA-${ca_name}"
local ca_path="/Volumes/${ca_image_name}"
nf::print_heading '-' 'ba' "Detach DMG image: ${ca_image_name}"
if [[ ! -d "${ca_path}" ]]; then
nf::carp -e "Error finding CA directory: ${ca_path}!"
fi
if [[ "${ca_path}" == "$(pwd)" ]]; then
if ! cd "${HOME}" >'/dev/null' 2>&1; then
nf::carp "Error changing directory: ${HOME}!"
fi
fi
if ! "${CA_X_HDIUTIL[@]}" detach "${ca_path}" 2>'/dev/null'; then
nf::carp -e 'Error detaching image!'
fi
}
ca::create_ca() {
# This public function creates a CA based of a private key and a self signed
# certificate.
#
# Globals:
# CA_DE_IMAGES
# CA_FE_CONFIG
# CA_CTRY
# CA_LOC
# CA_ORG
#
# Arguments:
# $1: Name of CA
if [[ "$#" -ne 1 ]]; then
ca::_usage "Error calling subcommand 'ca create': $*!"
fi
local ca_name="$1"
local ca_path="/Volumes/CA-${ca_name}"
local cnf_file="${ca_path}/openssl.cnf"
local key_file="${ca_path}/private/ca.key.pem"
local cert_file="${ca_path}/certs/ca.cert.pem"
local cert_subject="/C=${CA_CTRY}/L=${CA_LOC}/O=${CA_ORG}"
cert_subject+="/CN=${ca_name} Root CA"
nf::print_heading '-' 'ba' "Create CA: ${ca_name}"
ca::attach_image "${ca_name}"
if [[ ! -f "${cnf_file}" ]]; then
if ! "${CA_X_CP[@]}" "${CA_FE_CONFIG}" "${cnf_file}" 2>'/dev/null'; then
nf::carp -e "Error copying file: ${CA_FE_CONFIG}!"
fi
fi
if ! ca::_test_overwrite "${key_file}" "${cert_file}"; then
exit 1
fi
ca::_set_env "${ca_path}"
if ! os::mkdir_ifn "${ca_path}/"{'certs','crl','newcerts','private','status'}
then
exit 1
fi
if [[ ! -f "${ca_path}/status/index" ]]; then
if ! echo -n >"${ca_path}/status/index" 2>'/dev/null'; then
nf::carp -e "Error creating file: ${ca_path}/status/index!"
fi
fi
for item in 'crl/crlnumber' 'status/serial'; do
if [[ ! -f "${ca_path}/${item}" ]]; then
if ! echo 1000 >"${ca_path}/${item}" 2>'/dev/null'; then
nf::carp -e "Error creating file: ${ca_path}/${item}!"
fi
fi
done
ca::_set_passwd 'CA private key'
if ! "${CA_X_OPENSSL[@]}" genpkey \
-algorithm 'RSA' -pkeyopt 'rsa_keygen_bits:4096' -aes-256-cbc \
-out "${key_file}" -pass 'env:passwd' 2>'/dev/null'
then
nf::carp -e "Error creating CA private key: ${key_file}!"
fi
if ! "${CA_X_OPENSSL[@]}" req -new -x509 -config "${cnf_file}" \
-extensions 'v3_ca' -subj "${cert_subject}" -days 3650 \
-key "${key_file}" -passin 'env:passwd' -out "${cert_file}" 2>'/dev/null'
then
nf::carp -e "Error creating CA certificate: ${cert_file}!"
fi
ca::_reset_env
"${CA_X_OPENSSL[@]}" x509 -text -in "${cert_file}" -noout
ca::create_crl "${ca_name}"
ca::_reset_passwd
}
ca::create_server_csr() {
# This public function creates a private key and a Certificate Signing Request
# (CSR) which can be used for a server (e.g. HTTPS).
#
# Arguments:
# $1: Name of CA
# $2: FQHN of machine
if [[ "$#" -ne 2 ]]; then
ca::_usage "Error calling subcommand 'server_csr create': $*!"
fi
local ca_name="$1"; shift
local srv_fqdn="$1"
local ca_path="/Volumes/CA-${ca_name}"
local cnf_file="${ca_path}/openssl.cnf"
local key_file="${ca_path}/private/${srv_fqdn}.key.pem"
local csr_file="${ca_path}/certs/${srv_fqdn}.csr.pem"
nf::print_heading '-' 'ba' "Creating server CSR: ${srv_fqdn}"
ca::attach_image "${ca_name}"
if [[ ! -f "${cnf_file}" ]]; then
nf::carp -e "Error finding OpenSSL configuration file: ${cnf_file}!"
fi
if ! ca::_test_overwrite "${key_file}" "${csr_file}"; then
exit 1
fi
ca::_set_env "${ca_path}"
ca::_set_passwd 'server private key'
if ! "${CA_X_OPENSSL[@]}" genpkey \
-algorithm 'RSA' -pkeyopt 'rsa_keygen_bits:2048' -aes-256-cbc \
-out "${key_file}" -pass 'env:passwd' 2>'/dev/null'
then
nf::carp -e "Error creating server private key: ${key_file}!"
fi
if ! "${CA_X_OPENSSL[@]}" req -new -config "${cnf_file}" \
-subj "/CN=${srv_fqdn}" -key "${key_file}" -passin 'env:passwd' \
-out "${csr_file}" 2>'/dev/null'
then
nf::carp -e "Error creating server CSR: ${csr_file}!"
fi
ca::_reset_passwd
ca::_reset_env
"${CA_X_OPENSSL[@]}" req -text -in "${csr_file}" -noout
}
ca::sign_server_csr() {
# This public function signs a Certificate Signing Request (CSR) using the
# CA's private key.
#
# Globals:
# CA_CTRY
# CA_LOC
# CA_ORG
#
# Arguments:
# $1: Name of CA
# $2: FQHN of machine
# $@: SANs of machine (optional)
if [[ "$#" -lt 2 ]]; then
ca::_usage "Error calling subcommand 'server_csr sign': $*!"
fi
local ca_name="$1"; shift
local srv_fqdn="$1"; shift
local sans_array=("$@")
local sans_string="DNS:${srv_fqdn}"
local ca_path="/Volumes/CA-${ca_name}"
local cnf_file="${ca_path}/openssl.cnf"
local csr_file="${ca_path}/certs/${srv_fqdn}.csr.pem"
local cert_file="${ca_path}/certs/${srv_fqdn}.cert.pem"
local cert_subject="/C=${CA_CTRY}/L=${CA_LOC}/O=${CA_ORG}"
cert_subject+="/CN=${srv_fqdn}"
nf::print_heading '-' 'ba' "Signing server CSR: ${srv_fqdn}"
ca::attach_image "${ca_name}"
if [[ ! -f "${cnf_file}" ]]; then
nf::carp -e "Error finding OpenSSL configuration file: ${cnf_file}!"
fi
if [[ ! -f "${csr_file}" ]]; then
nf::carp -e "Error finding CSR file: ${csr_file}!"
fi
if ! ca::_test_overwrite "${cert_file}"; then
exit 1
fi
for item in "${sans_array[@]}"; do
if [[ ! "${item}" =~ ^(DNS|IP): ]]; then
nf::carp -e "Error parsing SAN: ${item}!"
fi
sans_string+=",${item}"
done
ca::_set_env "${ca_path}" "${sans_string}"
ca::_set_passwd 'CA private key'
if ! "${CA_X_OPENSSL[@]}" ca -batch -notext -config "${cnf_file}" \
-extensions 'server_cert' -subj "${cert_subject}" -rand_serial \
-passin 'env:passwd' -in "${csr_file}" -out "${cert_file}" 2>'/dev/null'
then
nf::carp -e 'Error signing CSR!'
fi
ca::_reset_passwd
ca::_reset_env
if [[ -f "${csr_file}" ]]; then
if ! "${CA_X_RM[@]}" "${csr_file}" 2>'/dev/null'; then
nf::carp 'Error deleting CSR!'
fi
fi
"${CA_X_OPENSSL[@]}" x509 -text -in "${cert_file}" -noout
}
ca::revoke_cert() {
# This public function revokes a certificate.
#
# Arguments:
# $1: Name of CA
# $2: Name of certificate
if [[ "$#" -ne 2 ]]; then
ca::_usage "Error calling subcommand 'cert revoke': $*!"
fi
local ca_name="$1"; shift
local cert_name="$1"
local ca_path="/Volumes/CA-${ca_name}"
local cnf_file="${ca_path}/openssl.cnf"
local cert_file="${ca_path}/certs/${cert_name}.cert.pem"
nf::print_heading '-' 'ba' "Revoke the certificate: ${cert_name}"
ca::attach_image "${ca_name}"
if [[ ! -f "${cnf_file}" ]]; then
nf::carp -e "Error finding OpenSSL configuration file: ${cnf_file}!"
fi
read -n 1 -p 'Press Ctrl-C to exit or any key to proceed.' -r -s
echo; echo
ca::_set_env "${ca_path}"
ca::_set_passwd 'CA private key'
if ! "${CA_X_OPENSSL[@]}" ca -revoke "${cert_file}" -notext \
-config "${cnf_file}" -passin 'env:passwd' 2>'/dev/null'
then
nf::carp -e 'Error revoking certificate!'
fi
ca::_reset_env
if ! "${CA_X_FIND[@]}" "${ca_path}/"{'certs','newcerts','private'} \
-name "*${cert_name}.*.pem" -delete -printf "Delete %p\\n" \
2>'/dev/null'
then
nf::carp "Error deleting revoked certificate: ${cert_file}!"
fi
ca::create_crl "${ca_name}"
ca::_reset_passwd
}
ca::create_crl() {
# This public function creates a Certificate Revocation List (CRL).
#
# Arguments:
# $1: Name of CA
if [[ "$#" -ne 1 ]]; then
ca::_usage "Error calling subcommand 'crl create': $*!"
fi
local ca_name="$1"
local ca_path="/Volumes/CA-${ca_name}"
local cnf_file="${ca_path}/openssl.cnf"
local crl_file="${ca_path}/crl/ca.crl.pem"
nf::print_heading '-' 'ba' "Creating CRL: ${ca_name}"
ca::attach_image "${ca_name}"
if [[ ! -f "${cnf_file}" ]]; then
nf::carp -e "Error finding OpenSSL configuration file: ${cnf_file}!"
fi
if [[ -f "${crl_file}" ]]; then
if ! "${CA_X_RM[@]}" "${crl_file}" 2>'/dev/null'; then
nf::carp "Error deleting old CRL: ${crl_file}!"
fi
fi
ca::_set_env "${ca_path}"
ca::_set_passwd 'CA private key'
if ! "${CA_X_OPENSSL[@]}" ca -gencrl -notext -config "${cnf_file}" \
-crldays 365 -passin 'env:passwd' -out "${crl_file}" 2>'/dev/null'
then
nf::carp -e 'Error creating CRL!'
fi
ca::_reset_passwd
ca::_reset_env
"${CA_X_OPENSSL[@]}" crl -text -in "${crl_file}" -noout
}
# ------------------------------------------------------------------------------
# Private functions
# -----------------
ca::_usage() {
# This private function prints the 'usage' message and ends the script with a
# error.
#
# Arguments:
# $@: Problem message
if [[ "$#" -eq 0 ]]; then
nf::carp -e 'Error calling function!'
fi
local exe="${BASH_SOURCE[0]##*/}"
"${CA_X_CAT[@]}" <<-EOF
$*
Usage:
${exe} image ( create | attach | detach ) <ca>
${exe} ca create <ca>
${exe} server_csr ( create <ca> <fqdn> | sign <ca> <fqdn> [DNS|IP:<san> ...] )
${exe} cert revoke <ca> <fqdn>
${exe} crl create <ca>
${exe} help
EOF
exit 1
} 1>&2
ca::_help() {
# This private function prints the 'help' message.
if [[ "$#" -ne 0 ]]; then
nf::carp -e "Error calling function: $*!"
fi
local exe="${BASH_SOURCE[0]##*/}"
"${CA_X_CAT[@]}" <<-EOF
${exe} image ( create | attach | detach ) <ca>
create Creates an encrypted DMG image.
attach Attaches the DMG image to the file system.
detach Detaches the DMG image from the file system.
${exe} ca create <ca>
create Creates a CA private key and a certificate.
${exe} server_csr ( create <ca> <fqdn> | sign <ca> <fqdn> [DNS|IP:<san> ...] )
create Creates a private key and a Certificate Signing Request (CSR)
for a server.
sign Signs a Certificate Signing Request (CSR) for a server.
${exe} cert revoke <ca> <fqdn>
revoke Revokes a certifikate.
${exe} crl create <ca>
create Creates a Certificate Revocation List (CRL).
${exe} help
Shows this help message.
EOF
}
ca::_set_env() {
# This private function sets the environment.
#
# Globals:
# CA_CTRY
# CA_LOC
# CA_ORG
#
# Arguments:
# $1: Path to CA (optional)
# $2: SANs string (optional)
declare -g -x CA_{CTRY,LOC,ORG} ca_path="${1-}" cert_sans="${2-}" \
sys_umask="$("${CA_X_UMASK[@]}")"
"${CA_X_UMASK[@]}" 0077
}
ca::_reset_env() {
# This private function resets the environment.
#
# Globals:
# CA_CTRY
# CA_LOC
# CA_ORG
"${CA_X_UMASK[@]}" "${sys_umask}"
declare +g +x CA_{CTRY,LOC,ORG} ca_path cert_sans
unset -v sys_umask
}
ca::_set_passwd() {
# This private function asks for a password and sets the environment.
#
# Arguments:
# $1: Purpose
if [[ "$#" -ne 1 ]]; then
nf::carp -e "Error calling function: $*!"
fi
if [[ -z "${passwd-}" ]]; then
read -p "Enter the '$1' password: " -r -s passwd
echo; echo
declare -g -x passwd
fi
}
ca::_reset_passwd() {
# This private function resets the environment.
unset -v passwd
}
ca::_test_overwrite() {
# This private function tests if a file already exists and prints a
# message.
for item in "$@"; do
if [[ -f "${item}" ]]; then
nf::carp "File must not be overwritten: ${item}!"
return 1
fi
done
}
ca::_trap() {
# This private function defines the commands which are executed when the
# corresponding handler is activated.
ca::_reset_env
ca::_reset_passwd
echo
exit 1
}
# ------------------------------------------------------------------------------
# Main
# ----
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
# Test if library path is available
if [[ ! -d "${CA_DE_GLOBAL_LIB}" ]]; then
echo "Error finding directory: ${CA_DE_GLOBAL_LIB}"
exit 1
fi
# Load libraries
for lib in "${CA_GLOBAL_LIBS[@]}"; do
lib="${CA_DE_GLOBAL_LIB}/${lib}.sh"
if [[ ! -f "${lib}" ]]; then
echo "Error finding library: ${lib}!"
exit 1
fi
if ! source "${lib}"; then
exit 1
fi
done
# Test the defined variables
if ! os::test_env 'CA'; then
exit 1
fi
if [[ ! "$("${CA_X_OPENSSL[@]}" version 2>'/dev/null')" =~ ^OpenSSL ]]; then
nf::carp -e 'Error finding OpenSSL!'
fi
if ! trap 'ca::_trap' SIG{INT,QUIT,TERM} >'/dev/null' 2>&1; then
nf::carp -e 'Error setting up trap!'
fi
nf::print_heading '=' 'b' 'Mini CA Tools'
command="${1-}"; shift
subcommand="${1-}"; shift
case "${command}" in
'') ca::_usage 'Error finding command!' ;;
'image')
case "${subcommand}" in
'') ca::_usage "Error finding '${command}' subcommand!" ;;
'create') ca::create_image "$@" ;;
'attach') ca::attach_image "$@" ;;
'detach') ca::detach_image "$@" ;;
*) ca::_usage "Error validating '${command}' subcommand: ${subcommand}!" ;;
esac ;;
'ca')
case "${subcommand}" in
'') ca::_usage "Error finding '${command}' subcommand!" ;;
'create') ca::create_ca "$@" ;;
*) ca::_usage "Error validating '${command}' subcommand: ${subcommand}!" ;;
esac ;;
'server_csr')
case "${subcommand}" in
'') ca::_usage "Error finding '${command}' subcommand!" ;;
'create') ca::create_server_csr "$@" ;;
'sign') ca::sign_server_csr "$@" ;;
*) ca::_usage "Error validating '${command}' subcommand: ${subcommand}!" ;;
esac ;;
'cert')
case "${subcommand}" in
'') ca::_usage "Error finding '${command}' subcommand!" ;;
'revoke') ca::revoke_cert "$@" ;;
*) ca::_usage "Error validating '${command}' subcommand: ${subcommand}!" ;;
esac ;;
'crl')
case "${subcommand}" in
'') ca::_usage "Error finding '${command}' subcommand!" ;;
'create') ca::create_crl "$@" ;;
*) ca::_usage "Error validating '${command}' subcommand: ${subcommand}!" ;;
esac ;;
'help') ca::_help "$@" ;;
*) ca::_usage "Error validating command: ${command}!" ;;
esac
exit 0
fi
# ------------------------------------------------------------------------------
Usage
NOTE:
This script uses functions from the libraries ~/dev/lib/nf.sh & ~/dev/lib/os.sh.
- Provide the configuration template
Copy the openssl.cnf template to the path which is defined in the variable CA_FE_CONFIG.
- Show help
~/dev/ca-tools.sh help
- Create an image and a CA certificate
ca_name='RaySoft'
~/dev/ca-tools.sh image create "${ca_name}"
~/dev/ca-tools.sh ca create "${ca_name}"
- Create a CSR and sign it
ca_name='RaySoft'
srv_name='neon.raysoft.loc'
san_names=('DNS:mx1.raysoft.loc' 'DNS:ns1.raysoft.loc' 'DNS:ntp1.raysoft.loc')
~/dev/ca-tools.sh server_csr create "${ca_name}" "${srv_name}"
~/dev/ca-tools.sh server_csr sign "${ca_name}" "${srv_name}" "${san_names[@]}"
- Detach image
ca_name='RaySoft'
~/dev/ca-tools.sh image detach "${ca_name}"