diff --git a/ssl_check.py b/ssl_check.py new file mode 100644 index 0000000..ab84ee4 --- /dev/null +++ b/ssl_check.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Standalone SSL check utility to be frozen for testing. + +Usage: + python ssl_check.py --url https://www.penracourses.org.uk --cert-dir ./cert + +What it does: + - Runs `openssl s_client -showcerts` against the host (if openssl on PATH). + - Extracts PEM cert blocks from the output and inspects each with `openssl x509`. + - Loads cert files from --cert-dir (PEM or DER) and converts DER to PEM as needed. + - Compares server chain Issuers with Subjects of bundled certs and reports which CA certs are missing. + +This script is intentionally small and self-contained so it can be frozen with PyInstaller +for quick testing on Windows where the system openssl may be too old. +""" + +from __future__ import annotations + +import argparse +import base64 +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import List, Tuple + + +def find_openssl() -> str | None: + return shutil.which("openssl") + + +def run_openssl_s_client(host: str, port: int = 443, tls_flag: str | None = None, timeout: int = 60) -> str: + openssl = find_openssl() + if not openssl: + raise RuntimeError("openssl not found on PATH") + cmd = [openssl, "s_client", "-showcerts", "-connect", f"{host}:{port}", "-servername", host] + if tls_flag: + cmd.append(tls_flag) + # give a newline so s_client won't wait for interactive input + proc = subprocess.run(cmd, input="\n", capture_output=True, text=True, timeout=timeout) + return proc.stdout + "\n" + proc.stderr + + +def extract_pem_blocks(s: str) -> List[str]: + pattern = re.compile(r"-----BEGIN CERTIFICATE-----(?:.|\n)*?-----END CERTIFICATE-----", re.M) + return pattern.findall(s) + + +def write_pems_to_temp(pems: List[str]) -> List[Path]: + out = [] + for i, pem in enumerate(pems): + f = Path(tempfile.gettempdir()) / f"ssl_check_cert_{i}.pem" + f.write_text(pem) + out.append(f) + return out + + +def inspect_cert(path: Path) -> Tuple[str, str, str]: + """Return (subject, issuer, fingerprint) by calling openssl x509.""" + openssl = find_openssl() + if not openssl: + raise RuntimeError("openssl not found on PATH") + proc = subprocess.run([openssl, "x509", "-in", str(path), "-noout", "-subject", "-issuer", "-fingerprint"], capture_output=True, text=True) + out = proc.stdout + proc.stderr + subj = re.search(r"subject=\s*(.*)", out) + issu = re.search(r"issuer=\s*(.*)", out) + fp = re.search(r"Fingerprint=([A-F0-9:]+)", out) + return (subj.group(1).strip() if subj else "", issu.group(1).strip() if issu else "", fp.group(1).strip() if fp else "") + + +def load_cert_files_from_dir(cert_dir: Path) -> List[Path]: + files = [] + if not cert_dir.exists(): + return files + for pattern in ("*.pem", "*.crt", "*.cer"): + files.extend(sorted(cert_dir.glob(pattern))) + + out = [] + for f in files: + data = f.read_bytes() + if b"-----BEGIN CERTIFICATE-----" in data: + # already PEM + out.append(f) + continue + # assume DER -> convert to PEM + b64 = base64.encodebytes(data).decode("ascii") + pem = "-----BEGIN CERTIFICATE-----\n" + b64 + "-----END CERTIFICATE-----\n" + tmp = Path(tempfile.gettempdir()) / f"ssl_check_bundle_{f.name}.pem" + tmp.write_text(pem) + out.append(tmp) + return out + + +def main(argv: List[str]) -> int: + p = argparse.ArgumentParser() + p.add_argument("--url", required=True, help="URL or host to check (eg https://www.penracourses.org.uk)") + p.add_argument("--cert-dir", default="cert", help="Directory with bundled certs") + p.add_argument("--force-tls12", action="store_true", help="Force -tls1_2 on openssl if initial attempt failed") + args = p.parse_args(argv) + + # parse host/port + from urllib.parse import urlparse + + parsed = urlparse(args.url if "//" in args.url else "//" + args.url) + host = parsed.hostname or args.url + port = parsed.port or 443 + + openssl = find_openssl() + if not openssl: + print("openssl not found on PATH; please install Git for Windows or OpenSSL and re-run.") + # we still attempt Python fallback but chain won't be available + else: + print(f"Using openssl: {openssl}") + + out = "" + if openssl: + try: + out = run_openssl_s_client(host, port) + if "tlsv1 alert protocol version" in out or "SSL23_GET_SERVER_HELLO" in out: + print("Server rejected legacy TLS; retrying with -tls1_2") + out2 = run_openssl_s_client(host, port, tls_flag="-tls1_2") + out = out + "\n--- retry result ---\n" + out2 + except subprocess.TimeoutExpired: + print("OpenSSL check timed out") + except Exception as e: + print(f"OpenSSL check failed: {e}") + + # extract certs from openssl output if any + pems = extract_pem_blocks(out) + if pems: + print(f"Found {len(pems)} PEM blocks in openssl output") + server_paths = write_pems_to_temp(pems) + server_info = [inspect_cert(p) for p in server_paths] + print("Server chain:") + for i, (s, iss, fp) in enumerate(server_info): + print(f"[{i}] SUBJECT: {s}") + print(f" ISSUER: {iss}") + print(f" FP: {fp}") + else: + print("No certificate chain found from openssl (server may have rejected the handshake or openssl is incompatible)") + server_info = [] + + # load bundled certs + cert_dir = Path(args.cert_dir) + bundled = load_cert_files_from_dir(cert_dir) + print(f"Found {len(bundled)} bundled cert files in {cert_dir}") + bundled_info = [inspect_cert(p) for p in bundled] + subjects = {info[0]: p for info, p in zip(bundled_info, bundled)} + + # Compare: ensure each server issuer is present in bundled subjects + missing = set() + for s, iss, fp in server_info: + if iss and iss not in subjects: + missing.add(iss) + + if not server_info: + print("No server certs to compare; check network/openssl compatibility") + return 2 + + if not missing: + print("All server issuers are present in the bundled certs. OK") + return 0 + else: + print("Missing issuer certificates in bundled certs:") + for m in missing: + print(" - ", m) + return 3 + + +if __name__ == "__main__": + try: + rc = main(sys.argv[1:]) + except Exception as e: + print(f"ssl_check failed: {e}") + rc = 1 + sys.exit(rc)