Refactor CA management and certificate generation
/ test-bash (push) Failing after 3s
/ test-python (push) Successful in 18s
/ test-go (push) Successful in 9m50s

- 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.
This commit is contained in:
2026-05-24 14:39:47 +02:00
parent 93fc382f9a
commit 16c228d2b1
4 changed files with 391 additions and 617 deletions
+62 -9
View File
@@ -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
+81 -79
View File
@@ -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}")
-451
View File
@@ -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
}
+248 -78
View File
@@ -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
}