You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
130 lines
4.3 KiB
130 lines
4.3 KiB
import ssl |
|
import socket |
|
from datetime import datetime |
|
from typing import Any, Dict, List, Optional |
|
import argparse |
|
import pprint |
|
import sys # added to detect whether --host was passed |
|
|
|
def fetch_certificate(host: str, port: int = 443, server_hostname: Optional[str] = None, timeout: float = 5.0) -> Dict[str, Any]: |
|
""" |
|
Fetch the TLS certificate from host:port. This function intentionally |
|
uses a non-verifying SSL context to retrieve the certificate even if it |
|
doesn't validate, so we can inspect its fields. |
|
|
|
Parameters: |
|
- host: TCP connect target (can be an IP or hostname) |
|
- port: TCP port (default 443) |
|
- server_hostname: SNI value to send. If None, server_hostname = host. |
|
- timeout: socket connect timeout in seconds |
|
|
|
Returns: |
|
Dictionary with the peer certificate (as returned by SSLSocket.getpeercert()) and additional metadata. |
|
""" |
|
if server_hostname is None: |
|
server_hostname = host |
|
|
|
# Create an SSL context that does NOT verify so we can always fetch the cert. |
|
context = ssl.create_default_context() |
|
context.check_hostname = False |
|
context.verify_mode = ssl.CERT_NONE |
|
|
|
with socket.create_connection((host, port), timeout=timeout) as sock: |
|
with context.wrap_socket(sock, server_hostname=server_hostname) as sslsock: |
|
cert = sslsock.getpeercert() |
|
peer_cipher = sslsock.cipher() |
|
peertime = datetime.utcnow().isoformat() + "Z" |
|
|
|
info: Dict[str, Any] = {"peer_certificate": cert, "cipher": peer_cipher, "fetched_at": peertime, "server_hostname_used": server_hostname} |
|
return info |
|
|
|
def parse_san(cert: Dict[str, Any]) -> List[str]: |
|
"""Return list of DNS names from subjectAltName (if any).""" |
|
san = [] |
|
for typ, val in cert.get("subjectAltName", ()): |
|
if typ.lower() == "dns": |
|
san.append(val) |
|
return san |
|
|
|
def format_subject(cert: Dict[str, Any]) -> str: |
|
"""Return a short human-friendly subject string.""" |
|
subject = cert.get("subject", ()) |
|
parts = [] |
|
for rdn in subject: |
|
for k, v in rdn: |
|
parts.append(f"{k}={v}") |
|
return ", ".join(parts) |
|
|
|
def check_hostname_match(cert: Dict[str, Any], hostname: str) -> bool: |
|
""" |
|
Check whether the certificate matches hostname using ssl.match_hostname. |
|
Returns True if match, False otherwise. |
|
""" |
|
try: |
|
ssl.match_hostname(cert, hostname) |
|
return True |
|
except Exception: |
|
return False |
|
|
|
def print_report(host: str, port: int, server_hostname: Optional[str]) -> None: |
|
"""Fetch certificate and print a readable report.""" |
|
info = fetch_certificate(host=host, port=port, server_hostname=server_hostname) |
|
cert = info["peer_certificate"] |
|
|
|
print(f"Connected target: {host}:{port}") |
|
print(f"SNI sent: {info['server_hostname_used']}") |
|
print(f"Cipher: {info['cipher']}") |
|
print(f"Fetched at (UTC): {info['fetched_at']}") |
|
print() |
|
|
|
print("Subject:") |
|
print(" ", format_subject(cert)) |
|
print() |
|
|
|
print("Issuer:") |
|
issuer = cert.get("issuer", ()) |
|
issuer_parts = [] |
|
for rdn in issuer: |
|
for k, v in rdn: |
|
issuer_parts.append(f"{k}={v}") |
|
print(" ", ", ".join(issuer_parts)) |
|
print() |
|
|
|
sans = parse_san(cert) |
|
print("Subject Alternative Names (SANs):") |
|
if sans: |
|
for n in sans: |
|
print(" -", n) |
|
else: |
|
print(" (none)") |
|
|
|
not_before = cert.get("notBefore") |
|
not_after = cert.get("notAfter") |
|
print() |
|
print("Validity:") |
|
print(" notBefore:", not_before) |
|
print(" notAfter: ", not_after) |
|
|
|
match = check_hostname_match(cert, server_hostname or host) |
|
print() |
|
print(f"Hostname match for '{server_hostname or host}':", "YES" if match else "NO") |
|
|
|
# For debugging show the full cert dict if requested |
|
# pprint.pprint(cert) |
|
|
|
def main() -> None: |
|
parser = argparse.ArgumentParser(description="Fetch and inspect TLS certificate from a host (SNI-aware).") |
|
# make --host optional and default to api.rixdagen.se so running without args works |
|
parser.add_argument("--host", "-H", required=False, default="api.rixdagen.se", help="Host or IP to connect to (TCP target). Defaults to api.rixdagen.se") |
|
parser.add_argument("--port", "-p", type=int, default=443, help="Port to connect to (default 443).") |
|
parser.add_argument("--sni", help="SNI hostname to send. If omitted, the --host value is used as SNI.") |
|
args = parser.parse_args() |
|
|
|
# Notify when using the default host for quick testing |
|
if ("--host" not in sys.argv) and ("-H" not in sys.argv): |
|
print("No --host provided: defaulting to api.rixdagen.se (you can override with --host or -H)") |
|
|
|
print_report(host=args.host, port=args.port, server_hostname=args.sni) |
|
|
|
if __name__ == "__main__": |
|
main()
|
|
|