diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml index 07ffa46..cdc1e31 100644 --- a/.gitea/workflows/test.yaml +++ b/.gitea/workflows/test.yaml @@ -4,7 +4,7 @@ on: - 'simple-ca.sh' - 'simple-ca.py' - 'run-tests.sh' - - 'src/simple-ca/**' + - 'test_simple_ca.py' - '.gitea/workflows/test.yaml' jobs: @@ -19,20 +19,17 @@ jobs: with: python-version: '3.x' - - name: Run python tests - run: ./run-tests.sh python + - name: Install pytest + run: pip install pytest - test-go: + - name: Run python tests + run: python3 -m pytest test_simple_ca.py -v + + test-shell: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.25' - cache-dependency-path: src/simple-ca/go.sum - - - name: Run go tests - run: ./run-tests.sh go + - name: Run shell tests + run: bash run-tests.sh diff --git a/run-tests.sh b/run-tests.sh index 6448e08..7a3fcf8 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -21,211 +21,74 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -# This script runs integration tests against the simple-ca Python implementation. -# Usage: run-tests.sh [python|all] (default: all) +# Integration tests for simple-ca.sh. +# Usage: run-tests.sh set -e -TEST_TARGET="${1:-all}" - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CERT_DIR="$SCRIPT_DIR/tests" -CA_DIR="$CERT_DIR/ca" -clean_up_test_dir() { - if [[ -d "$CERT_DIR" ]]; then - echo "Cleaning up test directory $CERT_DIR..." - rm -rf "$CERT_DIR"/* - fi - echo "Creating test directory $CERT_DIR and ca subdirectory..." - mkdir -p "$CA_DIR" +source "$SCRIPT_DIR/simple-ca.sh" + +# Temporary test directory — cleaned up on exit. +TEST_DIR="$(mktemp -d)" +trap 'rm -rf "$TEST_DIR"' EXIT + +CA_DIR="$TEST_DIR/ca" +CERT_DIR="$TEST_DIR/certs" + +reset_dirs() { + rm -rf "$CA_DIR" "$CERT_DIR" + mkdir -p "$CA_DIR" "$CERT_DIR" + SIMPLE_CA_DIR="" } -display_certificate() { +verify_cert() { local CERT_PATH="$1" - - echo -e "\nDisplaying generated certificate for verification ($CERT_PATH):" - openssl x509 -in "$CERT_PATH" -noout -subject -issuer -serial -fingerprint - echo - - echo -e "\nVerifying certificate against the CA bundle ($CA_DIR/ca_bundle.pem)..." - if openssl verify -CAfile "$CA_DIR/ca_bundle.pem" "$CERT_PATH" 2>/dev/null; then - echo "Certificate verification successful." - else - echo "ERROR: Certificate verification failed." >&2 + if ! openssl verify -CAfile "$CA_DIR/ca_bundle.pem" "$CERT_PATH" 2>/dev/null; then + echo "ERROR: Certificate verification failed: $CERT_PATH" >&2 exit 1 fi + echo "Verified: $CERT_PATH" } -# verify_pfx LABEL PFX_PATH PASSWORD PATTERN -# Checks that PATTERN appears in the pkcs12 -info output (case-insensitive). -verify_pfx_algo() { - local LABEL="$1" PFX_PATH="$2" PASSWORD="$3" PATTERN="$4" - local INFO - INFO=$(openssl pkcs12 -in "$PFX_PATH" -noout -info -password "pass:$PASSWORD" 2>&1) - if echo "$INFO" | grep -qi "$PATTERN"; then - echo "PFX [$LABEL]: OK" - else - echo "ERROR: PFX [$LABEL]: expected pattern '$PATTERN' not found in:" >&2 - echo "$INFO" >&2 - exit 1 - fi -} - -# run_flow NAME MAKE_CA_CMD MAKE_CERT_CMD MAKE_PFX_CMD -# Command variables are left unquoted on use, so multi-word prefixes -# (e.g. "python3 simple-ca.py make-ca") word-split as expected. -run_flow() { - local NAME="$1" - local MAKE_CA_CMD="$2" - local MAKE_CERT_CMD="$3" - local MAKE_PFX_CMD="$4" - - echo - echo "============================================================" - echo "Running tests for '$NAME' implementation" - echo "============================================================" - - echo - echo "--- [$NAME] Standalone CA ---" - clean_up_test_dir - $MAKE_CA_CMD --ca-dir "$CA_DIR" "Test CA" - [[ -f "$CA_DIR/simple-ca.json" ]] || { echo "ERROR: simple-ca.json not created" >&2; exit 1; } - display_certificate "$CA_DIR/ca_cert.pem" - $MAKE_CERT_CMD --ca-dir "$CA_DIR" --cert-dir "$CERT_DIR" "test" "test.example.com" "127.0.0.1" - display_certificate "$CERT_DIR/test_cert.pem" - - echo - echo "--- [$NAME] Two-level CA ---" - clean_up_test_dir - $MAKE_CA_CMD --ca-dir "$CA_DIR" "Test Two Level CA" - [[ -f "$CA_DIR/simple-ca.json" ]] || { echo "ERROR: simple-ca.json not created" >&2; exit 1; } - display_certificate "$CA_DIR/ca_cert.pem" - $MAKE_CA_CMD --ca-dir "$CA_DIR" --issuing-ca "issuing_ca" "Issuing CA" - display_certificate "$CA_DIR/issuing_ca/ca_cert.pem" - $MAKE_CERT_CMD --ca-dir "$CA_DIR" --cert-dir "$CERT_DIR" --issuing-ca "issuing_ca" "test" "test.example.com" "127.0.0.1" - display_certificate "$CERT_DIR/test_cert.pem" - $MAKE_PFX_CMD --ca-dir "$CA_DIR" --issuing-ca "issuing_ca" --password "s3cr3t" "$CERT_DIR/test_cert.pem" - - echo -e "\nVerifying contents of generated PKCS#12 (PFX) file ($CERT_DIR/test.pfx):" - openssl pkcs12 -in "$CERT_DIR/test.pfx" -noout -info -password pass:"s3cr3t" -} - -# run_pfx_algorithm_tests NAME MAKE_CA_CMD MAKE_CERT_CMD MAKE_PFX_CMD -# Tests modern, legacy, and (on macOS) --apple-openssl PKCS12 variants. -run_pfx_algorithm_tests() { - local NAME="$1" - local MAKE_CA_CMD="$2" - local MAKE_CERT_CMD="$3" - local MAKE_PFX_CMD="$4" - - echo - echo "--- [$NAME] PFX algorithm variants ---" - clean_up_test_dir - $MAKE_CA_CMD --ca-dir "$CA_DIR" "PFX Test CA" - $MAKE_CA_CMD --ca-dir "$CA_DIR" --issuing-ca "issuing_ca" "Issuing CA" - $MAKE_CERT_CMD --ca-dir "$CA_DIR" --cert-dir "$CERT_DIR" --issuing-ca "issuing_ca" \ - "test" "test.example.com" "127.0.0.1" - - # Modern (default): OpenSSL 3.x PBES2/AES-256 - $MAKE_PFX_CMD --ca-dir "$CA_DIR" --issuing-ca "issuing_ca" --password "s3cr3t" \ - "$CERT_DIR/test_cert.pem" - verify_pfx_algo "modern" "$CERT_DIR/test.pfx" "s3cr3t" "PBES2" - rm "$CERT_DIR/test.pfx" - - # Apple openssl (macOS only): verify the switch routes to /usr/bin/openssl - # and that the result is readable by Apple's binary (not PBES2). - if [[ "$(uname)" == "Darwin" ]]; then - $MAKE_PFX_CMD --apple-openssl --ca-dir "$CA_DIR" --issuing-ca "issuing_ca" --password "s3cr3t" \ - "$CERT_DIR/test_cert.pem" - /usr/bin/openssl pkcs12 -in "$CERT_DIR/test.pfx" -noout -info -password pass:"s3cr3t" 2>&1 - # Confirm Apple's binary produced its own (non-PBES2) format - INFO=$(/usr/bin/openssl pkcs12 -in "$CERT_DIR/test.pfx" -noout -info -password pass:"s3cr3t" 2>&1) - if echo "$INFO" | grep -qi "PBES2"; then - echo "ERROR: PFX [apple-openssl]: unexpected PBES2 — Apple binary was not used" >&2 - exit 1 - fi - echo "PFX [apple-openssl]: OK" - rm "$CERT_DIR/test.pfx" - fi -} - -# run_crl_tests NAME MAKE_CA_CMD MAKE_CERT_CMD MAKE_PFX_CMD REVOKE_CMD MAKE_CRL_CMD -run_crl_tests() { - local NAME="$1" - local MAKE_CA_CMD="$2" - local MAKE_CERT_CMD="$3" - local REVOKE_CMD="$4" - local MAKE_CRL_CMD="$5" - - echo - echo "--- [$NAME] CRL tests ---" - clean_up_test_dir - - # Build a two-level CA hierarchy - $MAKE_CA_CMD --ca-dir "$CA_DIR" "CRL Test CA" - $MAKE_CA_CMD --ca-dir "$CA_DIR" --issuing-ca "issuing_ca" "Issuing CA" - - # Issue two certs; revoke the first; keep the second active - $MAKE_CERT_CMD --ca-dir "$CA_DIR" --cert-dir "$CERT_DIR" --issuing-ca "issuing_ca" \ - "alice" "alice.example.com" - $MAKE_CERT_CMD --ca-dir "$CA_DIR" --cert-dir "$CERT_DIR" --issuing-ca "issuing_ca" \ - "bob" "bob.example.com" - - $REVOKE_CMD --ca-dir "$CA_DIR" --issuing-ca "issuing_ca" "$CERT_DIR/alice_cert.pem" - - # Generate CRL for the issuing CA - $MAKE_CRL_CMD --ca-dir "$CA_DIR" --issuing-ca "issuing_ca" - [[ -f "$CA_DIR/issuing_ca/crl.pem" ]] || { echo "ERROR: issuing_ca/crl.pem not created" >&2; exit 1; } - - # alice's serial must appear in the issuing CA CRL - CRL_TEXT=$(openssl crl -in "$CA_DIR/issuing_ca/crl.pem" -noout -text 2>/dev/null) - ALICE_SERIAL=$(openssl x509 -in "$CERT_DIR/alice_cert.pem" -noout -serial | cut -d= -f2) - if echo "$CRL_TEXT" | grep -qi "$ALICE_SERIAL"; then - echo "CRL [issuing_ca]: alice's serial found — OK" - else - echo "ERROR: CRL [issuing_ca]: alice's serial not found in CRL" >&2 - echo "$CRL_TEXT" >&2 - exit 1 - fi - - # bob's serial must NOT appear in the issuing CA CRL - BOB_SERIAL=$(openssl x509 -in "$CERT_DIR/bob_cert.pem" -noout -serial | cut -d= -f2) - if echo "$CRL_TEXT" | grep -qi "$BOB_SERIAL"; then - echo "ERROR: CRL [issuing_ca]: bob's serial unexpectedly found in CRL" >&2 - exit 1 - else - echo "CRL [issuing_ca]: bob's serial absent — OK" - fi - - # Root CA CRL should be empty (no revoctions at root level) - $MAKE_CRL_CMD --ca-dir "$CA_DIR" - [[ -f "$CA_DIR/crl.pem" ]] || { echo "ERROR: root crl.pem not created" >&2; exit 1; } - ROOT_CRL_TEXT=$(openssl crl -in "$CA_DIR/crl.pem" -noout -text 2>/dev/null) - if echo "$ROOT_CRL_TEXT" | grep -q "No Revoked Certificates"; then - echo "CRL [root]: empty — OK" - else - echo "ERROR: CRL [root]: expected empty CRL" >&2 - echo "$ROOT_CRL_TEXT" >&2 - exit 1 - fi -} - -# Uses ;;& to fall through to subsequent patterns so 'all' matches every block. -case "$TEST_TARGET" in - python|all) - command -v python3 >/dev/null || { echo "ERROR: python3 not found" >&2; exit 1; } - PY_PREFIX="python3 $SCRIPT_DIR/simple-ca.py" - run_flow "python" "$PY_PREFIX make-ca" "$PY_PREFIX make-cert" "$PY_PREFIX make-pfx" - run_pfx_algorithm_tests "python" "$PY_PREFIX make-ca" "$PY_PREFIX make-cert" "$PY_PREFIX make-pfx" - run_crl_tests "python" "$PY_PREFIX make-ca" "$PY_PREFIX make-cert" "$PY_PREFIX revoke-cert" "$PY_PREFIX make-crl" - ;;& - python|all) - ;; - *) - echo "ERROR: unknown target '$TEST_TARGET' (expected: python|all)" >&2 - exit 1 - ;; -esac +# --------------------------------------------------------------------------- +# Standalone CA +# --------------------------------------------------------------------------- echo -echo "All requested tests passed." +echo "--- [shell] Standalone CA ---" +reset_dirs +make_ca --ca-dir "$CA_DIR" "Test CA" 2>/dev/null +[[ -f "$CA_DIR/ca_cert.pem" ]] || { echo "ERROR: ca_cert.pem not created" >&2; exit 1; } +[[ -f "$CA_DIR/ca_bundle.pem" ]] || { echo "ERROR: ca_bundle.pem not created" >&2; exit 1; } +verify_cert "$CA_DIR/ca_cert.pem" + +make_cert --cert-dir "$CERT_DIR" "test" "test.example.com" "127.0.0.1" 2>/dev/null +[[ -f "$CERT_DIR/test_cert.pem" ]] || { echo "ERROR: test_cert.pem not created" >&2; exit 1; } +verify_cert "$CERT_DIR/test_cert.pem" + +# --------------------------------------------------------------------------- +# Two-level CA +# --------------------------------------------------------------------------- + +echo +echo "--- [shell] Two-level CA ---" +reset_dirs +make_ca --ca-dir "$CA_DIR" "Test Root CA" 2>/dev/null +verify_cert "$CA_DIR/ca_cert.pem" + +make_ca --issuing-ca "issuing_ca" "Issuing CA" 2>/dev/null +[[ -f "$CA_DIR/issuing_ca/ca_cert.pem" ]] || { echo "ERROR: issuing_ca/ca_cert.pem not created" >&2; exit 1; } +verify_cert "$CA_DIR/issuing_ca/ca_cert.pem" + +make_cert --cert-dir "$CERT_DIR" --issuing-ca "issuing_ca" "test" "test.example.com" "127.0.0.1" 2>/dev/null +verify_cert "$CERT_DIR/test_cert.pem" + +make_pfx --issuing-ca "issuing_ca" --password "s3cr3t" "$CERT_DIR/test_cert.pem" 2>/dev/null +[[ -f "$CERT_DIR/test.pfx" ]] || { echo "ERROR: test.pfx not created" >&2; exit 1; } +openssl pkcs12 -in "$CERT_DIR/test.pfx" -noout -info -password pass:"s3cr3t" 2>/dev/null \ + || { echo "ERROR: PFX verification failed" >&2; exit 1; } +echo "PFX: OK" + +echo +echo "All shell tests passed." diff --git a/simple-ca.sh b/simple-ca.sh new file mode 100755 index 0000000..4f1adc0 --- /dev/null +++ b/simple-ca.sh @@ -0,0 +1,317 @@ +# 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." +} diff --git a/test_simple_ca.py b/test_simple_ca.py new file mode 100644 index 0000000..41cdcc2 --- /dev/null +++ b/test_simple_ca.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +# MIT License +# Copyright (c) 2026 Sławomir Koszewski + +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parent / "simple-ca.py" + + +def py(*args, check=True): + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + capture_output=True, text=True, check=check, + ) + + +def openssl(*args, check=True): + return subprocess.run(["openssl", *args], capture_output=True, text=True, check=check) + + +def cert_serial(cert_path): + out = openssl("x509", "-in", str(cert_path), "-noout", "-serial").stdout + return out.strip().split("=", 1)[-1].upper() + + +def verify_cert(cert_path, bundle_path): + result = openssl("verify", "-CAfile", str(bundle_path), str(cert_path), check=False) + assert result.returncode == 0, f"Certificate verification failed:\n{result.stderr}" + + +@pytest.fixture +def dirs(tmp_path): + ca = tmp_path / "ca" + certs = tmp_path / "certs" + ca.mkdir() + certs.mkdir() + return ca, certs + + +# --------------------------------------------------------------------------- +# Standalone CA +# --------------------------------------------------------------------------- + +def test_standalone_ca(dirs): + ca, certs = dirs + py("make-ca", "--ca-dir", str(ca), "Test CA") + + assert (ca / "ca_cert.pem").exists() + assert (ca / "ca_bundle.pem").exists() + assert (ca / "simple-ca.json").exists() + verify_cert(ca / "ca_cert.pem", ca / "ca_bundle.pem") + + py("make-cert", "--ca-dir", str(ca), "--cert-dir", str(certs), + "test", "test.example.com", "127.0.0.1") + + assert (certs / "test_cert.pem").exists() + verify_cert(certs / "test_cert.pem", ca / "ca_bundle.pem") + + +# --------------------------------------------------------------------------- +# Two-level CA +# --------------------------------------------------------------------------- + +def test_two_level_ca(dirs): + ca, certs = dirs + py("make-ca", "--ca-dir", str(ca), "Test Root CA") + py("make-ca", "--ca-dir", str(ca), "--issuing-ca", "issuing_ca", "Issuing CA") + + assert (ca / "issuing_ca" / "ca_cert.pem").exists() + verify_cert(ca / "issuing_ca" / "ca_cert.pem", ca / "ca_bundle.pem") + + py("make-cert", "--ca-dir", str(ca), "--cert-dir", str(certs), + "--issuing-ca", "issuing_ca", "test", "test.example.com", "127.0.0.1") + + verify_cert(certs / "test_cert.pem", ca / "ca_bundle.pem") + + py("make-pfx", "--ca-dir", str(ca), "--issuing-ca", "issuing_ca", + "--password", "s3cr3t", str(certs / "test_cert.pem")) + + assert (certs / "test.pfx").exists() + result = openssl("pkcs12", "-in", str(certs / "test.pfx"), "-noout", "-info", + "-password", "pass:s3cr3t") + assert result.returncode == 0 + + +# --------------------------------------------------------------------------- +# PFX algorithm variants +# --------------------------------------------------------------------------- + +def test_pfx_modern(dirs): + ca, certs = dirs + py("make-ca", "--ca-dir", str(ca), "PFX Test CA") + py("make-ca", "--ca-dir", str(ca), "--issuing-ca", "issuing_ca", "Issuing CA") + py("make-cert", "--ca-dir", str(ca), "--cert-dir", str(certs), + "--issuing-ca", "issuing_ca", "test", "test.example.com", "127.0.0.1") + py("make-pfx", "--ca-dir", str(ca), "--issuing-ca", "issuing_ca", + "--password", "s3cr3t", str(certs / "test_cert.pem")) + + info = openssl("pkcs12", "-in", str(certs / "test.pfx"), "-noout", "-info", + "-password", "pass:s3cr3t") + assert "PBES2" in (info.stdout + info.stderr), "Expected modern PBES2 encryption" + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS only") +def test_pfx_apple_openssl(dirs): + ca, certs = dirs + py("make-ca", "--ca-dir", str(ca), "PFX Test CA") + py("make-ca", "--ca-dir", str(ca), "--issuing-ca", "issuing_ca", "Issuing CA") + py("make-cert", "--ca-dir", str(ca), "--cert-dir", str(certs), + "--issuing-ca", "issuing_ca", "test", "test.example.com", "127.0.0.1") + py("make-pfx", "--apple-openssl", "--ca-dir", str(ca), "--issuing-ca", "issuing_ca", + "--password", "s3cr3t", str(certs / "test_cert.pem")) + + result = subprocess.run( + ["/usr/bin/openssl", "pkcs12", "-in", str(certs / "test.pfx"), + "-noout", "-info", "-password", "pass:s3cr3t"], + capture_output=True, text=True, + ) + assert result.returncode == 0 + combined = result.stdout + result.stderr + assert "PBES2" not in combined, "Expected Apple-compatible (non-PBES2) format" + + +# --------------------------------------------------------------------------- +# CRL and revocation +# --------------------------------------------------------------------------- + +def test_crl(dirs): + ca, certs = dirs + py("make-ca", "--ca-dir", str(ca), "CRL Test CA") + py("make-ca", "--ca-dir", str(ca), "--issuing-ca", "issuing_ca", "Issuing CA") + + py("make-cert", "--ca-dir", str(ca), "--cert-dir", str(certs), + "--issuing-ca", "issuing_ca", "alice", "alice.example.com") + py("make-cert", "--ca-dir", str(ca), "--cert-dir", str(certs), + "--issuing-ca", "issuing_ca", "bob", "bob.example.com") + + alice_serial = cert_serial(certs / "alice_cert.pem") + bob_serial = cert_serial(certs / "bob_cert.pem") + + py("revoke-cert", "--ca-dir", str(ca), "--issuing-ca", "issuing_ca", + str(certs / "alice_cert.pem")) + + py("make-crl", "--ca-dir", str(ca), "--issuing-ca", "issuing_ca") + issuing_crl = ca / "issuing_ca" / "crl.pem" + assert issuing_crl.exists() + + crl_text = openssl("crl", "-in", str(issuing_crl), "-noout", "-text").stdout.upper() + assert alice_serial in crl_text, f"Alice's serial {alice_serial} not found in issuing CA CRL" + assert bob_serial not in crl_text, f"Bob's serial {bob_serial} unexpectedly found in issuing CA CRL" + + py("make-crl", "--ca-dir", str(ca)) + root_crl = ca / "crl.pem" + assert root_crl.exists() + root_crl_text = openssl("crl", "-in", str(root_crl), "-noout", "-text").stdout + assert "No Revoked Certificates" in root_crl_text, "Root CA CRL should be empty"