448 lines
14 KiB
Bash
Executable File
448 lines
14 KiB
Bash
Executable File
# 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-]+)*$ ]]; }
|
|
_is_email() { [[ "$1" =~ ^[^@]+@[^@]+\.[^@]+$ ]]; }
|
|
|
|
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=""
|
|
local CERT_TYPE="server"
|
|
|
|
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 ;;
|
|
--type)
|
|
if [[ -z "$2" ]]; then
|
|
echo "ERROR: --type requires a value." >&2
|
|
return 1
|
|
fi
|
|
if [[ "$2" != "server" && "$2" != "user" ]]; then
|
|
echo "ERROR: --type must be 'server' or 'user'." >&2
|
|
return 1
|
|
fi
|
|
CERT_TYPE="$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 [[ "$CERT_TYPE" == "server" ]] && ! _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=()
|
|
|
|
if [[ "$CERT_TYPE" == "server" ]]; then
|
|
SANS=("DNS:${CERT_SUBJECT_NAME}")
|
|
fi
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
if _is_ip "$1"; then
|
|
SANS+=("IP:$1")
|
|
elif _is_email "$1"; then
|
|
SANS+=("email:$1")
|
|
elif _is_dns "$1"; then
|
|
SANS+=("DNS:$1")
|
|
else
|
|
echo "ERROR: Invalid SAN entry '$1'." >&2
|
|
return 1
|
|
fi
|
|
shift
|
|
done
|
|
|
|
if [[ "$CERT_TYPE" == "server" ]]; then
|
|
echo "Generating server certificate for '$CERT_SUBJECT_NAME' with SANs:"
|
|
else
|
|
echo "Generating user certificate for '$CERT_SUBJECT_NAME':"
|
|
fi
|
|
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 REQ_ARGS=(
|
|
-newkey rsa:4096
|
|
-keyout "$CERT_DIR/${CERT_NAME}_key.pem"
|
|
-noenc
|
|
-subj "/CN=${CERT_SUBJECT_NAME}"
|
|
-addext "basicConstraints=critical,CA:FALSE"
|
|
)
|
|
|
|
local X509_ARGS=(
|
|
-req
|
|
-CA "$SIGNING_CERT"
|
|
-CAkey "$SIGNING_KEY"
|
|
-copy_extensions copyall
|
|
-days "$CERT_DAYS"
|
|
-text
|
|
-out "$CERT_DIR/${CERT_NAME}_cert.pem"
|
|
)
|
|
|
|
if [[ "$CERT_TYPE" == "server" ]]; then
|
|
REQ_ARGS+=(
|
|
-addext "keyUsage=critical,digitalSignature,keyEncipherment"
|
|
-addext "extendedKeyUsage=serverAuth,clientAuth"
|
|
-addext "subjectAltName=$(IFS=,; echo "${SANS[*]}")"
|
|
)
|
|
else
|
|
REQ_ARGS+=(
|
|
-addext "keyUsage=critical,digitalSignature,nonRepudiation"
|
|
-addext "extendedKeyUsage=clientAuth,emailProtection,codeSigning"
|
|
)
|
|
if [[ ${#SANS[@]} -gt 0 ]]; then
|
|
REQ_ARGS+=(-addext "subjectAltName=$(IFS=,; echo "${SANS[*]}")")
|
|
fi
|
|
fi
|
|
|
|
if [[ -n "$AIA_URL" ]]; then
|
|
REQ_ARGS+=(-addext "authorityInfoAccess=caIssuers;URI:${AIA_URL}")
|
|
fi
|
|
|
|
echo "Generating certificate and key..."
|
|
if ! openssl req "${REQ_ARGS[@]}" | openssl x509 "${X509_ARGS[@]}"; then
|
|
echo "ERROR: Failed to generate 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."
|
|
}
|