# 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. # # Directory layout: # $SIMPLE_CA_DIR/ca_cert.pem — root CA certificate # $SIMPLE_CA_DIR/ca_key.pem — root CA private key # $SIMPLE_CA_DIR/{name}_cert.pem — certificates issued by the root CA # $SIMPLE_CA_DIR/{issuing_ca}/ca_cert.pem — issuing CA certificate # $SIMPLE_CA_DIR/{issuing_ca}/ca_key.pem — issuing CA private key # $SIMPLE_CA_DIR/{issuing_ca}/{name}_cert.pem — certificates issued by that issuing CA # # Certificates are always written to the directory of the CA that signs them. # Any subdirectory containing ca_cert.pem is treated as an issuing CA. SIMPLE_CA_DIR="${SIMPLE_CA_DIR:-}" _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 } _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 } _is_ip() { [[ "$1" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; } _is_dns() { [[ "$1" =~ ^[a-z0-9-]+(\.[a-z0-9-]+)*$ ]]; } make_ca() { local CA_DAYS=3650 local ISSUING_CA="" local AIA_BASE_URL="" while [[ $# -gt 0 ]]; do case "$1" in --days) if [[ -z "$2" || ! "$2" =~ ^[0-9]+$ ]]; then echo "ERROR: --days requires a positive integer." >&2 return 1 fi CA_DAYS="$2"; shift 2 ;; --issuing-ca) if [[ -z "$2" ]]; then echo "ERROR: --issuing-ca requires a value." >&2 return 1 fi if [[ "$2" == "ca" ]]; then echo "ERROR: --issuing-ca cannot be 'ca'." >&2 return 1 fi ISSUING_CA="$2"; shift 2 ;; --aia-base-url) if [[ -z "$2" ]]; then echo "ERROR: --aia-base-url requires a value." >&2 return 1 fi AIA_BASE_URL="$2"; shift 2 ;; --ca-dir) if [[ -z "$2" ]]; then echo "ERROR: --ca-dir requires a value." >&2 return 1 fi SIMPLE_CA_DIR="$2"; shift 2 ;; *) break ;; esac done local CA_NAME="$1" _require_ca_dir || return 1 if [[ -z "$CA_NAME" ]]; then echo "ERROR: CA name is required." >&2 return 1 fi if [[ -z "$AIA_BASE_URL" && -f "$SIMPLE_CA_DIR/aia_base_url.txt" ]]; then AIA_BASE_URL="$(cat "$SIMPLE_CA_DIR/aia_base_url.txt")" fi 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 if [[ -n "$AIA_BASE_URL" ]]; then echo "$AIA_BASE_URL" > "$SIMPLE_CA_DIR/aia_base_url.txt" fi 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 } make_cert() { local ISSUING_CA="" local CERT_DAYS=365 local CERT_DIR="" while [[ $# -gt 0 ]]; do case "$1" in --ca-dir) if [[ -z "$2" ]]; then echo "ERROR: --ca-dir requires a value." >&2 return 1 fi SIMPLE_CA_DIR="$2"; shift 2 ;; --cert-dir) if [[ -z "$2" ]]; then echo "ERROR: --cert-dir requires a value." >&2 return 1 fi CERT_DIR="$2"; shift 2 ;; --issuing-ca) if [[ -z "$2" ]]; then echo "ERROR: --issuing-ca requires a value." >&2 return 1 fi if [[ "$2" == "ca" ]]; then echo "ERROR: --issuing-ca cannot be 'ca'." >&2 return 1 fi ISSUING_CA="$2"; shift 2 ;; --days) if [[ -z "$2" || ! "$2" =~ ^[0-9]+$ ]]; then echo "ERROR: --days requires a positive integer." >&2 return 1 fi CERT_DAYS="$2"; shift 2 ;; *) break ;; esac done local CERT_SUBJECT_NAME="$1" if [[ $# -gt 0 ]]; then shift fi _require_ca_dir || return 1 if [[ -z "$CERT_SUBJECT_NAME" ]]; then echo "ERROR: Subject name is required." >&2 return 1 fi if ! _is_dns "$CERT_SUBJECT_NAME"; then echo "ERROR: Invalid subject name '$CERT_SUBJECT_NAME'. Must be a valid DNS name." >&2 return 1 fi 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" CERT_DIR="${CERT_DIR:-$SIGNING_DIR}" if [[ ! -f "$SIGNING_CERT" || ! -f "$SIGNING_KEY" ]]; then echo "ERROR: Signing CA certificate and key not found in $SIGNING_DIR." >&2 return 1 fi 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 } make_pfx() { local ISSUING_CA="" local PFX_PASSWORD="" local APPLE_OPENSSL=0 while [[ $# -gt 0 ]]; do case "$1" in --ca-dir) if [[ -z "$2" ]]; then echo "ERROR: --ca-dir requires a value." >&2 return 1 fi SIMPLE_CA_DIR="$2"; shift 2 ;; --issuing-ca) if [[ -z "$2" ]]; then echo "ERROR: --issuing-ca requires a value." >&2 return 1 fi if [[ "$2" == "ca" ]]; then echo "ERROR: --issuing-ca cannot be 'ca'." >&2 return 1 fi ISSUING_CA="$2"; shift 2 ;; --password) if [[ -z "$2" ]]; then echo "ERROR: --password requires a value." >&2 return 1 fi PFX_PASSWORD="$2"; shift 2 ;; --apple-openssl) APPLE_OPENSSL=1; shift ;; *) break ;; esac done local CERT_PATH="$1" _require_ca_dir || return 1 if [[ -z "$CERT_PATH" ]]; then echo "ERROR: Certificate path is required." >&2 return 1 fi 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" if [[ ! -d "$CERT_DIR" ]]; then echo "ERROR: Certificate directory '$CERT_DIR' does not exist." >&2 return 1 fi if [[ ! -f "$CERT_PATH" || ! -f "$KEY_PATH" ]]; then echo "ERROR: Server certificate or key not found." >&2 return 1 fi if [[ ! -f "$SIMPLE_CA_DIR/ca_cert.pem" ]]; then echo "ERROR: Root CA certificate not found in $SIMPLE_CA_DIR." >&2 return 1 fi if [[ -n "$ISSUING_CA" && ! -f "$SIMPLE_CA_DIR/$ISSUING_CA/ca_cert.pem" ]]; then echo "ERROR: Issuing CA certificate not found in $SIMPLE_CA_DIR/$ISSUING_CA." >&2 return 1 fi if [[ -f "$CERT_DIR/${CERT_NAME}.pfx" ]]; then echo "PKCS#12 (PFX) file already exists, aborting generation." >&2 return 1 fi PFX_PASSWORD="${PFX_PASSWORD:-changeit}" local OPENSSL_BIN="openssl" if [[ "$APPLE_OPENSSL" -eq 1 ]]; then OPENSSL_BIN="/usr/bin/openssl" fi 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" if [[ -n "$ISSUING_CA" ]]; then cat "$SIMPLE_CA_DIR/$ISSUING_CA/ca_cert.pem" >> "$CHAIN_FILE" fi if ! "$OPENSSL_BIN" 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." }