Files
bongo/contrib/testing/local-test-pki.py
T
2026-07-26 17:46:49 +02:00

314 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
# This program is free software, licensed under the terms of the GNU GPL.
# See the Bongo COPYING file for full details.
# Copyright (c) 2026 Bongo Project contributors
"""Manage the two-root local PKI used by Bongo protocol tests."""
from __future__ import annotations
import argparse
import ipaddress
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
DEFAULT_ROOT = Path("/var/lib/bongo-test-pki")
TRUST_ANCHOR = Path(
"/usr/local/share/ca-certificates/bongo-local-test-root.crt")
SYSTEM_BUNDLE = Path("/etc/ssl/certs/ca-certificates.crt")
CERTTOOL = Path("/usr/bin/certtool")
UPDATE_CA_CERTIFICATES = Path("/usr/bin/update-ca-certificates")
OPENSSL = Path("/usr/bin/openssl")
SAFE_NAME = re.compile(r"^[a-z0-9][a-z0-9._-]{0,62}$")
def paths(root: Path) -> dict[str, Path]:
return {
"trusted_key": root / "trusted-ca.key",
"trusted_cert": root / "trusted-ca.crt",
"untrusted_key": root / "untrusted-ca.key",
"untrusted_cert": root / "untrusted-ca.crt",
"issued": root / "issued",
}
def run(arguments: list[str | Path], *, quiet: bool = False) -> None:
command = [str(argument) for argument in arguments]
completed = subprocess.run(
command,
check=False,
stdout=subprocess.DEVNULL if quiet else None,
stderr=subprocess.PIPE,
text=True,
)
if completed.returncode:
detail = completed.stderr.strip()
raise RuntimeError(
f"{Path(command[0]).name} failed"
+ (f": {detail}" if detail else ""))
def require_tools() -> None:
missing = [
path for path in (CERTTOOL, UPDATE_CA_CERTIFICATES, OPENSSL)
if not path.is_file()
]
if missing:
raise RuntimeError(
"missing test-PKI tools: "
+ ", ".join(str(path) for path in missing))
def require_root(action: str) -> None:
if os.geteuid() != 0:
raise RuntimeError(f"{action} must be run as root")
def ensure_private_directory(directory: Path) -> None:
directory.mkdir(parents=True, exist_ok=True)
if directory.is_symlink():
raise RuntimeError(f"refusing symlinked PKI directory: {directory}")
directory.chmod(0o700)
def write_template(content: str) -> Path:
descriptor, raw_path = tempfile.mkstemp(prefix="bongo-test-pki-",
suffix=".tmpl")
path = Path(raw_path)
try:
os.write(descriptor, content.encode("ascii"))
finally:
os.close(descriptor)
path.chmod(0o600)
return path
def generate_ca(root: Path, kind: str, common_name: str) -> None:
values = paths(root)
key = values[f"{kind}_key"]
certificate = values[f"{kind}_cert"]
if key.exists() or certificate.exists():
if key.is_file() and certificate.is_file():
return
raise RuntimeError(f"incomplete {kind} test CA below {root}")
template = write_template(
f"""cn = {common_name}
organization = Bongo local protocol tests
expiration_days = 3650
ca
cert_signing_key
crl_signing_key
""")
temporary_key = key.with_suffix(".key.new")
temporary_certificate = certificate.with_suffix(".crt.new")
try:
run([
CERTTOOL, "--generate-privkey", "--sec-param", "high",
"--outfile", temporary_key,
], quiet=True)
temporary_key.chmod(0o600)
run([
CERTTOOL, "--generate-self-signed",
"--load-privkey", temporary_key,
"--template", template,
"--outfile", temporary_certificate,
], quiet=True)
temporary_certificate.chmod(0o644)
temporary_key.replace(key)
temporary_certificate.replace(certificate)
finally:
template.unlink(missing_ok=True)
temporary_key.unlink(missing_ok=True)
temporary_certificate.unlink(missing_ok=True)
def prepare(root: Path) -> None:
require_tools()
if root == DEFAULT_ROOT:
require_root("preparing the system test PKI")
ensure_private_directory(root)
ensure_private_directory(paths(root)["issued"])
generate_ca(root, "trusted", "Bongo Local Test Root CA")
generate_ca(root, "untrusted", "Bongo Untrusted Test Root CA")
print(f"Trusted test CA: {paths(root)['trusted_cert']}")
print(f"Untrusted test CA: {paths(root)['untrusted_cert']}")
def normalise_dns_name(value: str) -> str:
try:
result = value.rstrip(".").encode("idna").decode("ascii").lower()
except UnicodeError as error:
raise RuntimeError(f"invalid DNS name {value!r}: {error}") from error
labels = result.split(".")
valid_label = r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?"
if (not result or len(result) > 253 or
any(not label or len(label) > 63 for label in labels) or
any(not re.fullmatch(valid_label, label) for label in labels)):
raise RuntimeError(f"invalid DNS name: {value!r}")
return result
def issue(root: Path, kind: str, name: str, dns_names: list[str],
ip_addresses: list[str]) -> None:
require_tools()
if root == DEFAULT_ROOT:
require_root("issuing a system test certificate")
if not SAFE_NAME.fullmatch(name):
raise RuntimeError(
"certificate name must contain only lowercase letters, digits, "
"dot, underscore, and hyphen")
prepare(root)
dns_names = list(dict.fromkeys(normalise_dns_name(item)
for item in dns_names))
if not dns_names:
raise RuntimeError("at least one --dns-name is required")
parsed_addresses = [str(ipaddress.ip_address(item))
for item in ip_addresses]
values = paths(root)
output = values["issued"] / name
if output.exists():
raise RuntimeError(
f"certificate output already exists; remove it first: {output}")
ensure_private_directory(output)
ca_key = values[f"{kind}_key"]
ca_certificate = values[f"{kind}_cert"]
key = output / "key.pem"
certificate = output / "cert.pem"
fullchain = output / "fullchain.pem"
template_lines = [
f"cn = {dns_names[0]}",
"organization = Bongo local protocol tests",
"expiration_days = 30",
"signing_key",
"encryption_key",
"tls_www_server",
]
template_lines.extend(f"dns_name = {item}" for item in dns_names)
template_lines.extend(f"ip_address = {item}" for item in parsed_addresses)
template = write_template("\n".join(template_lines) + "\n")
try:
run([
CERTTOOL, "--generate-privkey", "--sec-param", "high",
"--outfile", key,
], quiet=True)
key.chmod(0o600)
run([
CERTTOOL, "--generate-certificate",
"--load-privkey", key,
"--load-ca-certificate", ca_certificate,
"--load-ca-privkey", ca_key,
"--template", template,
"--outfile", certificate,
], quiet=True)
certificate.chmod(0o644)
fullchain.write_bytes(
certificate.read_bytes() + ca_certificate.read_bytes())
fullchain.chmod(0o644)
except Exception:
shutil.rmtree(output, ignore_errors=True)
raise
finally:
template.unlink(missing_ok=True)
print(f"Issued {kind} certificate: {certificate}")
print(f"Private key: {key}")
print(f"Full chain: {fullchain}")
def install(root: Path) -> None:
require_root("installing the trusted test CA")
prepare(root)
source = paths(root)["trusted_cert"]
TRUST_ANCHOR.parent.mkdir(parents=True, exist_ok=True)
if (TRUST_ANCHOR.exists()
and TRUST_ANCHOR.read_bytes() != source.read_bytes()):
raise RuntimeError(
f"refusing to replace a different trust anchor: {TRUST_ANCHOR}")
temporary = TRUST_ANCHOR.with_suffix(".crt.new")
shutil.copyfile(source, temporary)
temporary.chmod(0o644)
temporary.replace(TRUST_ANCHOR)
run([UPDATE_CA_CERTIFICATES])
run([OPENSSL, "verify", "-CAfile", SYSTEM_BUNDLE, source])
print(f"Installed trusted test CA: {TRUST_ANCHOR}")
print("The untrusted test CA was deliberately not installed.")
def remove(root: Path) -> None:
require_root("removing the trusted test CA")
source = paths(root)["trusted_cert"]
if TRUST_ANCHOR.exists():
if (source.exists()
and TRUST_ANCHOR.read_bytes() != source.read_bytes()):
raise RuntimeError(
f"refusing to remove a different trust anchor: {TRUST_ANCHOR}")
TRUST_ANCHOR.unlink()
run([UPDATE_CA_CERTIFICATES])
print(f"Removed trusted test CA: {TRUST_ANCHOR}")
def status(root: Path) -> None:
values = paths(root)
print(f"PKI directory: {root}")
print(
"Trusted CA generated: "
f"{'yes' if values['trusted_cert'].is_file() else 'no'}")
print(
"Untrusted CA generated: "
f"{'yes' if values['untrusted_cert'].is_file() else 'no'}")
installed = (
TRUST_ANCHOR.is_file()
and values["trusted_cert"].is_file()
and TRUST_ANCHOR.read_bytes() == values["trusted_cert"].read_bytes()
)
print(f"Trusted CA installed system-wide: {'yes' if installed else 'no'}")
print(f"System trust anchor: {TRUST_ANCHOR}")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=DEFAULT_ROOT)
subparsers = parser.add_subparsers(dest="action", required=True)
subparsers.add_parser("prepare")
subparsers.add_parser("install")
subparsers.add_parser("remove")
subparsers.add_parser("status")
issue_parser = subparsers.add_parser("issue")
issue_parser.add_argument("--ca", choices=("trusted", "untrusted"),
default="trusted")
issue_parser.add_argument("--name", required=True)
issue_parser.add_argument("--dns-name", action="append", default=[])
issue_parser.add_argument("--ip-address", action="append", default=[])
return parser
def main() -> int:
arguments = build_parser().parse_args()
try:
if arguments.action == "prepare":
prepare(arguments.root)
elif arguments.action == "install":
install(arguments.root)
elif arguments.action == "remove":
remove(arguments.root)
elif arguments.action == "status":
status(arguments.root)
elif arguments.action == "issue":
issue(arguments.root, arguments.ca, arguments.name,
arguments.dns_name, arguments.ip_address)
except (OSError, RuntimeError) as error:
print(f"local-test-pki: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())