11"""
2- This module retrieves only the leaf certificate from servers that don't
3- follow the standard TLS handshake procedure and fail to provide the complete
4- certificate chain. While `curl` can still connect to these servers by
5- specifying only the leaf certificate (and defaults to finding the chain in
6- /etc/ssl/certs), the `requests` module requires a full certificate bundle.
7- This module is intended for use cases where only the leaf certificate
8- is needed.
2+ Leaf Certificate Fetcher and Inspector for Misconfigured HTTPS Servers.
3+
4+ This module retrieves **only the leaf certificate** (the server's own certificate)
5+ from a given hostname over TLS, without verifying the chain.
6+
7+ Some servers are misconfigured and fail to send the full certificate chain
8+ during the TLS handshake. While `curl` can still connect to these by relying
9+ on system root certificates, higher-level libraries like `requests` may reject
10+ the connection unless a full CA bundle is provided.
911"""
1012
1113import sys
1820
1921
2022def get_certificate (hostname ):
21- """OpenSSL with TCP get the certificate"""
23+ """
24+ Perform a raw TLS connection to fetch the server's leaf certificate.
25+
26+ This bypasses certificate verification to ensure we can still retrieve
27+ certificates from misconfigured servers.
28+
29+ Args:
30+ hostname (str): The domain to connect to (without scheme or port).
31+
32+ Returns:
33+ x509.Certificate: Parsed certificate object, or None on failure.
34+ """
2235 context = ssl .create_default_context ()
23- # Disable certificate verification for the first connection
24- context .check_hostname = False
25- context .verify_mode = ssl .CERT_NONE
36+ context .check_hostname = False # Don't verify host name
37+ context .verify_mode = ssl .CERT_NONE # Don't verify certificate chain
2638
2739 with socket .create_connection ((hostname , 443 )) as sock :
2840 with context .wrap_socket (sock , server_hostname = hostname ) as ssock :
29- # Get certificate info
30- cert_der = ssock .getpeercert (True )
41+ cert_der = ssock .getpeercert (True ) # Get DER-encoded certificate
3142 if cert_der :
3243 return x509 .load_der_x509_certificate (cert_der , default_backend ())
3344 return None
3445
3546
3647def get_sha256_fingerprint (cert ):
37- """Identify the sum, we might want verify the certificate"""
48+ """
49+ Compute SHA-256 fingerprint of a certificate.
50+
51+ Args:
52+ cert (x509.Certificate): The certificate to hash.
53+
54+ Returns:
55+ bytes: Raw SHA-256 digest.
56+ """
3857 cert_der = cert .public_bytes (serialization .Encoding .DER )
3958 digest = hashes .Hash (hashes .SHA256 (), backend = default_backend ())
4059 digest .update (cert_der )
4160 return digest .finalize ()
4261
4362
4463def get_serial_number_hex (cert ):
45- """Identify the serial, this can be use to look for the certificate"""
46- # Get the serial number in a byte format
47- serial_number_bytes = cert .serial_number \
48- .to_bytes ((cert .serial_number .bit_length () + 7 ) // 8 , 'big' )
49- # Format it as a hex string
64+ """
65+ Return the certificate's serial number formatted as hexadecimal.
66+
67+ Useful for identifying and verifying specific certs manually.
68+
69+ Args:
70+ cert (x509.Certificate): The certificate to inspect.
71+
72+ Returns:
73+ str: Colon-separated hex string (e.g., "01:AB:CD:EF").
74+ """
75+ serial_number_bytes = cert .serial_number .to_bytes (
76+ (cert .serial_number .bit_length () + 7 ) // 8 , 'big'
77+ )
5078 return ':' .join (f'{ b :02X} ' for b in serial_number_bytes )
5179
5280
5381def print_certificate_details (cert ):
54- """Log basic certificate information"""
82+ """
83+ Print human-readable information about the certificate.
84+
85+ Includes fingerprint, serial number, subject and issuer details.
86+
87+ Args:
88+ cert (x509.Certificate): The certificate to print.
89+ """
5590 fingerprint = get_sha256_fingerprint (cert )
5691 fingerprint_hex = ':' .join (f'{ b :02X} ' for b in fingerprint )
5792 serial_number_hex = get_serial_number_hex (cert )
@@ -64,80 +99,32 @@ def print_certificate_details(cert):
6499
65100
66101def run (url ):
67- """Retrieve and return the certificate's public key in PEM byte format."""
102+ """
103+ Determine the target host from the URL, fetch and print its leaf certificate.
104+
105+ Args:
106+ url (str): The full URL (must start with one of the known domains).
107+
108+ Returns:
109+ bytes: The PEM-encoded certificate (as bytes), or exits on failure.
110+ """
111+ # Map URL to known hosts. These are the only ones we allow.
68112 if url .startswith ("https://www.trle.net" ):
69113 host = 'trle.net'
70114 elif url .startswith ("https://trcustoms.org" ):
71115 host = 'trcustoms.org'
72116 else :
73- sys .exit (1 )
117+ sys .exit (1 ) # Reject unknown domains for safety
118+
74119 certificate = get_certificate (host )
75120 if not certificate :
121+ print ("Failed to retrieve certificate." )
76122 sys .exit (1 )
77123
78124 print_certificate_details (certificate )
125+
79126 if not isinstance (certificate , Certificate ):
127+ print ("Invalid certificate object." )
80128 sys .exit (1 )
81129
82130 return certificate .public_bytes (encoding = serialization .Encoding .PEM )
83-
84-
85- '''
86- def validate_downloaded_key(id_number, expected_serial):
87- """Validate the certificate in binary form with the cryptography module"""
88- pem_key = get_response(f"https://crt.sh/?d={id_number}", 'application/pkix-cert')
89-
90- if not isinstance(pem_key, bytes):
91- logging.error("Data type error, expected bytes got %s", type(pem_key))
92- sys.exit(1)
93-
94- # Load the certificate
95- certificate = x509.load_pem_x509_certificate(pem_key, default_backend())
96-
97- # Extract the serial number and convert it to hex (without leading '0x')
98- hex_serial = f'{certificate.serial_number:x}'
99-
100- # Compare the serial numbers
101- if hex_serial == expected_serial:
102- print("The downloaded PEM key matches the expected serial number.")
103- else:
104- logging.error("Serial mismatch! Expected: %s, but got: %s", expected_serial, hex_serial)
105- sys.exit(1)
106-
107- # Extract and validate the domain (Common Name)
108- valid_domains = ["trle.net", "trcustoms.org", "data.trcustoms.org", "staging.trcustoms.org"]
109-
110- # Check the Common Name (CN) in the certificate subject
111- comon_name = certificate.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)[0].value
112- if comon_name in valid_domains:
113- print(f"Valid domain found in CN: {comon_name}")
114- else:
115- logging.error("Invalid domain in CN: %s", comon_name)
116- sys.exit(1)
117-
118- # Extract the Subject Alternative Name (SAN) extension
119- try:
120- san_extension = certificate.extensions \
121- .get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
122-
123- # Extract all DNS names listed in the SAN extension
124- dns_names = san_extension.value.get_values_for_type(x509.DNSName) # type: ignore
125-
126- print(f"DNS Names in SAN: {dns_names}")
127-
128- # Check if any of the DNS names match the valid domain list
129- valid_domains = ["trle.net", "www.trle.net", "trcustoms.org", "*.trcustoms.org",
130- "data.trcustoms.org", 'staging.trcustoms.org']
131-
132- if all(domain in valid_domains for domain in dns_names):
133- print(f"Valid domain found in SAN: {dns_names}")
134- else:
135- print(f"Invalid domain in SAN: {dns_names}")
136- sys.exit(1)
137-
138- except x509.ExtensionNotFound:
139- print("No Subject Alternative Name (SAN) extension found in the certificate.")
140-
141- pem_data = certificate.public_bytes(encoding=serialization.Encoding.PEM)
142- return pem_data.decode('utf-8')
143- '''
0 commit comments