From 16c228d2b1d5261f8c52a8b60294e092277f4442 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Sun, 24 May 2026 14:39:47 +0200 Subject: [PATCH] Refactor CA management and certificate generation - Removed the legacy simple-ca.sh script, consolidating functionality into Go code. - Introduced a JSON configuration file (simple-ca.json) to manage CA settings, including validity periods and AIA base URLs. - Enhanced the makeCA function to utilize configuration values for CA creation and AIA URL management. - Updated makeCert and makePFX functions to support configuration-driven behavior. - Improved error handling and user feedback throughout the CA and certificate generation processes. - Added support for using Apple's OpenSSL for PKCS#12 file generation. --- run-tests.sh | 71 ++++++- simple-ca.py | 160 +++++++-------- simple-ca.sh | 451 ------------------------------------------ src/simple-ca/main.go | 326 ++++++++++++++++++++++-------- 4 files changed, 391 insertions(+), 617 deletions(-) delete mode 100755 simple-ca.sh diff --git a/run-tests.sh b/run-tests.sh index 657b167..f0f6893 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -22,7 +22,7 @@ # SOFTWARE. # This script runs integration tests against one or more simple-ca implementations. -# Usage: run-tests.sh [bash|python|go|all] (default: all) +# Usage: run-tests.sh [python|go|all] (default: all) set -e TEST_TARGET="${1:-all}" @@ -56,6 +56,21 @@ display_certificate() { fi } +# 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. @@ -74,6 +89,7 @@ run_flow() { 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" @@ -82,9 +98,10 @@ run_flow() { 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_cert.pem" + 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" @@ -93,17 +110,52 @@ run_flow() { 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 +} + # Uses ;;& to fall through to subsequent patterns so 'all' matches every block. case "$TEST_TARGET" in - bash|all) - # shellcheck disable=SC1091 - source "$SCRIPT_DIR/simple-ca.sh" - run_flow "bash" "make_ca" "make_cert" "make_pfx" - ;;& 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" ;;& go|all) command -v go >/dev/null || { echo "ERROR: go not found" >&2; exit 1; } @@ -112,11 +164,12 @@ case "$TEST_TARGET" in echo "Building Go binary..." (cd "$GO_SRC" && go build -o simple-ca .) run_flow "go" "$GO_BIN make-ca" "$GO_BIN make-cert" "$GO_BIN make-pfx" + run_pfx_algorithm_tests "go" "$GO_BIN make-ca" "$GO_BIN make-cert" "$GO_BIN make-pfx" ;;& - bash|python|go|all) + python|go|all) ;; *) - echo "ERROR: unknown target '$TEST_TARGET' (expected: bash|python|go|all)" >&2 + echo "ERROR: unknown target '$TEST_TARGET' (expected: python|go|all)" >&2 exit 1 ;; esac diff --git a/simple-ca.py b/simple-ca.py index a786600..30feca0 100755 --- a/simple-ca.py +++ b/simple-ca.py @@ -30,50 +30,57 @@ import re import subprocess import sys -OPENSSL = "/usr/bin/openssl" +OPENSSL = "openssl" -CONFIG_FILE = "simple-ca.json" +class Config: + FILE = "simple-ca.json" + + def __init__(self, ca_dir: str): + self._path = os.path.join(ca_dir, self.FILE) + self._data: dict = {} + if not os.path.isfile(self._path): + return + try: + with open(self._path, "r") as f: + self._data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + print(f"WARNING: could not read {self._path}: {e}", file=sys.stderr) + + def get(self, key, default=None): + return self._data.get(key, default) + + def update(self, patch: dict): + file_missing = not os.path.isfile(self._path) + changed = any(self._data.get(k) != v for k, v in patch.items()) + if file_missing or changed: + self._data.update(patch) + self._save() + + def _save(self): + with open(self._path, "w") as f: + json.dump(self._data, f, indent=2) + f.write("\n") + + +_config: Config def _err(msg): print(f"ERROR: {msg}", file=sys.stderr) -def _load_config(ca_dir) -> dict: - path = os.path.join(ca_dir, CONFIG_FILE) - if not os.path.isfile(path): - return {} - try: - with open(path, "r") as f: - return json.load(f) - except (json.JSONDecodeError, OSError) as e: - print(f"WARNING: could not read {path}: {e}", file=sys.stderr) - return {} - - -def _save_config(ca_dir, patch: dict): - path = os.path.join(ca_dir, CONFIG_FILE) - cfg = _load_config(ca_dir) - cfg.update(patch) - with open(path, "w") as f: - json.dump(cfg, f, indent=2) - f.write("\n") - - def _rebuild_ca_bundle(ca_dir): - """Write ca_bundle.pem = root cert + any issuing CA certs in this dir.""" + """Write ca_bundle.pem = root cert + registered subordinate CA certs.""" bundle_path = os.path.join(ca_dir, "ca_bundle.pem") parts = [] root = os.path.join(ca_dir, "ca_cert.pem") if os.path.isfile(root): with open(root, "rb") as f: parts.append(f.read()) - for name in sorted(os.listdir(ca_dir)): - if name == "ca_cert.pem" or not name.endswith("_cert.pem"): - continue - path = os.path.join(ca_dir, name) - if os.path.isfile(path): - with open(path, "rb") as f: + for name in sorted(_config.get("subordinates", [])): + sub_cert = os.path.join(ca_dir, name, "ca_cert.pem") + if os.path.isfile(sub_cert): + with open(sub_cert, "rb") as f: parts.append(f.read()) with open(bundle_path, "wb") as f: f.write(b"".join(parts)) @@ -92,19 +99,11 @@ def make_ca(ca_dir, ca_name, days=3650, issuing_ca=None, aia_base_url=None): _err("CA name is required.") return False - ca_file_prefix = issuing_ca or "ca" - root_ca_cert = "ca_cert.pem" - root_ca_key = "ca_key.pem" - ca_cert = f"{ca_file_prefix}_cert.pem" - ca_key = f"{ca_file_prefix}_key.pem" - - root_ca_cert_path = os.path.join(ca_dir, root_ca_cert) - root_ca_key_path = os.path.join(ca_dir, root_ca_key) - ca_cert_path = os.path.join(ca_dir, ca_cert) - ca_key_path = os.path.join(ca_dir, ca_key) + root_ca_cert_path = os.path.join(ca_dir, "ca_cert.pem") + root_ca_key_path = os.path.join(ca_dir, "ca_key.pem") if not os.path.isfile(root_ca_cert_path) or not os.path.isfile(root_ca_key_path): - if root_ca_cert != ca_cert: + if issuing_ca: _err( f"Cannot create issuing CA '{ca_name}' without existing root CA " "certificate and key. Please create the root CA first." @@ -133,15 +132,20 @@ def make_ca(ca_dir, ca_name, days=3650, issuing_ca=None, aia_base_url=None): return False _rebuild_ca_bundle(ca_dir) - _save_config(ca_dir, {"aia_base_url": aia_base_url} if aia_base_url else {}) + _config.update({"aia_base_url": aia_base_url} if aia_base_url else {}) return True - if not os.path.isfile(ca_cert_path) or not os.path.isfile(ca_key_path): + issuing_ca_dir = os.path.join(ca_dir, issuing_ca) + issuing_ca_cert = os.path.join(issuing_ca_dir, "ca_cert.pem") + issuing_ca_key = os.path.join(issuing_ca_dir, "ca_key.pem") + + if not os.path.isfile(issuing_ca_cert) or not os.path.isfile(issuing_ca_key): print(f"Generating issuing CA certificate '{ca_name}' and key...") + os.makedirs(issuing_ca_dir, exist_ok=True) req_cmd = [ OPENSSL, "req", "-newkey", "rsa:4096", - "-keyout", ca_key_path, + "-keyout", issuing_ca_key, "-noenc", "-subj", f"/CN={ca_name}", "-addext", "basicConstraints=critical,CA:TRUE,pathlen:0", @@ -160,14 +164,18 @@ def make_ca(ca_dir, ca_name, days=3650, issuing_ca=None, aia_base_url=None): "-copy_extensions", "copyall", "-days", str(days), "-text", - "-out", ca_cert_path, + "-out", issuing_ca_cert, ] if not _pipe(req_cmd, x509_cmd): _err("Failed to generate issuing CA certificate and key.") return False + patch = {"aia_base_url": aia_base_url} if aia_base_url else {} + subs = _config.get("subordinates", []) + if issuing_ca not in subs: + patch["subordinates"] = subs + [issuing_ca] + _config.update(patch) _rebuild_ca_bundle(ca_dir) - _save_config(ca_dir, {"aia_base_url": aia_base_url} if aia_base_url else {}) return True @@ -198,14 +206,8 @@ def make_cert(cert_dir, cert_subject_name, sans=None, ca_dir=None, _err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.") return False - ca_file_prefix = issuing_ca or "ca" ca_dir = ca_dir or cert_dir - aia_url = f"{aia_base_url}/{ca_file_prefix}_cert.crt" if aia_base_url else "" - - ca_cert = f"{ca_file_prefix}_cert.pem" - ca_key = f"{ca_file_prefix}_key.pem" - if not cert_dir or not os.path.isdir(cert_dir): _err(f"Certificate directory {cert_dir} does not exist.") return False @@ -222,15 +224,21 @@ def make_cert(cert_dir, cert_subject_name, sans=None, ca_dir=None, _err(f"Invalid subject name '{cert_subject_name}'. Must be a valid DNS name.") return False - ca_cert_path = os.path.join(ca_dir, ca_cert) - ca_key_path = os.path.join(ca_dir, ca_key) + signing_dir = os.path.join(ca_dir, issuing_ca) if issuing_ca else ca_dir + ca_cert_path = os.path.join(signing_dir, "ca_cert.pem") + ca_key_path = os.path.join(signing_dir, "ca_key.pem") if not os.path.isfile(ca_cert_path) or not os.path.isfile(ca_key_path): _err( - f"Signing CA certificate and key not found in {ca_dir}. " - "Please call setup a signing CA first." + f"Signing CA certificate and key not found in {signing_dir}. " + "Please set up a signing CA first." ) return False + if aia_base_url: + aia_url = f"{aia_base_url}/{issuing_ca}/ca_cert.crt" if issuing_ca else f"{aia_base_url}/ca_cert.crt" + else: + aia_url = "" + # "account" name from the subject: hostname part before the first dot cert_name = cert_subject_name.split(".", 1)[0] @@ -285,17 +293,11 @@ def make_cert(cert_dir, cert_subject_name, sans=None, ca_dir=None, return True -def make_pfx(cert_path, ca_dir, issuing_ca=None, password=None): +def make_pfx(cert_path, ca_dir, issuing_ca=None, password=None, apple_openssl=False): if issuing_ca == "ca": _err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.") return False - root_ca_cert = "ca_cert.pem" - root_ca_key = "ca_key.pem" - ca_file_prefix = issuing_ca or "ca" - ca_cert = f"{ca_file_prefix}_cert.pem" - ca_key = f"{ca_file_prefix}_key.pem" - cert_dir = os.path.dirname(cert_path) cert_basename = os.path.basename(cert_path) if cert_basename.endswith("_cert.pem"): @@ -316,17 +318,16 @@ def make_pfx(cert_path, ca_dir, issuing_ca=None, password=None): _err("Server certificate or key not found.") return False - root_ca_cert_path = os.path.join(ca_dir, root_ca_cert) - root_ca_key_path = os.path.join(ca_dir, root_ca_key) + root_ca_cert_path = os.path.join(ca_dir, "ca_cert.pem") + root_ca_key_path = os.path.join(ca_dir, "ca_key.pem") if not os.path.isfile(root_ca_cert_path) or not os.path.isfile(root_ca_key_path): _err(f"CA certificate or key not found in {ca_dir}.") return False - ca_cert_path = os.path.join(ca_dir, ca_cert) - ca_key_path = os.path.join(ca_dir, ca_key) if issuing_ca: - if not os.path.isfile(ca_cert_path) or not os.path.isfile(ca_key_path): - _err(f"Issuing CA certificate or key not found in {ca_dir}.") + issuing_ca_cert_path = os.path.join(ca_dir, issuing_ca, "ca_cert.pem") + if not os.path.isfile(issuing_ca_cert_path): + _err(f"Issuing CA certificate not found: {issuing_ca_cert_path}.") return False if not password: @@ -343,7 +344,7 @@ def make_pfx(cert_path, ca_dir, issuing_ca=None, password=None): with open(root_ca_cert_path, "rb") as f: chain_bytes += f.read() if issuing_ca: - with open(ca_cert_path, "rb") as f: + with open(issuing_ca_cert_path, "rb") as f: chain_bytes += f.read() import tempfile @@ -352,7 +353,7 @@ def make_pfx(cert_path, ca_dir, issuing_ca=None, password=None): with os.fdopen(chain_fd, "wb") as f: f.write(chain_bytes) cmd = [ - OPENSSL, "pkcs12", + "/usr/bin/openssl" if apple_openssl else OPENSSL, "pkcs12", "-export", "-out", pfx_path, "-inkey", key_path, "-in", cert_path, @@ -399,27 +400,27 @@ def _build_parser(): p_pfx.add_argument("--issuing-ca", default=None, help="Specify the issuing CA") p_pfx.add_argument("--ca-dir", help="Directory of the CA") p_pfx.add_argument("--password", help="Password for the PFX file") - p_pfx.add_argument("--openssl", default=None, metavar="PATH", - help=f"Path to the openssl binary (default: {OPENSSL})") + p_pfx.add_argument("--apple-openssl", action="store_true", default=False, + help="Use Apple's bundled /usr/bin/openssl for PKCS12 generation") p_pfx.add_argument("path", help="Path to the certificate file") return parser def main(argv=None): - global OPENSSL + global OPENSSL, _config parser = _build_parser() args = parser.parse_args(argv) ca_dir = args.ca_dir or os.environ.get("SIMPLE_CA_DIR") or os.getcwd() - cfg = _load_config(ca_dir) + _config = Config(ca_dir) - OPENSSL = args.openssl or cfg.get("openssl", OPENSSL) - issuing_ca = args.issuing_ca or cfg.get("issuing_ca") - aia_base_url = getattr(args, "aia_base_url", None) or cfg.get("aia_base_url") + OPENSSL = getattr(args, "openssl", None) or _config.get("openssl", OPENSSL) + issuing_ca = args.issuing_ca or _config.get("issuing_ca") + aia_base_url = getattr(args, "aia_base_url", None) or _config.get("aia_base_url") - days_cfg = cfg.get("days", {}) + days_cfg = _config.get("days", {}) if args.command == "make-ca": days = args.days or days_cfg.get("ca", 3650) @@ -444,6 +445,7 @@ def main(argv=None): args.path, ca_dir, issuing_ca=issuing_ca, password=args.password, + apple_openssl=args.apple_openssl, ) else: parser.error(f"Unknown command: {args.command}") diff --git a/simple-ca.sh b/simple-ca.sh deleted file mode 100755 index 6eb25b0..0000000 --- a/simple-ca.sh +++ /dev/null @@ -1,451 +0,0 @@ -# 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 _rebuild_ca_bundle() { - local CA_DIR="$1" - local BUNDLE="$CA_DIR/ca_bundle.pem" - : > "$BUNDLE" - if [[ -f "$CA_DIR/ca_cert.pem" ]]; then - cat "$CA_DIR/ca_cert.pem" >> "$BUNDLE" - fi - local f - for f in "$CA_DIR"/*_cert.pem; do - [[ -f "$f" ]] || continue - [[ "$(basename "$f")" == "ca_cert.pem" ]] && continue - cat "$f" >> "$BUNDLE" - done -} - -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 AIA_BASE_URL="" - local CA_DIR="" - - 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 - ;; - --aia-base-url) - if [[ -z "$2" ]]; then - echo "ERROR: Missing value for --aia-base-url." >&2 - return 1 - fi - AIA_BASE_URL="$2" - shift 2 - ;; - --ca-dir) - if [[ -z "$2" ]]; then - echo "ERROR: Missing value for --ca-dir." >&2 - return 1 - fi - CA_DIR="$2" - shift 2 - ;; - *) - break - ;; - esac - done - - local CA_NAME="$1" - shift 1 - CA_DIR="${CA_DIR:-${SIMPLE_CA_DIR:-$(pwd)}}" - - if [[ -z "$CA_DIR" || ! -d "$CA_DIR" ]]; then - echo "ERROR: Certificate directory $CA_DIR does not exist." - return 1 - fi - - if [[ -z "$CA_NAME" ]]; then - echo "ERROR: CA name is required." >&2 - return 1 - fi - - if [[ -z "$AIA_BASE_URL" && -f "$CA_DIR/aia_base_url.txt" ]]; then - AIA_BASE_URL="$(cat "$CA_DIR/aia_base_url.txt")" - 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..." - # Path length constraint of 1 is set for the root CA to allow creating one level of issuing CAs, - # but prevent creating a longer chain of CAs which is not supported by this script. - 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:1" \ - -addext "keyUsage=critical,keyCertSign,cRLSign"; then - echo "ERROR: Failed to generate CA certificate and key." >&2 - return 1 - fi - - # Rebuild the CA bundle (root + any issuing CAs) for use with `openssl verify -CAfile` - _rebuild_ca_bundle "$CA_DIR" - - if [[ -n "$AIA_BASE_URL" ]]; then - echo "$AIA_BASE_URL" > "$CA_DIR/aia_base_url.txt" - fi - - 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" \ - -addext "keyUsage=critical,keyCertSign,cRLSign" \ - ${AIA_BASE_URL:+-addext "authorityInfoAccess=caIssuers;URI:${AIA_BASE_URL}/ca_cert.crt"} \ - | 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 - - # Rebuild the CA bundle (root + any issuing CAs) for use with `openssl verify -CAfile` - _rebuild_ca_bundle "$CA_DIR" - - if [[ -n "$AIA_BASE_URL" ]]; then - echo "$AIA_BASE_URL" > "$CA_DIR/aia_base_url.txt" - fi - - 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="" - local CERT_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 - ;; - --cert-dir) - if [[ -z "$2" ]]; then - echo "ERROR: Missing value for --cert-dir." >&2 - return 1 - fi - CERT_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_SUBJECT_NAME="$1" - shift 1 - - CA_DIR="${CA_DIR:-${SIMPLE_CA_DIR:-$(pwd)}}" - - local AIA_BASE_URL_FILE="$CA_DIR/aia_base_url.txt" - local AIA_URL="" - if [[ -f "$AIA_BASE_URL_FILE" ]]; then - AIA_URL="$(cat "$AIA_BASE_URL_FILE")/${CA_FILE_PREFIX}_cert.crt" - fi - - 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=critical,digitalSignature,keyEncipherment" \ - -addext "extendedKeyUsage=serverAuth,clientAuth" \ - -addext "$SANS_EXT" \ - ${AIA_URL:+-addext "authorityInfoAccess=caIssuers;URI:${AIA_URL}"} \ - | 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 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 - ;; - --password) - if [[ -z "$2" ]]; then - echo "ERROR: Missing value for --password." >&2 - return 1 - fi - PFX_PASSWORD="$2" - shift 2 - ;; - *) break ;; - esac - done - - local CERT_PATH="$1" - shift 1 - - CA_DIR="${CA_DIR:-${SIMPLE_CA_DIR:-$(pwd)}}" - 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 -} - diff --git a/src/simple-ca/main.go b/src/simple-ca/main.go index f15784a..3d8eb42 100644 --- a/src/simple-ca/main.go +++ b/src/simple-ca/main.go @@ -27,22 +27,26 @@ import ( "crypto/rsa" "crypto/x509" "crypto/x509/pkix" + "encoding/json" "encoding/pem" "errors" "fmt" - - "github.com/spf13/cobra" "math/big" "net" "os" + "os/exec" "path/filepath" "regexp" + "sort" "strings" "time" + "github.com/spf13/cobra" "software.sslmate.com/src/go-pkcs12" ) +// ---- helpers ---------------------------------------------------------------- + func dirExists(path string) bool { info, err := os.Stat(path) return err == nil && info.IsDir() @@ -109,11 +113,84 @@ func loadKey(path string) (*rsa.PrivateKey, error) { return x509.ParsePKCS1PrivateKey(block.Bytes) } -func rebuildCABundle(caDir string) error { - entries, err := os.ReadDir(caDir) +// ---- Config ----------------------------------------------------------------- + +type daysConfig struct { + CA int `json:"ca,omitempty"` + Cert int `json:"cert,omitempty"` +} + +type configData struct { + OpenSSL string `json:"openssl,omitempty"` + AIABaseURL string `json:"aia_base_url,omitempty"` + IssuingCA string `json:"issuing_ca,omitempty"` + Days *daysConfig `json:"days,omitempty"` + Subordinates []string `json:"subordinates,omitempty"` +} + +type Config struct { + path string + data configData + // isNew is true when the file did not exist at load time, + // so the first mutation always creates it. + isNew bool +} + +func loadConfig(caDir string) *Config { + cfg := &Config{path: filepath.Join(caDir, "simple-ca.json")} + b, err := os.ReadFile(cfg.path) + if err != nil { + cfg.isNew = true + return cfg + } + if err := json.Unmarshal(b, &cfg.data); err != nil { + fmt.Fprintf(os.Stderr, "WARNING: could not read %s: %v\n", cfg.path, err) + cfg.isNew = true + } + return cfg +} + +func (c *Config) save() error { + b, err := json.MarshalIndent(c.data, "", " ") if err != nil { return err } + c.isNew = false + return os.WriteFile(c.path, append(b, '\n'), 0o644) +} + +// ensure writes the file if it did not exist at load time. +func (c *Config) ensure() error { + if c.isNew { + return c.save() + } + return nil +} + +// setAIABaseURL updates the stored URL (if changed) and persists. +func (c *Config) setAIABaseURL(url string) error { + if url != "" && c.data.AIABaseURL != url { + c.data.AIABaseURL = url + return c.save() + } + return c.ensure() +} + +// addSubordinate registers name in the subordinates list (if absent) and persists. +func (c *Config) addSubordinate(name string) error { + for _, s := range c.data.Subordinates { + if s == name { + return c.ensure() + } + } + c.data.Subordinates = append(c.data.Subordinates, name) + sort.Strings(c.data.Subordinates) + return c.save() +} + +// ---- CA bundle -------------------------------------------------------------- + +func rebuildCABundle(caDir string, cfg *Config) error { var bundle []byte rootPath := filepath.Join(caDir, "ca_cert.pem") if fileExists(rootPath) { @@ -123,12 +200,14 @@ func rebuildCABundle(caDir string) error { } bundle = append(bundle, data...) } - for _, e := range entries { - name := e.Name() - if e.IsDir() || name == "ca_cert.pem" || !strings.HasSuffix(name, "_cert.pem") { + subs := append([]string(nil), cfg.data.Subordinates...) + sort.Strings(subs) + for _, name := range subs { + subCert := filepath.Join(caDir, name, "ca_cert.pem") + if !fileExists(subCert) { continue } - data, err := os.ReadFile(filepath.Join(caDir, name)) + data, err := os.ReadFile(subCert) if err != nil { return err } @@ -137,7 +216,9 @@ func rebuildCABundle(caDir string) error { return os.WriteFile(filepath.Join(caDir, "ca_bundle.pem"), bundle, 0o644) } -func makeCA(caDir, caName string, days int, issuingCA, aiaBaseURL string) error { +// ---- makeCA ----------------------------------------------------------------- + +func makeCA(caDir, caName string, days int, issuingCA, aiaBaseURL string, cfg *Config) error { if issuingCA == "ca" { return errors.New("--issuing-ca cannot be 'ca' as it is reserved for the root CA") } @@ -148,26 +229,20 @@ func makeCA(caDir, caName string, days int, issuingCA, aiaBaseURL string) error return errors.New("CA name is required") } - aiaFile := filepath.Join(caDir, "aia_base_url.txt") + // Inherit AIA URL from config when not provided on CLI. if aiaBaseURL == "" { - if data, err := os.ReadFile(aiaFile); err == nil { - aiaBaseURL = strings.TrimSpace(string(data)) - } + aiaBaseURL = cfg.data.AIABaseURL } - prefix := issuingCA - if prefix == "" { - prefix = "ca" - } rootCertPath := filepath.Join(caDir, "ca_cert.pem") rootKeyPath := filepath.Join(caDir, "ca_key.pem") - caCertPath := filepath.Join(caDir, prefix+"_cert.pem") - caKeyPath := filepath.Join(caDir, prefix+"_key.pem") - isRoot := prefix == "ca" + // ---- root CA ---- if !fileExists(rootCertPath) || !fileExists(rootKeyPath) { - if !isRoot { - return fmt.Errorf("cannot create issuing CA '%s' without existing root CA certificate and key. Please create the root CA first", caName) + if issuingCA != "" { + return fmt.Errorf( + "cannot create issuing CA '%s' without existing root CA certificate and key. "+ + "Please create the root CA first", caName) } fmt.Printf("Generating CA certificate '%s' and key...\n", caName) key, err := rsa.GenerateKey(rand.Reader, 4096) @@ -199,19 +274,22 @@ func makeCA(caDir, caName string, days int, issuingCA, aiaBaseURL string) error if err := writeCert(der, rootCertPath); err != nil { return err } - if err := rebuildCABundle(caDir); err != nil { + if err := rebuildCABundle(caDir, cfg); err != nil { return err } - if aiaBaseURL != "" { - if err := os.WriteFile(aiaFile, []byte(aiaBaseURL), 0o644); err != nil { - return err - } - } - return nil + return cfg.setAIABaseURL(aiaBaseURL) } - if !fileExists(caCertPath) || !fileExists(caKeyPath) { + // ---- issuing CA ---- + issuingCADir := filepath.Join(caDir, issuingCA) + issuingCACert := filepath.Join(issuingCADir, "ca_cert.pem") + issuingCAKey := filepath.Join(issuingCADir, "ca_key.pem") + + if !fileExists(issuingCACert) || !fileExists(issuingCAKey) { fmt.Printf("Generating issuing CA certificate '%s' and key...\n", caName) + if err := os.MkdirAll(issuingCADir, 0o755); err != nil { + return err + } rootCert, err := loadCert(rootCertPath) if err != nil { return err @@ -247,38 +325,37 @@ func makeCA(caDir, caName string, days int, issuingCA, aiaBaseURL string) error if err != nil { return err } - if err := writeKey(key, caKeyPath); err != nil { + if err := writeKey(key, issuingCAKey); err != nil { return err } - if err := writeCert(der, caCertPath); err != nil { + if err := writeCert(der, issuingCACert); err != nil { return err } } - if err := rebuildCABundle(caDir); err != nil { + if err := cfg.addSubordinate(issuingCA); err != nil { return err } - if aiaBaseURL != "" { - if err := os.WriteFile(aiaFile, []byte(aiaBaseURL), 0o644); err != nil { + if aiaBaseURL != "" && aiaBaseURL != cfg.data.AIABaseURL { + cfg.data.AIABaseURL = aiaBaseURL + if err := cfg.save(); err != nil { return err } } - return nil + return rebuildCABundle(caDir, cfg) } +// ---- makeCert --------------------------------------------------------------- + var ( ipRE = regexp.MustCompile(`^[0-9]{1,3}(\.[0-9]{1,3}){3}$`) dnsRE = regexp.MustCompile(`^[a-z0-9-]+(\.[a-z0-9-]+)*$`) ) -func makeCert(certDir, subjectName string, sans []string, caDir, issuingCA string, days int) error { +func makeCert(certDir, subjectName string, sans []string, caDir, issuingCA string, days int, cfg *Config) error { if issuingCA == "ca" { return errors.New("--issuing-ca cannot be 'ca' as it is reserved for the root CA") } - prefix := issuingCA - if prefix == "" { - prefix = "ca" - } if certDir == "" || !dirExists(certDir) { return fmt.Errorf("certificate directory %s does not exist", certDir) } @@ -292,16 +369,25 @@ func makeCert(certDir, subjectName string, sans []string, caDir, issuingCA strin return fmt.Errorf("invalid subject name '%s'. Must be a valid DNS name", subjectName) } - aiaFile := filepath.Join(caDir, "aia_base_url.txt") - aiaURL := "" - if data, err := os.ReadFile(aiaFile); err == nil { - aiaURL = strings.TrimSpace(string(data)) + "/" + prefix + "_cert.crt" + signingDir := caDir + if issuingCA != "" { + signingDir = filepath.Join(caDir, issuingCA) + } + caCertPath := filepath.Join(signingDir, "ca_cert.pem") + caKeyPath := filepath.Join(signingDir, "ca_key.pem") + if !fileExists(caCertPath) || !fileExists(caKeyPath) { + return fmt.Errorf( + "signing CA certificate and key not found in %s. Please set up a signing CA first", + signingDir) } - caCertPath := filepath.Join(caDir, prefix+"_cert.pem") - caKeyPath := filepath.Join(caDir, prefix+"_key.pem") - if !fileExists(caCertPath) || !fileExists(caKeyPath) { - return fmt.Errorf("signing CA certificate and key not found in %s. Please call setup a signing CA first", caDir) + aiaURL := "" + if base := cfg.data.AIABaseURL; base != "" { + if issuingCA != "" { + aiaURL = base + "/" + issuingCA + "/ca_cert.crt" + } else { + aiaURL = base + "/ca_cert.crt" + } } certName := subjectName @@ -379,22 +465,18 @@ func makeCert(certDir, subjectName string, sans []string, caDir, issuingCA strin return writeCert(der, certOut) } -func makePFX(certPath, caDir, issuingCA, password string) error { +// ---- makePFX ---------------------------------------------------------------- + +func makePFX(certPath, caDir, issuingCA, password string, appleOpenSSL bool) error { if issuingCA == "ca" { return errors.New("--issuing-ca cannot be 'ca' as it is reserved for the root CA") } - prefix := issuingCA - if prefix == "" { - prefix = "ca" - } - rootCertPath := filepath.Join(caDir, "ca_cert.pem") - rootKeyPath := filepath.Join(caDir, "ca_key.pem") - caCertPath := filepath.Join(caDir, prefix+"_cert.pem") - caKeyPath := filepath.Join(caDir, prefix+"_key.pem") certDir := filepath.Dir(certPath) certName := strings.TrimSuffix(filepath.Base(certPath), "_cert.pem") keyPath := filepath.Join(certDir, certName+"_key.pem") + pfxPath := filepath.Join(certDir, certName+".pfx") + rootCertPath := filepath.Join(caDir, "ca_cert.pem") if !dirExists(certDir) { return fmt.Errorf("certificate directory %s does not exist", certDir) @@ -405,12 +487,15 @@ func makePFX(certPath, caDir, issuingCA, password string) error { if !fileExists(certPath) || !fileExists(keyPath) { return errors.New("server certificate or key not found") } - if !fileExists(rootCertPath) || !fileExists(rootKeyPath) { - return fmt.Errorf("CA certificate or key not found in %s", caDir) + if !fileExists(rootCertPath) { + return fmt.Errorf("CA certificate not found in %s", caDir) } + + var issuingCACertPath string if issuingCA != "" { - if !fileExists(caCertPath) || !fileExists(caKeyPath) { - return fmt.Errorf("issuing CA certificate or key not found in %s", caDir) + issuingCACertPath = filepath.Join(caDir, issuingCA, "ca_cert.pem") + if !fileExists(issuingCACertPath) { + return fmt.Errorf("issuing CA certificate not found: %s", issuingCACertPath) } } @@ -418,7 +503,6 @@ func makePFX(certPath, caDir, issuingCA, password string) error { password = "changeit" } - pfxPath := filepath.Join(certDir, certName+".pfx") if fileExists(pfxPath) { fmt.Println("PKCS#12 (PFX) file already exists, aborting generation.") return errors.New("PFX file already exists") @@ -426,6 +510,10 @@ func makePFX(certPath, caDir, issuingCA, password string) error { fmt.Print("Generating PKCS#12 (PFX) file...") + if appleOpenSSL { + return makePFXViaAppleOpenSSL(certPath, keyPath, rootCertPath, issuingCACertPath, pfxPath, password) + } + cert, err := loadCert(certPath) if err != nil { return err @@ -434,14 +522,13 @@ func makePFX(certPath, caDir, issuingCA, password string) error { if err != nil { return err } - chain := []*x509.Certificate{} root, err := loadCert(rootCertPath) if err != nil { return err } - chain = append(chain, root) + chain := []*x509.Certificate{root} if issuingCA != "" { - issuing, err := loadCert(caCertPath) + issuing, err := loadCert(issuingCACertPath) if err != nil { return err } @@ -459,6 +546,53 @@ func makePFX(certPath, caDir, issuingCA, password string) error { return nil } +func makePFXViaAppleOpenSSL(certPath, keyPath, rootCertPath, issuingCACertPath, pfxPath, password string) error { + chainFile, err := os.CreateTemp("", "chain-*.pem") + if err != nil { + return err + } + defer os.Remove(chainFile.Name()) + + rootData, err := os.ReadFile(rootCertPath) + if err != nil { + chainFile.Close() + return err + } + if _, err := chainFile.Write(rootData); err != nil { + chainFile.Close() + return err + } + if issuingCACertPath != "" { + issuingData, err := os.ReadFile(issuingCACertPath) + if err != nil { + chainFile.Close() + return err + } + if _, err := chainFile.Write(issuingData); err != nil { + chainFile.Close() + return err + } + } + chainFile.Close() + + cmd := exec.Command("/usr/bin/openssl", "pkcs12", + "-export", "-out", pfxPath, + "-inkey", keyPath, + "-in", certPath, + "-certfile", chainFile.Name(), + "-password", "pass:"+password, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("apple openssl pkcs12 failed: %w", err) + } + fmt.Println("done.") + return nil +} + +// ---- CLI -------------------------------------------------------------------- + func resolveCADir(flagVal string) string { if flagVal != "" { return flagVal @@ -492,12 +626,26 @@ func newMakeCACmd() *cobra.Command { Use: "make-ca CA_NAME", Short: "Create a root or issuing CA.", Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return makeCA(resolveCADir(caDir), args[0], days, issuingCA, aiaBaseURL) + RunE: func(c *cobra.Command, args []string) error { + dir := resolveCADir(caDir) + cfg := loadConfig(dir) + effectiveDays := days + if !c.Flags().Changed("days") { + if cfg.data.Days != nil && cfg.data.Days.CA > 0 { + effectiveDays = cfg.data.Days.CA + } else { + effectiveDays = 3650 + } + } + effectiveIssuingCA := issuingCA + if !c.Flags().Changed("issuing-ca") && cfg.data.IssuingCA != "" { + effectiveIssuingCA = cfg.data.IssuingCA + } + return makeCA(dir, args[0], effectiveDays, effectiveIssuingCA, aiaBaseURL, cfg) }, } cmd.Flags().IntVar(&days, "days", 3650, "validity period in days") - cmd.Flags().StringVar(&issuingCA, "issuing-ca", "", "issuing CA file prefix (creates an issuing CA signed by the root)") + cmd.Flags().StringVar(&issuingCA, "issuing-ca", "", "issuing CA directory name (creates an issuing CA signed by the root)") cmd.Flags().StringVar(&aiaBaseURL, "aia-base-url", "", "base URL for the AIA caIssuers extension") cmd.Flags().StringVar(&caDir, "ca-dir", "", "directory for CA files") return cmd @@ -514,34 +662,56 @@ func newMakeCertCmd() *cobra.Command { Use: "make-cert SUBJECT [SAN...]", Short: "Create a server/client certificate signed by the CA.", Args: cobra.MinimumNArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return makeCert(certDir, args[0], args[1:], resolveCADir(caDir), issuingCA, days) + RunE: func(c *cobra.Command, args []string) error { + dir := resolveCADir(caDir) + cfg := loadConfig(dir) + effectiveDays := days + if !c.Flags().Changed("days") { + if cfg.data.Days != nil && cfg.data.Days.Cert > 0 { + effectiveDays = cfg.data.Days.Cert + } else { + effectiveDays = 365 + } + } + effectiveIssuingCA := issuingCA + if !c.Flags().Changed("issuing-ca") && cfg.data.IssuingCA != "" { + effectiveIssuingCA = cfg.data.IssuingCA + } + return makeCert(certDir, args[0], args[1:], dir, effectiveIssuingCA, effectiveDays, cfg) }, } cmd.Flags().StringVar(&certDir, "cert-dir", "", "directory to store the certificate files") cmd.Flags().StringVar(&caDir, "ca-dir", "", "CA directory") - cmd.Flags().StringVar(&issuingCA, "issuing-ca", "", "issuing CA file prefix") + cmd.Flags().StringVar(&issuingCA, "issuing-ca", "", "issuing CA directory name") cmd.Flags().IntVar(&days, "days", 365, "validity period in days") return cmd } func newMakePFXCmd() *cobra.Command { var ( - caDir string - issuingCA string - password string + caDir string + issuingCA string + password string + appleOpenSSL bool ) cmd := &cobra.Command{ Use: "make-pfx CERT_PATH", Short: "Create a PKCS#12 (PFX) bundle for a leaf certificate.", Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return makePFX(args[0], resolveCADir(caDir), issuingCA, password) + RunE: func(c *cobra.Command, args []string) error { + dir := resolveCADir(caDir) + cfg := loadConfig(dir) + effectiveIssuingCA := issuingCA + if !c.Flags().Changed("issuing-ca") && cfg.data.IssuingCA != "" { + effectiveIssuingCA = cfg.data.IssuingCA + } + return makePFX(args[0], dir, effectiveIssuingCA, password, appleOpenSSL) }, } cmd.Flags().StringVar(&caDir, "ca-dir", "", "CA directory") - cmd.Flags().StringVar(&issuingCA, "issuing-ca", "", "issuing CA file prefix") + cmd.Flags().StringVar(&issuingCA, "issuing-ca", "", "issuing CA directory name") cmd.Flags().StringVar(&password, "password", "", "PFX password (default: changeit)") + cmd.Flags().BoolVar(&appleOpenSSL, "apple-openssl", false, "use Apple's bundled /usr/bin/openssl for PKCS12 generation") return cmd }