diff --git a/README.md b/README.md index 13abcc1..bd4ebab 100644 --- a/README.md +++ b/README.md @@ -56,45 +56,6 @@ make_pfx --ca-dir [--issuing-ca ] --path `: The path where the generated PFX file will be saved. - `--password `: Optional. The custom password to protect the PFX, instead of the default `changeit`. -## Go binary - -A Go port with the same feature set lives in [`src/simple-ca`](src/simple-ca). It compiles to a single self-contained binary (~5–6 MB) with no runtime dependencies, and exposes `make-ca`, `make-cert`, and `make-pfx` as subcommands mirroring the Bash flag names. - -### Build for the host platform - -```bash -cd src/simple-ca -go build -o simple-ca . -./simple-ca --help -``` - -### Cross-compile - -Go builds statically linked binaries for any target from any host: - -```bash -cd src/simple-ca - -# Linux -GOOS=linux GOARCH=amd64 go build -o simple-ca-linux-amd64 . -GOOS=linux GOARCH=arm64 go build -o simple-ca-linux-arm64 . - -# macOS -GOOS=darwin GOARCH=amd64 go build -o simple-ca-darwin-amd64 . -GOOS=darwin GOARCH=arm64 go build -o simple-ca-darwin-arm64 . - -# Windows -GOOS=windows GOARCH=amd64 go build -o simple-ca-windows-amd64.exe . -``` - -### Usage - -```bash -simple-ca make-ca [--days N] [--issuing-ca PREFIX] [--aia-base-url URL] CA_DIR CA_NAME -simple-ca make-cert [--ca-dir DIR] [--days N] [--issuing-ca PREFIX] CERT_DIR SUBJECT [SAN...] -simple-ca make-pfx --ca-dir DIR [--issuing-ca PREFIX] --path CERT_PATH [--password PASS] -``` - ## generate-mobileconfig.py `generate-mobileconfig.py` generates Apple `.mobileconfig` profiles for distributing CA certificates and optionally client certificates and IKEv2 VPN configuration to Apple devices (macOS / iOS / iPadOS). diff --git a/run-tests.sh b/run-tests.sh index f0f6893..6448e08 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -21,8 +21,8 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -# This script runs integration tests against one or more simple-ca implementations. -# Usage: run-tests.sh [python|go|all] (default: all) +# This script runs integration tests against the simple-ca Python implementation. +# Usage: run-tests.sh [python|all] (default: all) set -e TEST_TARGET="${1:-all}" @@ -149,6 +149,67 @@ run_pfx_algorithm_tests() { fi } +# run_crl_tests NAME MAKE_CA_CMD MAKE_CERT_CMD MAKE_PFX_CMD REVOKE_CMD MAKE_CRL_CMD +run_crl_tests() { + local NAME="$1" + local MAKE_CA_CMD="$2" + local MAKE_CERT_CMD="$3" + local REVOKE_CMD="$4" + local MAKE_CRL_CMD="$5" + + echo + echo "--- [$NAME] CRL tests ---" + clean_up_test_dir + + # Build a two-level CA hierarchy + $MAKE_CA_CMD --ca-dir "$CA_DIR" "CRL Test CA" + $MAKE_CA_CMD --ca-dir "$CA_DIR" --issuing-ca "issuing_ca" "Issuing CA" + + # Issue two certs; revoke the first; keep the second active + $MAKE_CERT_CMD --ca-dir "$CA_DIR" --cert-dir "$CERT_DIR" --issuing-ca "issuing_ca" \ + "alice" "alice.example.com" + $MAKE_CERT_CMD --ca-dir "$CA_DIR" --cert-dir "$CERT_DIR" --issuing-ca "issuing_ca" \ + "bob" "bob.example.com" + + $REVOKE_CMD --ca-dir "$CA_DIR" --issuing-ca "issuing_ca" "$CERT_DIR/alice_cert.pem" + + # Generate CRL for the issuing CA + $MAKE_CRL_CMD --ca-dir "$CA_DIR" --issuing-ca "issuing_ca" + [[ -f "$CA_DIR/issuing_ca/crl.pem" ]] || { echo "ERROR: issuing_ca/crl.pem not created" >&2; exit 1; } + + # alice's serial must appear in the issuing CA CRL + CRL_TEXT=$(openssl crl -in "$CA_DIR/issuing_ca/crl.pem" -noout -text 2>/dev/null) + ALICE_SERIAL=$(openssl x509 -in "$CERT_DIR/alice_cert.pem" -noout -serial | cut -d= -f2) + if echo "$CRL_TEXT" | grep -qi "$ALICE_SERIAL"; then + echo "CRL [issuing_ca]: alice's serial found — OK" + else + echo "ERROR: CRL [issuing_ca]: alice's serial not found in CRL" >&2 + echo "$CRL_TEXT" >&2 + exit 1 + fi + + # bob's serial must NOT appear in the issuing CA CRL + BOB_SERIAL=$(openssl x509 -in "$CERT_DIR/bob_cert.pem" -noout -serial | cut -d= -f2) + if echo "$CRL_TEXT" | grep -qi "$BOB_SERIAL"; then + echo "ERROR: CRL [issuing_ca]: bob's serial unexpectedly found in CRL" >&2 + exit 1 + else + echo "CRL [issuing_ca]: bob's serial absent — OK" + fi + + # Root CA CRL should be empty (no revoctions at root level) + $MAKE_CRL_CMD --ca-dir "$CA_DIR" + [[ -f "$CA_DIR/crl.pem" ]] || { echo "ERROR: root crl.pem not created" >&2; exit 1; } + ROOT_CRL_TEXT=$(openssl crl -in "$CA_DIR/crl.pem" -noout -text 2>/dev/null) + if echo "$ROOT_CRL_TEXT" | grep -q "No Revoked Certificates"; then + echo "CRL [root]: empty — OK" + else + echo "ERROR: CRL [root]: expected empty CRL" >&2 + echo "$ROOT_CRL_TEXT" >&2 + exit 1 + fi +} + # Uses ;;& to fall through to subsequent patterns so 'all' matches every block. case "$TEST_TARGET" in python|all) @@ -156,20 +217,12 @@ case "$TEST_TARGET" in PY_PREFIX="python3 $SCRIPT_DIR/simple-ca.py" run_flow "python" "$PY_PREFIX make-ca" "$PY_PREFIX make-cert" "$PY_PREFIX make-pfx" run_pfx_algorithm_tests "python" "$PY_PREFIX make-ca" "$PY_PREFIX make-cert" "$PY_PREFIX make-pfx" + run_crl_tests "python" "$PY_PREFIX make-ca" "$PY_PREFIX make-cert" "$PY_PREFIX revoke-cert" "$PY_PREFIX make-crl" ;;& - go|all) - command -v go >/dev/null || { echo "ERROR: go not found" >&2; exit 1; } - GO_SRC="$SCRIPT_DIR/src/simple-ca" - GO_BIN="$GO_SRC/simple-ca" - 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" - ;;& - python|go|all) + python|all) ;; *) - echo "ERROR: unknown target '$TEST_TARGET' (expected: python|go|all)" >&2 + echo "ERROR: unknown target '$TEST_TARGET' (expected: python|all)" >&2 exit 1 ;; esac diff --git a/simple-ca.py b/simple-ca.py index 30feca0..39696d2 100755 --- a/simple-ca.py +++ b/simple-ca.py @@ -24,14 +24,17 @@ # This module requires Python 3.8+ and OpenSSL to be installed on the system. import argparse +import datetime import json import os import re import subprocess import sys +import tempfile OPENSSL = "openssl" + class Config: FILE = "simple-ca.json" @@ -56,6 +59,26 @@ class Config: self._data.update(patch) self._save() + def append_history(self, ca_key: str, entry: dict): + history = self._data.get("history", {}) + history.setdefault(ca_key, []).append(entry) + self._data["history"] = history + self._save() + + def revoke_in_history(self, ca_key: str, serial: str, revoked_at: str): + """Mark a certificate as revoked. Returns True if newly revoked, + False if already revoked, None if serial not found.""" + history = self._data.get("history", {}) + for entry in history.get(ca_key, []): + if entry.get("serial") == serial: + if "revoked" in entry: + return False + entry["revoked"] = revoked_at + self._data["history"] = history + self._save() + return True + return None + def _save(self): with open(self._path, "w") as f: json.dump(self._data, f, indent=2) @@ -65,12 +88,15 @@ class Config: _config: Config +def _now() -> str: + return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + def _err(msg): print(f"ERROR: {msg}", file=sys.stderr) def _rebuild_ca_bundle(ca_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") @@ -86,7 +112,54 @@ def _rebuild_ca_bundle(ca_dir): f.write(b"".join(parts)) -def make_ca(ca_dir, ca_name, days=3650, issuing_ca=None, aia_base_url=None): +def _read_serial(cert_path) -> str: + result = subprocess.run( + [OPENSSL, "x509", "-in", cert_path, "-noout", "-serial"], + capture_output=True, text=True, + ) + return result.stdout.strip().split("=", 1)[-1] + + +def _read_expiry(cert_path) -> str: + """Return the certificate notAfter date as an ISO 8601 UTC string.""" + result = subprocess.run( + [OPENSSL, "x509", "-in", cert_path, "-noout", "-enddate"], + capture_output=True, text=True, + ) + raw = result.stdout.strip().split("=", 1)[-1] + raw = " ".join(raw.split()) # normalize whitespace ("May 4" → "May 4") + dt = datetime.datetime.strptime(raw, "%b %d %H:%M:%S %Y GMT") + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _iso_to_asn1(iso: str) -> str: + """Convert "2026-05-24T14:28:10Z" → "260524142810Z" for OpenSSL index.txt.""" + dt = datetime.datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ") + return dt.strftime("%y%m%d%H%M%SZ") + + +_IP_RE = re.compile(r"^[0-9]{1,3}(\.[0-9]{1,3}){3}$") +_DNS_RE = re.compile(r"^[a-z0-9-]+(\.[a-z0-9-]+)*$") + + +def _is_ip(value): + return bool(_IP_RE.match(value)) + + +def _is_dns(value): + return bool(_DNS_RE.match(value)) + + +def _pipe(cmd1, cmd2): + p1 = subprocess.Popen(cmd1, stdout=subprocess.PIPE) + p2 = subprocess.Popen(cmd2, stdin=p1.stdout) + p1.stdout.close() + p2.communicate() + p1.wait() + return p1.returncode == 0 and p2.returncode == 0 + + +def make_ca(ca_dir, ca_name, days=3650, issuing_ca=None, ca_publish_base_url=None): if issuing_ca == "ca": _err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.") return False @@ -111,9 +184,7 @@ def make_ca(ca_dir, ca_name, days=3650, issuing_ca=None, aia_base_url=None): return False print(f"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 a longer chain which is not - # supported by this script. + # Path length constraint of 1: allows one level of issuing CAs. cmd = [ OPENSSL, "req", "-x509", @@ -132,7 +203,10 @@ def make_ca(ca_dir, ca_name, days=3650, issuing_ca=None, aia_base_url=None): return False _rebuild_ca_bundle(ca_dir) - _config.update({"aia_base_url": aia_base_url} if aia_base_url else {}) + patch = {"name": ca_name, "created": _now()} + if ca_publish_base_url: + patch["ca_publish_base_url"] = ca_publish_base_url + _config.update(patch) return True issuing_ca_dir = os.path.join(ca_dir, issuing_ca) @@ -151,10 +225,10 @@ def make_ca(ca_dir, ca_name, days=3650, issuing_ca=None, aia_base_url=None): "-addext", "basicConstraints=critical,CA:TRUE,pathlen:0", "-addext", "keyUsage=critical,keyCertSign,cRLSign", ] - if aia_base_url: + if ca_publish_base_url: req_cmd += [ - "-addext", - f"authorityInfoAccess=caIssuers;URI:{aia_base_url}/ca_cert.crt", + "-addext", f"authorityInfoAccess=caIssuers;URI:{ca_publish_base_url}/ca_cert.crt", + "-addext", f"crlDistributionPoints=URI:{ca_publish_base_url}/crl.pem", ] x509_cmd = [ OPENSSL, "x509", @@ -170,7 +244,7 @@ def make_ca(ca_dir, ca_name, days=3650, issuing_ca=None, aia_base_url=None): _err("Failed to generate issuing CA certificate and key.") return False - patch = {"aia_base_url": aia_base_url} if aia_base_url else {} + patch = {"ca_publish_base_url": ca_publish_base_url} if ca_publish_base_url else {} subs = _config.get("subordinates", []) if issuing_ca not in subs: patch["subordinates"] = subs + [issuing_ca] @@ -179,29 +253,8 @@ def make_ca(ca_dir, ca_name, days=3650, issuing_ca=None, aia_base_url=None): return True -_IP_RE = re.compile(r"^[0-9]{1,3}(\.[0-9]{1,3}){3}$") -_DNS_RE = re.compile(r"^[a-z0-9-]+(\.[a-z0-9-]+)*$") - - -def _is_ip(value): - return bool(_IP_RE.match(value)) - - -def _is_dns(value): - return bool(_DNS_RE.match(value)) - - -def _pipe(cmd1, cmd2): - p1 = subprocess.Popen(cmd1, stdout=subprocess.PIPE) - p2 = subprocess.Popen(cmd2, stdin=p1.stdout) - p1.stdout.close() - p2.communicate() - p1.wait() - return p1.returncode == 0 and p2.returncode == 0 - - def make_cert(cert_dir, cert_subject_name, sans=None, ca_dir=None, - issuing_ca=None, days=365, aia_base_url=None): + issuing_ca=None, days=365, ca_publish_base_url=None): if issuing_ca == "ca": _err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.") return False @@ -224,7 +277,7 @@ 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 - signing_dir = os.path.join(ca_dir, issuing_ca) if issuing_ca else ca_dir + 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): @@ -234,12 +287,15 @@ def make_cert(cert_dir, cert_subject_name, sans=None, ca_dir=None, ) 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 = "" + aia_url = cdp_url = "" + if ca_publish_base_url: + if issuing_ca: + aia_url = f"{ca_publish_base_url}/{issuing_ca}/ca_cert.crt" + cdp_url = f"{ca_publish_base_url}/{issuing_ca}/crl.pem" + else: + aia_url = f"{ca_publish_base_url}/ca_cert.crt" + cdp_url = f"{ca_publish_base_url}/crl.pem" - # "account" name from the subject: hostname part before the first dot cert_name = cert_subject_name.split(".", 1)[0] san_entries = [f"DNS:{cert_subject_name}"] @@ -259,7 +315,7 @@ def make_cert(cert_dir, cert_subject_name, sans=None, ca_dir=None, print(f" - {san}") cert_out = os.path.join(cert_dir, f"{cert_name}_cert.pem") - key_out = os.path.join(cert_dir, f"{cert_name}_key.pem") + key_out = os.path.join(cert_dir, f"{cert_name}_key.pem") if not os.path.isfile(cert_out) or not os.path.isfile(key_out): print("Generating server certificate and key...") @@ -276,6 +332,8 @@ def make_cert(cert_dir, cert_subject_name, sans=None, ca_dir=None, ] if aia_url: req_cmd += ["-addext", f"authorityInfoAccess=caIssuers;URI:{aia_url}"] + if cdp_url: + req_cmd += ["-addext", f"crlDistributionPoints=URI:{cdp_url}"] x509_cmd = [ OPENSSL, "x509", "-req", @@ -290,6 +348,13 @@ def make_cert(cert_dir, cert_subject_name, sans=None, ca_dir=None, _err("Failed to generate server certificate and key.") return False + _config.append_history(issuing_ca or "ca", { + "name": cert_subject_name, + "serial": _read_serial(cert_out), + "created": _now(), + "expires": _read_expiry(cert_out), + }) + return True @@ -298,13 +363,10 @@ def make_pfx(cert_path, ca_dir, issuing_ca=None, password=None, apple_openssl=Fa _err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.") return False - cert_dir = os.path.dirname(cert_path) + cert_dir = os.path.dirname(cert_path) cert_basename = os.path.basename(cert_path) - if cert_basename.endswith("_cert.pem"): - cert_name = cert_basename[: -len("_cert.pem")] - else: - cert_name = cert_basename - key_path = os.path.join(cert_dir, f"{cert_name}_key.pem") + cert_name = cert_basename[:-len("_cert.pem")] if cert_basename.endswith("_cert.pem") else cert_basename + key_path = os.path.join(cert_dir, f"{cert_name}_key.pem") if not cert_dir or not os.path.isdir(cert_dir): _err(f"Certificate directory {cert_dir} does not exist.") @@ -347,7 +409,6 @@ def make_pfx(cert_path, ca_dir, issuing_ca=None, password=None, apple_openssl=Fa with open(issuing_ca_cert_path, "rb") as f: chain_bytes += f.read() - import tempfile chain_fd, chain_file = tempfile.mkstemp() try: with os.fdopen(chain_fd, "wb") as f: @@ -371,6 +432,82 @@ def make_pfx(cert_path, ca_dir, issuing_ca=None, password=None, apple_openssl=Fa return True +def make_crl(ca_dir, issuing_ca=None, days=30): + signing_dir = os.path.join(ca_dir, issuing_ca) if issuing_ca else ca_dir + ca_cert = os.path.join(signing_dir, "ca_cert.pem") + ca_key = os.path.join(signing_dir, "ca_key.pem") + + if not os.path.isfile(ca_cert) or not os.path.isfile(ca_key): + _err(f"CA certificate or key not found in {signing_dir}.") + return False + + crl_path = os.path.join(signing_dir, "crl.pem") + + history_key = issuing_ca or "ca" + revoked_entries = [ + e for e in _config.get("history", {}).get(history_key, []) + if "revoked" in e and "expires" in e + ] + + with tempfile.TemporaryDirectory() as tmp: + index_txt = os.path.join(tmp, "index.txt") + crlnumber = os.path.join(tmp, "crlnumber") + cnf_path = os.path.join(tmp, "openssl.cnf") + + with open(index_txt, "w") as f: + for e in revoked_entries: + expires_asn1 = _iso_to_asn1(e["expires"]) + revoked_asn1 = _iso_to_asn1(e["revoked"]) + f.write(f"R\t{expires_asn1}\t{revoked_asn1}\t{e['serial']}\tunknown\t/CN={e['name']}\n") + with open(crlnumber, "w") as f: + f.write("01\n") + with open(cnf_path, "w") as f: + f.write( + "[ ca ]\ndefault_ca = CA_default\n\n" + "[ CA_default ]\n" + f"database = {index_txt}\n" + f"crlnumber = {crlnumber}\n" + f"certificate = {ca_cert}\n" + f"private_key = {ca_key}\n" + f"default_crl_days = {days}\n" + "default_md = sha256\n\n" + "[ crl_ext ]\n" + "authorityKeyIdentifier = keyid:always\n" + ) + + if subprocess.run([OPENSSL, "ca", "-gencrl", "-config", cnf_path, "-out", crl_path]).returncode != 0: + _err("Failed to generate CRL.") + return False + + print(f"CRL written to {crl_path}") + return True + + +def revoke_cert(cert_path, ca_dir, issuing_ca=None): + if not os.path.isfile(cert_path): + _err(f"Certificate not found: {cert_path}") + return False + + signing_dir = os.path.join(ca_dir, issuing_ca) if issuing_ca else ca_dir + if not os.path.isdir(signing_dir): + _err(f"CA directory not found: {signing_dir}") + return False + + ca_key = issuing_ca or "ca" + serial = _read_serial(cert_path) + result = _config.revoke_in_history(ca_key, serial, _now()) + + if result is None: + _err(f"Certificate with serial {serial} not found in history for CA '{ca_key}'.") + return False + if result is False: + print(f"Certificate {cert_path} (serial {serial}) is already revoked.") + return True + + print(f"Certificate {cert_path} (serial {serial}) marked as revoked.") + return True + + def _build_parser(): parser = argparse.ArgumentParser( description="Simple CA for creating and managing test certificates." @@ -380,7 +517,8 @@ def _build_parser(): p_ca = sub.add_parser("make-ca", help="Create a root or issuing CA.") p_ca.add_argument("--days", type=int, default=None, help="Validity period in days (default: 3650)") p_ca.add_argument("--issuing-ca", default=None, help="Specify the issuing CA") - p_ca.add_argument("--aia-base-url", default=None, help="Specify the AIA base URL") + p_ca.add_argument("--ca-publish-base-url", default=None, + help="Base URL for AIA and CRL distribution point extensions") p_ca.add_argument("--ca-dir", help="Directory to store the CA files") p_ca.add_argument("--openssl", default=None, metavar="PATH", help=f"Path to the openssl binary (default: {OPENSSL})") @@ -404,6 +542,16 @@ def _build_parser(): help="Use Apple's bundled /usr/bin/openssl for PKCS12 generation") p_pfx.add_argument("path", help="Path to the certificate file") + p_crl = sub.add_parser("make-crl", help="Generate a CRL for a CA.") + p_crl.add_argument("--ca-dir", help="Directory of the CA") + p_crl.add_argument("--issuing-ca", default=None, help="Generate CRL for this issuing CA") + p_crl.add_argument("--days", type=int, default=None, help="CRL validity in days (default: 30)") + + p_rev = sub.add_parser("revoke-cert", help="Revoke a certificate.") + p_rev.add_argument("--ca-dir", help="Directory of the CA") + p_rev.add_argument("--issuing-ca", default=None, help="Issuing CA that signed the certificate") + p_rev.add_argument("cert_path", help="Path to the certificate file to revoke") + return parser @@ -416,9 +564,9 @@ def main(argv=None): _config = Config(ca_dir) - 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") + OPENSSL = getattr(args, "openssl", None) or _config.get("openssl", OPENSSL) + issuing_ca = args.issuing_ca or _config.get("issuing_ca") + ca_publish_base_url = getattr(args, "ca_publish_base_url", None) or _config.get("ca_publish_base_url") days_cfg = _config.get("days", {}) @@ -428,7 +576,7 @@ def main(argv=None): ca_dir, args.ca_name, days=days, issuing_ca=issuing_ca, - aia_base_url=aia_base_url, + ca_publish_base_url=ca_publish_base_url, ) elif args.command == "make-cert": days = args.days or days_cfg.get("cert", 365) @@ -438,7 +586,7 @@ def main(argv=None): ca_dir=ca_dir, issuing_ca=issuing_ca, days=days, - aia_base_url=aia_base_url, + ca_publish_base_url=ca_publish_base_url, ) elif args.command == "make-pfx": ok = make_pfx( @@ -447,6 +595,11 @@ def main(argv=None): password=args.password, apple_openssl=args.apple_openssl, ) + elif args.command == "make-crl": + days = args.days or days_cfg.get("crl", 30) + ok = make_crl(ca_dir, issuing_ca=issuing_ca, days=days) + elif args.command == "revoke-cert": + ok = revoke_cert(args.cert_path, ca_dir, issuing_ca=issuing_ca) else: parser.error(f"Unknown command: {args.command}") ok = False