# MIT License # # Copyright (c) 2026 Sławomir Koszewski # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # These functions require OpenSSL to be installed on the system. # # SIMPLE_CA_DIR points to the root CA directory. It can be set in the environment # before sourcing this file, or overridden per-call with --ca-dir. Once set by any # call, subsequent calls in the same session inherit it. # # Issuing CAs live in subdirectories of SIMPLE_CA_DIR: # $SIMPLE_CA_DIR/ca_cert.pem — root CA certificate # $SIMPLE_CA_DIR/ca_key.pem — root CA private key # $SIMPLE_CA_DIR/{issuing_ca}/ca_cert.pem — issuing CA certificate # $SIMPLE_CA_DIR/{issuing_ca}/ca_key.pem — issuing CA private key # # Any subdirectory containing ca_cert.pem is treated as an issuing CA. SIMPLE_CA_DIR="${SIMPLE_CA_DIR:-}" function _rebuild_ca_bundle() { local BUNDLE="$SIMPLE_CA_DIR/ca_bundle.pem" cat "$SIMPLE_CA_DIR/ca_cert.pem" > "$BUNDLE" for issuing_ca_dir in $SIMPLE_CA_DIR/*; do if [[ -d "$issuing_ca_dir" && -f "$issuing_ca_dir/ca_cert.pem" ]]; then cat "$issuing_ca_dir/ca_cert.pem" >> "$BUNDLE" fi done } function _require_ca_dir() { SIMPLE_CA_DIR="${SIMPLE_CA_DIR:-$(pwd)}" if [[ ! -d "$SIMPLE_CA_DIR" ]]; then echo "ERROR: CA directory '$SIMPLE_CA_DIR' does not exist." >&2 return 1 fi } function _is_ip() { [[ "$1" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; } function _is_dns() { [[ "$1" =~ ^[a-z0-9-]+(\.[a-z0-9-]+)*$ ]]; } function make_ca() { local CA_DAYS=3650 local ISSUING_CA="" local AIA_BASE_URL="" while [[ $# -gt 0 ]]; do case "$1" in --days) [[ -z "$2" || ! "$2" =~ ^[0-9]+$ ]] && { echo "ERROR: --days requires a positive integer." >&2; return 1; } CA_DAYS="$2"; shift 2 ;; --issuing-ca) [[ -z "$2" ]] && { echo "ERROR: --issuing-ca requires a value." >&2; return 1; } [[ "$2" == "ca" ]] && { echo "ERROR: --issuing-ca cannot be 'ca'." >&2; return 1; } ISSUING_CA="$2"; shift 2 ;; --aia-base-url) [[ -z "$2" ]] && { echo "ERROR: --aia-base-url requires a value." >&2; return 1; } AIA_BASE_URL="$2"; shift 2 ;; --ca-dir) [[ -z "$2" ]] && { echo "ERROR: --ca-dir requires a value." >&2; return 1; } SIMPLE_CA_DIR="$2"; shift 2 ;; *) break ;; esac done local CA_NAME="$1" _require_ca_dir || return 1 [[ -z "$CA_NAME" ]] && { echo "ERROR: CA name is required." >&2; return 1; } [[ -z "$AIA_BASE_URL" && -f "$SIMPLE_CA_DIR/aia_base_url.txt" ]] \ && AIA_BASE_URL="$(cat "$SIMPLE_CA_DIR/aia_base_url.txt")" local ROOT_CA_CERT="$SIMPLE_CA_DIR/ca_cert.pem" local ROOT_CA_KEY="$SIMPLE_CA_DIR/ca_key.pem" # Create root CA if [[ -z "$ISSUING_CA" ]]; then if [[ -f "$ROOT_CA_CERT" && -f "$ROOT_CA_KEY" ]]; then echo "Root CA already exists in $SIMPLE_CA_DIR, skipping." return 0 fi echo "Generating root CA certificate '$CA_NAME' and key..." if ! openssl req \ -x509 \ -newkey rsa:4096 \ -keyout "$ROOT_CA_KEY" \ -out "$ROOT_CA_CERT" \ -days "$CA_DAYS" \ -noenc \ -subj "/CN=${CA_NAME}" \ -text \ -addext "basicConstraints=critical,CA:TRUE,pathlen:1" \ -addext "keyUsage=critical,keyCertSign,cRLSign"; then echo "ERROR: Failed to generate root CA certificate and key." >&2 return 1 fi _rebuild_ca_bundle [[ -n "$AIA_BASE_URL" ]] && echo "$AIA_BASE_URL" > "$SIMPLE_CA_DIR/aia_base_url.txt" return 0 fi # Create issuing CA if [[ ! -f "$ROOT_CA_CERT" || ! -f "$ROOT_CA_KEY" ]]; then echo "ERROR: Cannot create issuing CA '$CA_NAME' without an existing root CA. Create the root CA first." >&2 return 1 fi local ISSUING_DIR="$SIMPLE_CA_DIR/$ISSUING_CA" local ISSUING_CERT="$ISSUING_DIR/ca_cert.pem" local ISSUING_KEY="$ISSUING_DIR/ca_key.pem" if [[ -f "$ISSUING_CERT" && -f "$ISSUING_KEY" ]]; then echo "Issuing CA '$ISSUING_CA' already exists in $ISSUING_DIR, skipping." return 0 fi mkdir -p "$ISSUING_DIR" echo "Generating issuing CA certificate '$CA_NAME' and key..." if ! openssl req \ -newkey rsa:4096 \ -keyout "$ISSUING_KEY" \ -noenc \ -subj "/CN=${CA_NAME}" \ -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ -addext "keyUsage=critical,keyCertSign,cRLSign" \ ${AIA_BASE_URL:+-addext "authorityInfoAccess=caIssuers;URI:${AIA_BASE_URL}/ca_cert.crt"} \ | openssl x509 \ -req \ -CA "$ROOT_CA_CERT" \ -CAkey "$ROOT_CA_KEY" \ -copy_extensions copyall \ -days "$CA_DAYS" \ -text \ -out "$ISSUING_CERT"; then echo "ERROR: Failed to generate issuing CA certificate and key." >&2 return 1 fi _rebuild_ca_bundle return 0 } function make_cert() { local ISSUING_CA="" local CERT_DAYS=365 local CERT_DIR="" while [[ $# -gt 0 ]]; do case "$1" in --ca-dir) [[ -z "$2" ]] && { echo "ERROR: --ca-dir requires a value." >&2; return 1; } SIMPLE_CA_DIR="$2"; shift 2 ;; --cert-dir) [[ -z "$2" ]] && { echo "ERROR: --cert-dir requires a value." >&2; return 1; } CERT_DIR="$2"; shift 2 ;; --issuing-ca) [[ -z "$2" ]] && { echo "ERROR: --issuing-ca requires a value." >&2; return 1; } [[ "$2" == "ca" ]] && { echo "ERROR: --issuing-ca cannot be 'ca'." >&2; return 1; } ISSUING_CA="$2"; shift 2 ;; --days) [[ -z "$2" || ! "$2" =~ ^[0-9]+$ ]] && { echo "ERROR: --days requires a positive integer." >&2; return 1; } CERT_DAYS="$2"; shift 2 ;; *) break ;; esac done local CERT_SUBJECT_NAME="$1" [[ $# -gt 0 ]] && shift _require_ca_dir || return 1 [[ -z "$CERT_DIR" ]] && { echo "ERROR: --cert-dir is required." >&2; return 1; } [[ ! -d "$CERT_DIR" ]] && { echo "ERROR: Certificate directory '$CERT_DIR' does not exist." >&2; return 1; } [[ -z "$CERT_SUBJECT_NAME" ]] && { echo "ERROR: Subject name is required." >&2; return 1; } _is_dns "$CERT_SUBJECT_NAME" || { echo "ERROR: Invalid subject name '$CERT_SUBJECT_NAME'. Must be a valid DNS name." >&2; return 1; } local SIGNING_DIR="$SIMPLE_CA_DIR${ISSUING_CA:+/$ISSUING_CA}" local SIGNING_CERT="$SIGNING_DIR/ca_cert.pem" local SIGNING_KEY="$SIGNING_DIR/ca_key.pem" [[ ! -f "$SIGNING_CERT" || ! -f "$SIGNING_KEY" ]] \ && { echo "ERROR: Signing CA certificate and key not found in $SIGNING_DIR." >&2; return 1; } local AIA_URL="" if [[ -f "$SIMPLE_CA_DIR/aia_base_url.txt" ]]; then local BASE_URL BASE_URL="$(cat "$SIMPLE_CA_DIR/aia_base_url.txt")" AIA_URL="${BASE_URL}${ISSUING_CA:+/$ISSUING_CA}/ca_cert.crt" fi local CERT_NAME="${CERT_SUBJECT_NAME%%.*}" local SANS=("DNS:${CERT_SUBJECT_NAME}") while [[ $# -gt 0 ]]; do if _is_ip "$1"; then SANS+=("IP:$1") elif _is_dns "$1"; then SANS+=("DNS:$1") else { echo "ERROR: Invalid SAN entry '$1'." >&2; return 1; } fi shift done echo "Generating server certificate for '$CERT_SUBJECT_NAME' with SANs:" for san in "${SANS[@]}"; do echo " - $san"; done if [[ -f "$CERT_DIR/${CERT_NAME}_cert.pem" && -f "$CERT_DIR/${CERT_NAME}_key.pem" ]]; then echo "Certificate already exists in $CERT_DIR, skipping." return 0 fi local SANS_EXT="subjectAltName=$(IFS=,; echo "${SANS[*]}")" echo "Generating server certificate and key..." if ! openssl req \ -newkey rsa:4096 \ -keyout "$CERT_DIR/${CERT_NAME}_key.pem" \ -noenc \ -subj "/CN=${CERT_SUBJECT_NAME}" \ -addext "basicConstraints=critical,CA:FALSE" \ -addext "keyUsage=critical,digitalSignature,keyEncipherment" \ -addext "extendedKeyUsage=serverAuth,clientAuth" \ -addext "$SANS_EXT" \ ${AIA_URL:+-addext "authorityInfoAccess=caIssuers;URI:${AIA_URL}"} \ | openssl x509 \ -req \ -CA "$SIGNING_CERT" \ -CAkey "$SIGNING_KEY" \ -copy_extensions copyall \ -days "$CERT_DAYS" \ -text \ -out "$CERT_DIR/${CERT_NAME}_cert.pem"; then echo "ERROR: Failed to generate server certificate and key." >&2 return 1 fi } function make_pfx() { local ISSUING_CA="" local PFX_PASSWORD="" while [[ $# -gt 0 ]]; do case "$1" in --ca-dir) [[ -z "$2" ]] && { echo "ERROR: --ca-dir requires a value." >&2; return 1; } SIMPLE_CA_DIR="$2"; shift 2 ;; --issuing-ca) [[ -z "$2" ]] && { echo "ERROR: --issuing-ca requires a value." >&2; return 1; } [[ "$2" == "ca" ]] && { echo "ERROR: --issuing-ca cannot be 'ca'." >&2; return 1; } ISSUING_CA="$2"; shift 2 ;; --password) [[ -z "$2" ]] && { echo "ERROR: --password requires a value." >&2; return 1; } PFX_PASSWORD="$2"; shift 2 ;; *) break ;; esac done local CERT_PATH="$1" _require_ca_dir || return 1 [[ -z "$CERT_PATH" ]] && { echo "ERROR: Certificate path is required." >&2; return 1; } local CERT_DIR CERT_NAME KEY_PATH CERT_DIR="$(dirname "$CERT_PATH")" CERT_NAME="$(basename "$CERT_PATH" _cert.pem)" KEY_PATH="$CERT_DIR/${CERT_NAME}_key.pem" [[ ! -d "$CERT_DIR" ]] && { echo "ERROR: Certificate directory '$CERT_DIR' does not exist." >&2; return 1; } [[ ! -f "$CERT_PATH" || ! -f "$KEY_PATH" ]] && { echo "ERROR: Server certificate or key not found." >&2; return 1; } [[ ! -f "$SIMPLE_CA_DIR/ca_cert.pem" ]] && { echo "ERROR: Root CA certificate not found in $SIMPLE_CA_DIR." >&2; return 1; } [[ -n "$ISSUING_CA" && ! -f "$SIMPLE_CA_DIR/$ISSUING_CA/ca_cert.pem" ]] \ && { echo "ERROR: Issuing CA certificate not found in $SIMPLE_CA_DIR/$ISSUING_CA." >&2; return 1; } [[ -f "$CERT_DIR/${CERT_NAME}.pfx" ]] && { echo "PKCS#12 (PFX) file already exists, aborting generation." >&2; return 1; } PFX_PASSWORD="${PFX_PASSWORD:-changeit}" echo -n "Generating PKCS#12 (PFX) file..." local CHAIN_FILE CHAIN_FILE=$(mktemp) trap "rm -f '$CHAIN_FILE'" EXIT QUIT KILL INT HUP cat "$SIMPLE_CA_DIR/ca_cert.pem" > "$CHAIN_FILE" [[ -n "$ISSUING_CA" ]] && cat "$SIMPLE_CA_DIR/$ISSUING_CA/ca_cert.pem" >> "$CHAIN_FILE" if ! openssl pkcs12 \ -export \ -out "$CERT_DIR/${CERT_NAME}.pfx" \ -inkey "$KEY_PATH" \ -in "$CERT_PATH" \ -certfile "$CHAIN_FILE" \ -password "pass:${PFX_PASSWORD}"; then echo "ERROR: Failed to generate PKCS#12 (PFX) file." >&2 return 1 fi echo "done." }