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

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()