blob: 82a6db4fcbadd5fce01a8db1575ad05a6c880fe1 [file]
"""Generate the SSL Certificate Authority and test certificates.
Python port of Apache::TestSSLCA. Builds, under ``t/conf/ssl/ca/asf``, a self-
signed CA plus server and client certificates that the SSL test configs reference
(``server.crt``/``server.pem``, ``server_des3.*``, ``client_ok``/``client_snakeoil``/
``client_revoked``/``client_colon``, proxy client PEMs, and a CRL). The layout and
filenames match the Perl framework so the existing ``t/conf/ssl/*.conf.in`` files
work unchanged.
Notes vs. the Perl original:
* RSA keys are 2048-bit, digest sha256 (matches modern openssl defaults).
* DSA server variants (``server_dsa`` etc.) are generated too, for config files
that may reference them; on OpenSSL 3.x DSA keygen still works.
* All passphrases are ``httpd`` (``$pass`` in the original).
"""
from __future__ import annotations
import subprocess
from pathlib import Path
PASS = "httpd"
DAYS = "365"
DGST = "sha256"
EMAIL_FIELD = "emailAddress"
# CA distinguished name (ca_dn{asf}).
CA_DN = {
"C": "US",
"ST": "California",
"L": "San Francisco",
"O": "ASF",
"OU": "httpd-test",
"CN": "",
EMAIL_FIELD: "test-dev@httpd.apache.org",
}
# Per-cert DN overrides (cert_dn). CN for server* is set to the servername at
# generation time (matching generate() in the Perl module).
CERT_DN: dict[str, dict[str, str]] = {
"client_snakeoil": {
"C": "AU", "ST": "Queensland", "L": "Mackay",
"O": "Snake Oil, Ltd.", "OU": "Staff",
},
"client_ok": {},
"client_colon": {"CN": "user:colon"},
"client_revoked": {},
"server": {"CN": "localhost", "OU": "httpd-test/rsa-test"},
"server2": {"CN": "localhost", "OU": "httpd-test/rsa-test-2"},
"server_des3": {"CN": "localhost", "OU": "httpd-test/rsa-des3-test"},
"server2_des3": {"CN": "localhost", "OU": "httpd-test/rsa-des3-test-2"},
}
# DSA variants of every server cert (Perl derives these dynamically).
for _k in list(CERT_DN):
if _k.startswith("server"):
_dsa = dict(CERT_DN[_k])
_dsa["OU"] = _dsa.get("OU", "").replace("rsa", "dsa")
CERT_DN[f"{_k}_dsa"] = _dsa
class SSLCA:
def __init__(self, ca_root: Path, servername: str, *, openssl: str = "openssl") -> None:
# ca_root is e.g. t/conf/ssl/ca ; the CA tree lives under ca_root/asf.
self.root = ca_root
self.ca = "asf"
self.servername = servername
self.openssl = openssl
self.dir = ca_root / self.ca
# -- helpers ----------------------------------------------------------
def _run(self, *args: str, cwd: Path) -> None:
cmd = [self.openssl, *args]
proc = subprocess.run( # noqa: S603 - trusted openssl invocation
cmd, cwd=cwd, capture_output=True, text=True, check=False
)
if proc.returncode != 0:
raise RuntimeError(
f"openssl failed: {' '.join(cmd)}\n{proc.stdout}\n{proc.stderr}"
)
def _dn(self, name: str) -> dict[str, str]:
dn = dict(CA_DN)
dn["CN"] = dn["CN"] or name
dn.update(CERT_DN.get(name, {}))
if name.startswith("server"):
dn["CN"] = self.servername
return dn
def _config_file(self, name: str) -> Path:
"""Write conf/<name>.cnf (openssl config) and return its path."""
dn = self._dn(name)
cnf = self.dir / "conf" / f"{name}.cnf"
cnf.write_text(
f"mail = {dn[EMAIL_FIELD]}\n"
f"CN = {dn['CN']}\n\n"
"[ req ]\n"
"distinguished_name = req_distinguished_name\n"
"attributes = req_attributes\n"
"prompt = no\n"
"default_bits = 2048\n"
f"output_password = {PASS}\n\n"
"[ req_distinguished_name ]\n"
f"C = {dn['C']}\n"
f"ST = {dn['ST']}\n"
f"L = {dn['L']}\n"
f"O = {dn['O']}\n"
f"OU = {dn['OU']}\n"
"CN = $CN\n"
f"{EMAIL_FIELD} = $mail\n\n"
"[ req_attributes ]\n"
f"challengePassword = {PASS}\n\n"
"[ ca ]\n"
"default_ca = CA_default\n\n"
"[ CA_default ]\n"
"certs = certs\n"
"new_certs_dir = newcerts\n"
"crl_dir = crl\n"
"database = index.txt\n"
"serial = serial\n"
"certificate = certs/ca.crt\n"
"crl = crl/ca-bundle.crl\n"
"private_key = keys/ca.pem\n"
"default_days = 365\n"
"default_crl_days = 365\n"
f"default_md = {DGST}\n"
"preserve = no\n\n"
"[ policy_anything ]\n"
"countryName = optional\n"
"stateOrProvinceName = optional\n"
"localityName = optional\n"
"organizationName = optional\n"
"organizationalUnitName = optional\n"
"commonName = supplied\n"
f"{EMAIL_FIELD} = optional\n\n"
"[ client_ok_ext ]\n"
"nsComment = This Is A Comment\n"
# Custom OID extension carrying the DER-encoded UTF8String "Lemons"
# (0c=UTF8String, 06=len 6, 4c656d6f6e73="Lemons"). t/ssl/require.t's
# certext location asserts: "Lemons" in PeerExtList(<this OID>).
# Faithful to Apache::TestSSLCA's client_ok_ext.
"1.3.6.1.4.1.18060.12.0 = DER:0c064c656d6f6e73\n"
"subjectAltName = email:$mail\n\n"
"[ client_ext ]\n"
"extendedKeyUsage = clientAuth\n\n"
"[ server_ext ]\n"
"subjectAltName = DNS:$CN\n"
"extendedKeyUsage = serverAuth\n"
"subjectKeyIdentifier = hash\n"
"authorityKeyIdentifier = keyid,issuer\n\n"
"[ ca_ext ]\n"
"subjectKeyIdentifier = hash\n"
"authorityKeyIdentifier = keyid:always,issuer\n"
"basicConstraints = critical,CA:true\n"
# OpenSSL 3.x's verifier rejects a CA cert used to sign certs unless
# it carries keyCertSign (and cRLSign for the CRL). The Perl-era
# certs predate this strictness; add it so modern clients (httpx via
# OpenSSL 3) can verify the chain.
"keyUsage = critical,keyCertSign,cRLSign\n"
)
return cnf
# -- generation steps -------------------------------------------------
def _init_dirs(self) -> None:
for d in ("keys", "newcerts", "certs", "crl", "export", "csr", "conf", "proxy"):
(self.dir / d).mkdir(parents=True, exist_ok=True)
(self.dir / "index.txt").write_text("")
(self.dir / "serial").write_text("01\n")
def _new_ca(self) -> None:
self._config_file("ca")
self._run(
"req", "-new", "-x509", "-extensions", "ca_ext",
"-keyout", "keys/ca.pem", "-out", "certs/ca.crt",
"-days", DAYS, "-config", "conf/ca.cnf",
"-passout", f"pass:{PASS}",
cwd=self.dir,
)
def _new_key(self, name: str) -> None:
out = f"keys/{name}.pem"
if "dsa" in name:
param = self.dir / "dsa-param"
if not param.exists():
self._run("dsaparam", "-out", "dsa-param", "2048", cwd=self.dir)
args = ["gendsa", "-out", out]
if "_des3" in name:
args += ["-des3", "-passout", f"pass:{PASS}"]
args += ["dsa-param"]
self._run(*args, cwd=self.dir)
else:
args = ["genrsa", "-out", out]
if "_des3" in name:
args += ["-des3", "-passout", f"pass:{PASS}"]
args += ["2048"]
self._run(*args, cwd=self.dir)
def _new_cert(self, name: str) -> None:
self._config_file(name)
self._run(
"req", "-new", "-key", f"keys/{name}.pem", "-out", f"csr/{name}.csr",
"-passin", f"pass:{PASS}", "-passout", f"pass:{PASS}",
"-config", f"conf/{name}.cnf",
cwd=self.dir,
)
exts: list[str] = []
if "client" in name:
exts = ["-extensions", "client_ext"]
if "client_ok" in name:
exts = ["-extensions", "client_ok_ext"]
if "server" in name:
exts = ["-extensions", "server_ext"]
self._run(
"ca", "-policy", "policy_anything",
"-in", f"csr/{name}.csr", "-out", f"certs/{name}.crt",
"-passin", f"pass:{PASS}", "-config", f"conf/{name}.cnf",
"-batch", *exts,
cwd=self.dir,
)
def _make_proxy_cert(self, name: str) -> None:
"""Concatenate cert + key into proxy/<name>.pem (make_proxy_cert)."""
crt = (self.dir / "certs" / f"{name}.crt").read_text()
key = (self.dir / "keys" / f"{name}.pem").read_text()
(self.dir / "proxy" / f"{name}.pem").write_text(crt + key)
def _revoke_cert(self, name: str) -> None:
self._run(
"ca", "-revoke", f"certs/{name}.crt",
"-config", "conf/ca.cnf", "-passin", f"pass:{PASS}",
cwd=self.dir,
)
self._run(
"ca", "-gencrl", "-out", "crl/ca-bundle.crl",
"-config", "conf/ca.cnf", "-passin", f"pass:{PASS}",
cwd=self.dir,
)
def generate(self) -> Path:
"""Build the full CA tree (idempotent). Returns the CA root dir."""
if self.dir.is_dir() and (self.dir / "certs" / "ca.crt").exists():
return self.root # already generated
self._init_dirs()
self._new_ca()
# Ensure a CRL exists even if nothing is revoked.
for name in CERT_DN:
self._new_key(name)
self._new_cert(name)
if name.endswith("_revoked"):
self._revoke_cert(name)
if name.startswith("client_"):
self._make_proxy_cert(name)
# Guarantee crl/ca-bundle.crl exists (referenced by SSLCARevocationFile).
crl = self.dir / "crl" / "ca-bundle.crl"
if not crl.exists():
self._run(
"ca", "-gencrl", "-out", "crl/ca-bundle.crl",
"-config", "conf/ca.cnf", "-passin", f"pass:{PASS}",
cwd=self.dir,
)
return self.root