Reference scripts · reviewed 24 September 2026
Check the connection. Understand the limits.
Choose the system you use. Run readable scripts on your own machine, or follow the vendor checks when a product does not expose its cryptography. You do not need a CheckPQC account, API key, CLI package or PowerShell module.
Need help choosing? Find your next step. To track more than one system, use the private readiness worksheet.
Three different answers
- Negotiated on this connection
- A tool observed a named PQC or hybrid key-exchange group on a verified connection. This is evidence for that connection and network path.
- Available locally
- A library or application lists a PQC algorithm. Settings, the other endpoint, or a proxy may prevent its use.
- Unknown / vendor evidence needed
- The tool failed, the group was not exposed, or the product uses a private protocol. Unknown does not mean classical or insecure.
A TLS 1.3 label, AES-256 cipher, padlock, or current OS version alone does not identify a post-quantum key exchange. PQC key exchange does not prove PQC certificate signatures, encryption at rest, or end-to-end messaging protection.
Websites, HTTPS APIs & TLS services
No tools installed? Run a free public-domain scan. That observes the connection from our server; the script below observes it from your machine.
Prerequisites: Python 3.9+ and a trusted OpenSSL 3.5+ installation that lists X25519MLKEM768. The script installs nothing and changes no system settings. It works from macOS, Linux and Windows with those tools installed.
1. Check your local tools
macOS / Linux shell:
python3 --version
openssl version
openssl list -tls1_3 -tls-groupsWindows PowerShell:
py -3 --version
Get-Command openssl.exe
openssl.exe version
openssl.exe list -tls1_3 -tls-groupsIf the group is absent, stop: that is a local-tool limitation, not a verdict on the server. On macOS, the system binary may differ from a separately installed OpenSSL. Use --openssl with the trusted executable's full path. Python's own linked TLS library is not used for these probes.
2. Save and read the script
Download tls-check.py or copy the source below into a file named tls-check.py. Review it before running. No download-and-execute pipeline is needed.
Read / copy the full Python source
#!/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())
Download integrity: version and SHA-256 manifest · checksum · change notes. A checksum from this same website detects changes but does not independently authenticate the publisher.
3. Run against an endpoint you own or may test
macOS / Linux:
# Save and review tls-check.py first. Replace the example with your hostname.
python3 tls-check.py example.comWindows PowerShell:
# Save and review tls-check.py first. Replace the example with your hostname.
py -3 .\tls-check.py example.com
# If OpenSSL is not on PATH, use its trusted installed location:
# py -3 .\tls-check.py example.com --openssl 'C:\Program Files\OpenSSL-Win64\bin\openssl.exe'Each run opens two connections from your machine: one with your local OpenSSL defaults and one restricted to X25519MLKEM768. Each handshake has a 15-second timeout, verifies the certificate chain and DNS hostname, and sends no application credentials. It sends the hostname via SNI and contacts the destination directly. The destination and your network may log the connection. Private PKI needs a properly configured OpenSSL trust store; do not bypass certificate verification.
Read your result
HYBRID_NEGOTIATED/PQC_NEGOTIATED: the reported group was observed on that verified TLS connection.CLASSICAL_NEGOTIATED: a recognized classical group was observed on that attempt; it does not prove the server lacks other groups.INCONCLUSIVE: no usable verified result. Check DNS, the port, protocol, trust store, tool support, firewall and timeout. A failed forced-group attempt is not proof that every PQC group is unsupported.
If defaults choose a classical group and the restricted attempt succeeds, this endpoint accepted the tested hybrid group with that client. If it fails, keep the reason unresolved. Exit codes: 0 = both attempts conclusive with PQC observed; 1 = both conclusive classical; 2 = an inconclusive attempt, missing prerequisite or invalid input. Read both records, not just the exit code.
A CDN result describes its public edge, not the CDN-to-origin connection. This script tests TCP TLS, not QUIC/HTTP/3, a native app's TLS stack, or mutual-TLS services needing client certificates.
Source: OpenSSL s_client and TLS group configuration.
Browsers, phones & computers
Check this browser's connection to CheckPQC. The result applies to that connection only. Repeat on the network and browser you care about. If TLS is intercepted, the server can see a proxy's connection rather than the browser's original one.
A mobile browser cannot inspect every installed app, OS service or VPN. For another website, inspect its connection in browser developer tools when the negotiated group is available. Otherwise record unknown and use that browser vendor's documentation. A local OpenSSL check uses OpenSSL, not your browser.
Platform guides: Windows, macOS, Linux, Android, iOS, ChromeOS.
SSH, SFTP & Git over SSH
These commands work in a shell or PowerShell with OpenSSH installed. First list your client's capabilities:
ssh -V
ssh -Q kexThen inspect a connection to an already trusted host. An unknown host key will be rejected; verify its fingerprint through your normal trusted channel first. This command deliberately ignores custom SSH configuration; a service requiring a jump host may be unreachable.
# Requires an already trusted host key. Replace user and host.
# No password/key authentication, forwarding or remote command.
# Stop with Ctrl+C after reading "kex: algorithm:".
ssh -F none -vv -N -T -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=yes -o UpdateHostKeys=no -o PreferredAuthentications=none user@ssh.example.comRead the exact kex: algorithm: line, not the offered list or host key algorithm. mlkem768x25519-sha256, sntrup761x25519-sha512 and sntrup761x25519-sha512@openssh.com identify hybrid exchanges. Authentication failure afterward is expected: this example offers no credentials. A failure before key exchange is inconclusive. Host-key signatures are a separate question.
Source: OpenSSH post-quantum guidance. See the OpenSSH guide.
Email, databases & other services
Use the same saved script with the service's actual TLS mode and port. On Windows, replace python3 with py -3. The example domains are placeholders.
# SMTP submission with STARTTLS (upgrade before TLS)
python3 tls-check.py mail.example.com --starttls smtp --port 587
# IMAP with STARTTLS
python3 tls-check.py mail.example.com --starttls imap --port 143
# IMAP with implicit TLS (no STARTTLS flag)
python3 tls-check.py mail.example.com --port 993
# PostgreSQL TLS upgrade
python3 tls-check.py db.example.com --starttls postgres --port 5432
# Another direct TLS service
python3 tls-check.py service.example.com --port 8443Also supported: --starttls pop3 and --starttls ftp. The script tests the connection to that listener, not a database's stored data, an email's end-to-end encryption, or later server-to-server delivery hops. Proprietary handshakes, mTLS and non-TLS services need the vendor's diagnostic tools. See email evidence limits.
Apps & programming runtimes
Identify the exact runtime and crypto provider used by your deployed process. A system OpenSSL installation may be unrelated to a bundled application library.
# Run only the commands for runtimes installed on your machine.
node -p "JSON.stringify({node:process.version, openssl:process.versions.openssl})"
python3 -c "import ssl; print(ssl.OPENSSL_VERSION)"
java -version
go version
dotnet --info
php -r 'echo OPENSSL_VERSION_TEXT, PHP_EOL;'These are inventory, not readiness verdicts. Check runtime/provider settings and capture a negotiated group through that runtime's documented diagnostics in a test environment. A test with the standalone script establishes endpoint behavior for OpenSSL only. Avoid debug dumps containing credentials or session keys.
Guides: Node.js, Python, Java, Go, .NET, PHP, Rustls, wolfSSL.
Messaging apps, VPNs & opaque services
There is no universal script that can certify all apps. Their website's HTTPS group cannot establish what protects messages, calls, backups or tunnel traffic.
- Record the app version, protocol, platform and feature being checked.
- Find the vendor's security specification for that exact feature and rollout. Distinguish a roadmap from shipped behavior.
- Use documented local diagnostics if they expose the negotiated exchange; otherwise label the result vendor-documented or unknown.
- Check the other participant/endpoint, fallback modes, backups, certificate signatures and key distribution separately.
Start with Signal, iMessage, WhatsApp, WireGuard or OpenVPN. For VPNs, never print or paste private keys, preshared keys, full configurations or session secrets as evidence.
Examples of protocol documentation: Signal SPQR, Apple PQ3, WireGuard protocol. These sources describe designs; they are not a live attestation of your session.
Keep a useful evidence record
Copy this template into your own notes. CheckPQC does not receive or store the output of these local scripts. Redact private hostnames and other sensitive context before sharing.
Product / endpoint:
Client and server versions:
Protocol and TLS terminator / proxy:
Date and network used:
Observed negotiated group (if exposed):
Evidence: connection / local capability / vendor documentation / unknown
Certificate or host-key verification:
What remains unverified:
Vendor source and next action:For a product not listed here, identify the protocol first, then choose the matching check or request its vendor's cryptographic design and migration guidance. Browse all technology guides.