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.
This commit is contained in:
+62
-9
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user