# 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 Bash and OpenSSL to be installed on the system. function make_hash_link() { local CERT_PATH="$1" if [[ ! -f "$CERT_PATH" ]]; then echo "ERROR: Certificate file $CERT_PATH does not exist." >&2 return 1 fi local CERT_DIR="$(dirname "$CERT_PATH")" local HASH="$(openssl x509 -in "$CERT_PATH" -noout -hash 2>/dev/null)" if [[ -z "$HASH" ]]; then echo "ERROR: Failed to calculate hash for certificate $CERT_PATH." >&2 return 1 fi ln -sf "$(basename "$CERT_PATH")" "$CERT_DIR/${HASH}.0" } function make_ca() { local CA_DAYS=3650 # Default validity period for CA certificates # CA defaults to the main CA if not specified, but can be overridden with --issuing-ca local CA_FILE_PREFIX="ca" local PATHLEN=0 while [[ $# -gt 0 ]]; do case "$1" in --days) if [[ -z "$2" || ! "$2" =~ ^[0-9]+$ ]]; then echo "ERROR: Invalid value for --days. Must be a positive integer." >&2 return 1 fi CA_DAYS="$2" shift 2 ;; --issuing-ca) if [[ -z "$2" ]]; then echo "ERROR: Missing value for --issuing-ca." >&2 return 1 fi if [[ "$2" == "ca" ]]; then echo "ERROR: --issuing-ca cannot be 'ca' as it is reserved for the root CA." >&2 return 1 fi CA_FILE_PREFIX="$2" shift 2 ;; --path-len) if [[ -z "$2" || ! "$2" =~ ^[0-9]+$ ]]; then echo "ERROR: Invalid value for --path-len. Must be a non-negative integer." >&2 return 1 fi PATHLEN="$2" shift 2 ;; *) break ;; esac done # Use the provided directory argument local CA_DIR="$1" local CA_NAME="$2" shift 2 if [[ -z "$CA_DIR" || -z "$CA_NAME" || ! -d "$CA_DIR" ]]; then echo "ERROR: Certificate directory $CA_DIR does not exist." return 1 fi local ROOT_CA_CERT="ca_cert.pem" local ROOT_CA_KEY="ca_key.pem" local CA_CERT="${CA_FILE_PREFIX}_cert.pem" local CA_KEY="${CA_FILE_PREFIX}_key.pem" # Generate CA certificate and key if they don't exist if [[ ! -f "$CA_DIR/$ROOT_CA_CERT" || ! -f "$CA_DIR/$ROOT_CA_KEY" ]]; then # Check, if the user requested a non-root CA without an existing root CA, which is not possible. if [[ "$ROOT_CA_CERT" != "$CA_CERT" ]]; then echo "ERROR: Cannot create issuing CA '$CA_NAME' without existing root CA certificate and key. Please create the root CA first." >&2 return 1 fi echo "Generating CA certificate '$CA_NAME' and key..." if ! openssl req \ -x509 \ -newkey rsa:4096 \ -keyout "$CA_DIR/$ROOT_CA_KEY" \ -out "$CA_DIR/$ROOT_CA_CERT" \ -days "$CA_DAYS" \ -noenc \ -subj "/CN=${CA_NAME}" \ -text \ -addext "basicConstraints=critical,CA:TRUE,pathlen:${PATHLEN}"; then echo "ERROR: Failed to generate CA certificate and key." >&2 return 1 fi # Make a "hash" symlink for the CA certificate to allow OpenSSL to find it when verifying other certificates make_hash_link "$CA_DIR/$ROOT_CA_CERT" return 0 fi if [[ ! -f "$CA_DIR/$CA_CERT" || ! -f "$CA_DIR/$CA_KEY" ]]; then echo "Generating issuing CA certificate '$CA_NAME' and key..." if ! openssl req \ -newkey rsa:4096 \ -keyout "$CA_DIR/${CA_KEY}" \ -noenc \ -subj "/CN=${CA_NAME}" \ -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ | openssl x509 \ -req \ -CA "$CA_DIR/$ROOT_CA_CERT" \ -CAkey "$CA_DIR/$ROOT_CA_KEY" \ -copy_extensions copyall \ -days "$CA_DAYS" \ -text \ -out "$CA_DIR/${CA_CERT}"; then echo "ERROR: Failed to generate issuing CA certificate and key." >&2 return 1 fi fi # Make a "hash" symlink for the issuing CA certificate to allow OpenSSL to find it when verifying other certificates make_hash_link "$CA_DIR/${CA_CERT}" return 0 } function _is_ip() { if [[ "$1" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then return 0 else return 1 fi } function _is_dns() { if [[ "$1" =~ ^[a-z0-9-]+(\.[a-z0-9-]+)*$ ]]; then return 0 else return 1 fi } function make_cert() { local CA_FILE_PREFIX="ca" # Default to CA if no issuing CA is used local CERT_DAYS=365 # Default validity period for leaf certificates local CA_DIR="" # The CA directory will default to certificate directory if not specified with --ca-dir while [[ $# -gt 0 ]]; do case "$1" in --ca-dir) if [[ -z "$2" ]]; then echo "ERROR: Missing value for --ca-dir." >&2 return 1 fi CA_DIR="$2" shift 2 ;; --issuing-ca) if [[ -z "$2" ]]; then echo "ERROR: Missing value for --issuing-ca." >&2 return 1 fi if [[ "$2" == "ca" ]]; then echo "ERROR: --issuing-ca cannot be 'ca' as it is reserved for the root CA." >&2 return 1 fi CA_FILE_PREFIX="$2" shift 2 ;; --days) if [[ -z "$2" || ! "$2" =~ ^[0-9]+$ ]]; then echo "ERROR: Invalid value for --days. Must be a positive integer." >&2 return 1 fi CERT_DAYS="$2" shift 2 ;; *) break ;; esac done local CERT_DIR="$1" local CERT_SUBJECT_NAME="$2" shift 2 CA_DIR="${CA_DIR:-$CERT_DIR}" local CA_CERT="${CA_FILE_PREFIX}_cert.pem" local CA_KEY="${CA_FILE_PREFIX}_key.pem" if [[ -z "$CERT_DIR" || ! -d "$CERT_DIR" ]]; then echo "ERROR: Certificate directory $CERT_DIR does not exist." return 1 fi if [[ -z "$CA_DIR" || ! -d "$CA_DIR" ]]; then echo "ERROR: CA directory $CA_DIR does not exist." >&2 return 1 fi 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 if [[ ! -f "$CA_DIR/$CA_CERT" || ! -f "$CA_DIR/$CA_KEY" ]]; then echo "ERROR: Signing CA certificate and key not found in $CA_DIR. Please call setup a signing CA first." >&2 return 1 fi # Calculate the "account" name from the subject name, the hostname part before the first dot local CERT_NAME="${CERT_SUBJECT_NAME%%.*}" # Start with the subjectAltName extension containing the main DNS name local SANS=("DNS:${CERT_SUBJECT_NAME}") # Combine the remaining arguments into a single string for the subjectAltName extension 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 # Join the SAN entries with commas for the OpenSSL command local SANS_EXT="subjectAltName=$(IFS=,; echo "${SANS[*]}")" echo "Generating server certificate for '$CERT_SUBJECT_NAME' with SANs:" for san in "${SANS[@]}"; do echo " - $san" done # Generate server certificate and key if they don't exist if [[ ! -f "$CERT_DIR/${CERT_NAME}_cert.pem" || ! -f "$CERT_DIR/${CERT_NAME}_key.pem" ]]; then 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=digitalSignature,keyEncipherment" \ -addext "extendedKeyUsage=serverAuth,clientAuth" \ -addext "$SANS_EXT" \ | openssl x509 \ -req \ -CA "$CA_DIR/$CA_CERT" \ -CAkey "$CA_DIR/$CA_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 fi return 0 } function make_pfx() { local CA_DIR="" local CA_FILE_PREFIX="" local CERT_PATH="" local PFX_PASSWORD="" while [[ $# -gt 0 ]]; do case "$1" in --ca-dir) if [[ -z "$2" ]]; then echo "ERROR: Missing value for --ca-dir." >&2 return 1 fi CA_DIR="$2" shift 2 ;; --issuing-ca) if [[ -z "$2" ]]; then echo "ERROR: Missing value for --issuing-ca." >&2 return 1 fi if [[ "$2" == "ca" ]]; then echo "ERROR: --issuing-ca cannot be 'ca' as it is reserved for the root CA." >&2 return 1 fi CA_FILE_PREFIX="$2" shift 2 ;; --path) if [[ -z "$2" ]]; then echo "ERROR: Missing value for certificate path." >&2 return 1 fi CERT_PATH="$2" shift 2 ;; --password) if [[ -z "$2" ]]; then echo "ERROR: Missing value for --password." >&2 return 1 fi PFX_PASSWORD="$2" shift 2 ;; *) break ;; esac done local ROOT_CA_CERT="ca_cert.pem" local ROOT_CA_KEY="ca_key.pem" local CA_CERT="${CA_FILE_PREFIX:-ca}_cert.pem" local CA_KEY="${CA_FILE_PREFIX:-ca}_key.pem" local CERT_DIR="$(dirname "$CERT_PATH")" local CERT_NAME="$(basename "$CERT_PATH" _cert.pem)" local KEY_PATH="$CERT_DIR/${CERT_NAME}_key.pem" if [[ -z "$CERT_DIR" || ! -d "$CERT_DIR" ]]; then echo "ERROR: Certificate directory $CERT_DIR does not exist." return 1 fi if [[ -z "$CA_DIR" || ! -d "$CA_DIR" ]]; then echo "ERROR: CA directory $CA_DIR does not exist." 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 "$CA_DIR/$ROOT_CA_CERT" || ! -f "$CA_DIR/$ROOT_CA_KEY" ]]; then echo "ERROR: CA certificate or key not found in $CA_DIR." >&2 return 1 fi if [[ ! -z "$CA_FILE_PREFIX" ]]; then if [[ ! -f "$CA_DIR/$CA_CERT" || ! -f "$CA_DIR/$CA_KEY" ]]; then echo "ERROR: Issuing CA certificate or key not found in $CA_DIR." >&2 return 1 fi fi if [[ -z "$PFX_PASSWORD" ]]; then PFX_PASSWORD="changeit" fi if [[ ! -f "$CERT_DIR/${CERT_NAME}.pfx" ]]; then echo -n "Generating PKCS#12 (PFX) file..." CHAIN_FILE=$(mktemp) trap "rm -f $CHAIN_FILE" EXIT QUIT KILL INT HUP cat "$CA_DIR/$ROOT_CA_CERT" > "$CHAIN_FILE" if [[ ! -z "$CA_FILE_PREFIX" ]]; then cat "$CA_DIR/$CA_CERT" >> "$CHAIN_FILE" fi 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." else echo "PKCS#12 (PFX) file already exists, aborting generation." return 1 fi return 0 }