#!/usr/bin/env python3 """Read-only TLS reference check. Python 3.9+ and OpenSSL 3.5+; no packages. Run against endpoints you own or are authorized to test. Each run opens two connections from YOUR machine, never via CheckPQC. It sends no application credentials. STARTTLS performs the protocol upgrade before the TLS handshake. Source: https://docs.openssl.org/3.5/man1/openssl-s_client/ """ import argparse from datetime import datetime, timezone import ipaddress import json import re import shutil import subprocess import threading SCRIPT_VERSION = '1.1.0' SCRIPT_RELEASED = '2026-09-25' HYBRID = {'X25519MLKEM768', 'SecP256r1MLKEM768', 'SecP384r1MLKEM1024'} PURE_PQ = {'MLKEM512', 'MLKEM768', 'MLKEM1024'} CLASSICAL = {'X25519', 'X448', 'prime256v1', 'secp256r1', 'secp384r1', 'secp521r1', 'P-256', 'P-384', 'P-521'} PORTS = {'smtp': 587, 'imap': 143, 'pop3': 110, 'postgres': 5432, 'ftp': 21} MAX_OUTPUT_BYTES = 128 * 1024 class OutputLimitError(Exception): pass def run_bounded(command, timeout): """Drain output on a thread so time and memory limits work on Windows too.""" with subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) as process: chunks = [] overflow = threading.Event() def drain(): total = 0 while True: data = process.stdout.read(4096) if not data: break total += len(data) if total > MAX_OUTPUT_BYTES: overflow.set() process.kill() break chunks.append(data) reader = threading.Thread(target=drain, daemon=True) reader.start() try: process.wait(timeout=timeout) except subprocess.TimeoutExpired: process.kill() process.wait() reader.join(timeout=1) raise reader.join(timeout=1) if reader.is_alive() or overflow.is_set(): raise OutputLimitError('Diagnostic output exceeded the safe limit.') output = b''.join(chunks).decode('utf-8', errors='replace') return subprocess.CompletedProcess(command, process.returncode, output, '') def hostname(value): value = value.lower() try: ipaddress.ip_address(value) except ValueError: pass else: raise argparse.ArgumentTypeError('Use the DNS hostname on the certificate, not an IP address.') labels = value.split('.') if len(value) > 253 or not all(re.fullmatch(r'[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?', label) for label in labels): raise argparse.ArgumentTypeError('Use a DNS hostname only, without scheme, path or port (IDNs: use punycode).') return value def port(value): try: number = int(value) except ValueError: number = 0 if not 1 <= number <= 65535: raise argparse.ArgumentTypeError('Port must be between 1 and 65535.') return number def classify(returncode, output): if returncode != 0 or not re.search(r'^\s*Verification: OK\s*$', output, re.M) or not re.search(r'^\s*Protocol version: TLSv1\.3\s*$', output, re.M): return 'INCONCLUSIVE', None match = re.search(r'^\s*(?:Negotiated TLS1\.3 group|Peer Temp Key|Server Temp Key):\s*([A-Za-z0-9-]+)', output, re.M) group = match.group(1) if match else None if group in HYBRID: return 'HYBRID_NEGOTIATED', group if group in PURE_PQ: return 'PQC_NEGOTIATED', group if group in CLASSICAL: return 'CLASSICAL_NEGOTIATED', group return 'INCONCLUSIVE', group def check(openssl, target, target_port, starttls, hybrid_only): command = [openssl, 's_client', '-connect', f'{target}:{target_port}', '-servername', target, '-verify_hostname', target, '-verify_return_error', '-tls1_3', '-brief', '-no_ign_eof'] if hybrid_only: command += ['-groups', 'X25519MLKEM768'] if starttls: command += ['-starttls', starttls] try: process = run_bounded(command, 15) result, group = classify(process.returncode, process.stdout + '\n' + process.stderr) except (OSError, subprocess.TimeoutExpired, OutputLimitError): result, group = 'INCONCLUSIVE', None record = {'attempt': 'hybrid-only' if hybrid_only else 'local-defaults', 'result': result, 'negotiated_group': group} if result == 'INCONCLUSIVE': record['note'] = 'No verified recognized TLS 1.3 group observed. Check trust store, hostname, protocol, tool support and connectivity; this does not prove absence of PQC.' return record def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--version', action='version', version=f'%(prog)s {SCRIPT_VERSION} ({SCRIPT_RELEASED})') parser.add_argument('hostname', type=hostname) parser.add_argument('--port', type=port) parser.add_argument('--starttls', choices=sorted(PORTS), help='For explicit TLS upgrades; omit for HTTPS or implicit TLS such as IMAPS on 993.') parser.add_argument('--openssl', default='openssl', help='Trusted OpenSSL executable name or full path.') args = parser.parse_args() openssl = shutil.which(args.openssl) if not openssl: parser.exit(2, 'OpenSSL not found. Install it separately from a trusted source, then retry. Nothing was installed.\n') try: process = run_bounded([openssl, 'list', '-tls1_3', '-tls-groups'], 5) except (OSError, subprocess.TimeoutExpired, OutputLimitError): parser.exit(2, 'Could not inspect OpenSSL capabilities. No endpoint was checked.\n') if process.returncode != 0 or not re.search(r'\bX25519MLKEM768\b', process.stdout): parser.exit(2, 'Local tool cannot offer X25519MLKEM768. Use OpenSSL 3.5+ with that group enabled. Target readiness is unknown.\n') target_port = args.port or PORTS.get(args.starttls, 443) started_at = datetime.now(timezone.utc).isoformat() results = [check(openssl, args.hostname, target_port, args.starttls, forced) for forced in (False, True)] print(json.dumps({'schema_version': 1, 'script_version': SCRIPT_VERSION, 'started_at': started_at, 'finished_at': datetime.now(timezone.utc).isoformat(), 'vantage_point': 'This machine, using the selected OpenSSL executable', 'target': f'{args.hostname}:{target_port}', 'checks': results, 'scope': 'These TLS connections only. Not application E2EE, origin behind a CDN, certificate signatures, other clients or whole-device readiness.'}, indent=2)) # Mixed success and failure stays inconclusive for automation; retain both records. if any(item['result'] == 'INCONCLUSIVE' for item in results): return 2 return 0 if any(item['result'] in ('HYBRID_NEGOTIATED', 'PQC_NEGOTIATED') for item in results) else 1 if __name__ == '__main__': raise SystemExit(main())