{"id":11993,"date":"2026-01-26T13:56:53","date_gmt":"2026-01-26T18:56:53","guid":{"rendered":"https:\/\/www.rushworth.us\/lisa\/?p=11993"},"modified":"2026-02-06T14:10:10","modified_gmt":"2026-02-06T19:10:10","slug":"viewing-real-certificate-chain","status":"publish","type":"post","link":"https:\/\/www.rushworth.us\/lisa\/?p=11993","title":{"rendered":"Viewing *Real* Certificate Chain"},"content":{"rendered":"\n<p>Browsers implement AIA which &#8220;helps&#8221; by repairing the certificate chain and forming a trust even without proper server configuration. Which is great for user experience, but causes a lot of challenges to people troubleshooting SSL connection failures from devices, old equipment, etc. It&#8217;s <em>fine<\/em> when I try it from my laptop!<\/p>\n\n\n\n<p>This python script reports on the <em>real<\/em> certificate chain being served from an endpoint. Self-signed certificates will show as untrusted <\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.rushworth.us\/lisa\/wp-content\/uploads\/2026\/02\/certchain-selfsignedcert.jpg\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"317\" src=\"https:\/\/www.rushworth.us\/lisa\/wp-content\/uploads\/2026\/02\/certchain-selfsignedcert-1024x317.jpg\" alt=\"\" class=\"wp-image-11995\" srcset=\"https:\/\/www.rushworth.us\/lisa\/wp-content\/uploads\/2026\/02\/certchain-selfsignedcert-1024x317.jpg 1024w, https:\/\/www.rushworth.us\/lisa\/wp-content\/uploads\/2026\/02\/certchain-selfsignedcert-300x93.jpg 300w, https:\/\/www.rushworth.us\/lisa\/wp-content\/uploads\/2026\/02\/certchain-selfsignedcert-768x237.jpg 768w, https:\/\/www.rushworth.us\/lisa\/wp-content\/uploads\/2026\/02\/certchain-selfsignedcert-750x232.jpg 750w, https:\/\/www.rushworth.us\/lisa\/wp-content\/uploads\/2026\/02\/certchain-selfsignedcert.jpg 1103w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><\/figure>\n\n\n\n<p>And public certs will show the chain and show as trusted<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.rushworth.us\/lisa\/wp-content\/uploads\/2026\/02\/certchain-publictrusted.jpg\"><img loading=\"lazy\" decoding=\"async\" width=\"1005\" height=\"527\" src=\"https:\/\/www.rushworth.us\/lisa\/wp-content\/uploads\/2026\/02\/certchain-publictrusted.jpg\" alt=\"\" class=\"wp-image-11996\" srcset=\"https:\/\/www.rushworth.us\/lisa\/wp-content\/uploads\/2026\/02\/certchain-publictrusted.jpg 1005w, https:\/\/www.rushworth.us\/lisa\/wp-content\/uploads\/2026\/02\/certchain-publictrusted-300x157.jpg 300w, https:\/\/www.rushworth.us\/lisa\/wp-content\/uploads\/2026\/02\/certchain-publictrusted-768x403.jpg 768w, https:\/\/www.rushworth.us\/lisa\/wp-content\/uploads\/2026\/02\/certchain-publictrusted-750x393.jpg 750w\" sizes=\"auto, (max-width: 1005px) 100vw, 1005px\" \/><\/a><\/figure>\n\n\n\n<p>Code:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: python; title: ; notranslate\" title=\"\">\nimport ssl\nimport socket\nimport datetime\nimport select\n\n# Third-party modules (install: pip install pyopenssl cryptography)\ntry:\n    from OpenSSL import SSL, crypto\n    from cryptography import x509\n    from cryptography.hazmat.primitives import hashes\nexcept ImportError as e:\n    raise SystemExit(\n        &quot;Missing required modules. Please install:\\n&quot;\n        &quot;  pip install pyopenssl cryptography\\n&quot;\n        f&quot;Import error: {e}&quot;\n    )\n\ndef prompt(text, default=None):\n    s = input(text).strip()\n    return s if s else default\n\ndef check_trust(hostname: str, port: int, timeout=6.0):\n    &quot;&quot;&quot;\n    Attempt a TLS connection using system trust store and hostname verification.\n    Returns (trusted: bool, message: str).\n    &quot;&quot;&quot;\n    try:\n        ctx = ssl.create_default_context()\n        with socket.create_connection((hostname, port), timeout=timeout) as sock:\n            with ctx.wrap_socket(sock, server_hostname=hostname) as ssock:\n                # Minimal HTTP GET to ensure we fully complete the handshake\n                req = f&quot;GET \/ HTTP\/1.1\\r\\nHost: {hostname}\\r\\nConnection: close\\r\\n\\r\\n&quot;\n                ssock.sendall(req.encode(&quot;utf-8&quot;))\n                _ = ssock.recv(1)\n        return True, &quot;TRUSTED (system trust store)&quot;\n    except ssl.SSLCertVerificationError as e:\n        return False, f&quot;NOT TRUSTED (certificate verification error): {e}&quot;\n    except ssl.SSLError as e:\n        return False, f&quot;NOT TRUSTED (SSL error): {e}&quot;\n    except Exception as e:\n        return False, f&quot;Error connecting: {e}&quot;\n\ndef _aware_utc(dt: datetime.datetime) -&gt; datetime.datetime:\n    &quot;&quot;&quot;\n    Ensure a datetime is timezone-aware in UTC. cryptography returns naive UTC datetimes.\n    &quot;&quot;&quot;\n    if dt.tzinfo is None:\n        return dt.replace(tzinfo=datetime.timezone.utc)\n    return dt.astimezone(datetime.timezone.utc)\n\ndef _cert_to_info(cert: x509.Certificate):\n    subj = cert.subject.rfc4514_string()\n    issr = cert.issuer.rfc4514_string()\n    fp_sha1 = cert.fingerprint(hashes.SHA1()).hex().upper()\n    nb = _aware_utc(cert.not_valid_before_utc)\n    na = _aware_utc(cert.not_valid_after_utc)\n    now = datetime.datetime.now(datetime.timezone.utc)\n    delta_days = max(0, (na - now).days)\n    return {\n        &quot;subject&quot;: subj,\n        &quot;issuer&quot;: issr,\n        &quot;sha1&quot;: fp_sha1,\n        &quot;not_before&quot;: nb,\n        &quot;not_after&quot;: na,\n        &quot;days_to_expiry&quot;: delta_days\n    }\n\ndef _do_handshake_blocking(conn: SSL.Connection, sock: socket.socket, timeout: float):\n    &quot;&quot;&quot;\n    Drive the TLS handshake, handling WantRead\/WantWrite by waiting with select.\n    &quot;&quot;&quot;\n    deadline = datetime.datetime.now() + datetime.timedelta(seconds=timeout)\n    while True:\n        try:\n            conn.do_handshake()\n            return\n        except SSL.WantReadError:\n            remaining = (deadline - datetime.datetime.now()).total_seconds()\n            if remaining &lt;= 0:\n                raise TimeoutError(&quot;TLS handshake timed out (WantRead)&quot;)\n            r, _, _ = select.select(&#x5B;sock], &#x5B;], &#x5B;], remaining)\n            if not r:\n                raise TimeoutError(&quot;TLS handshake timed out (WantRead)&quot;)\n            continue\n        except SSL.WantWriteError:\n            remaining = (deadline - datetime.datetime.now()).total_seconds()\n            if remaining &lt;= 0:\n                raise TimeoutError(&quot;TLS handshake timed out (WantWrite)&quot;)\n            _, w, _ = select.select(&#x5B;], &#x5B;sock], &#x5B;], remaining)\n            if not w:\n                raise TimeoutError(&quot;TLS handshake timed out (WantWrite)&quot;)\n            continue\n\ndef fetch_presented_chain(hostname: str, port: int, timeout: float = 12.0):\n    &quot;&quot;&quot;\n    Capture the presented certificate chain using pyOpenSSL.\n    Returns (chain: list of {subject, issuer, sha1, not_before, not_after, days_to_expiry}, error: str or None).\n    &quot;&quot;&quot;\n    # TCP connect\n    try:\n        sock = socket.create_connection((hostname, port), timeout=timeout)\n        sock.settimeout(timeout)\n    except Exception as e:\n        return &#x5B;], f&quot;Error connecting: {e}&quot;\n\n    try:\n        # TLS client context\n        ctx = SSL.Context(SSL.TLS_CLIENT_METHOD)\n\n        # Compatibility tweaks:\n        # - Lower OpenSSL security level\n        try:\n            ctx.set_cipher_list(b&quot;DEFAULT:@SECLEVEL=1&quot;)\n        except Exception:\n            pass\n\n        # - Disable TLS 1.3 and set minimum TLS 1.2\n        try:\n            ctx.set_options(SSL.OP_NO_TLSv1_3)\n        except Exception:\n            pass\n        try:\n            # Ensure TLSv1.2+ (pyOpenSSL exposes set_min_proto_version on some builds)\n            if hasattr(ctx, &quot;set_min_proto_version&quot;):\n                ctx.set_min_proto_version(SSL.TLS1_2_VERSION)\n        except Exception:\n            pass\n\n        # - Set ALPN to http\/1.1 (some paths work better when ALPN is present)\n        try:\n            ctx.set_alpn_protos(&#x5B;b&quot;http\/1.1&quot;])\n        except Exception:\n            pass\n\n        conn = SSL.Connection(ctx, sock)\n        conn.set_tlsext_host_name(hostname.encode(&quot;utf-8&quot;))\n        conn.set_connect_state()\n\n        # Blocking mode (best effort)\n        try:\n            conn.setblocking(True)\n        except Exception:\n            pass\n\n        # Drive handshake\n        _do_handshake_blocking(conn, sock, timeout=timeout)\n\n        # Retrieve chain (some servers only expose leaf)\n        chain = conn.get_peer_cert_chain()\n        infos = &#x5B;]\n        if chain:\n            for c in chain:\n                der = crypto.dump_certificate(crypto.FILETYPE_ASN1, c)\n                cert = x509.load_der_x509_certificate(der)\n                infos.append(_cert_to_info(cert))\n        else:\n            peer = conn.get_peer_certificate()\n            if peer is not None:\n                der = crypto.dump_certificate(crypto.FILETYPE_ASN1, peer)\n                cert = x509.load_der_x509_certificate(der)\n                infos.append(_cert_to_info(cert))\n\n        # Cleanup\n        try:\n            conn.shutdown()\n        except Exception:\n            pass\n        finally:\n            try:\n                conn.close()\n            except Exception:\n                pass\n            try:\n                sock.close()\n            except Exception:\n                pass\n\n        if not infos:\n            return &#x5B;], &quot;No certificates captured (server did not present a chain and peer cert unavailable)&quot;\n        return infos, None\n\n    except Exception as e:\n        try:\n            sock.close()\n        except Exception:\n            pass\n        etype = type(e).__name__\n        emsg = str(e) or &quot;no message&quot;\n        return &#x5B;], f&quot;TLS handshake or chain retrieval error: {etype}: {emsg}&quot;\n\ndef main():\n    hostname = prompt(&quot;Enter hostname to test (e.g., example.domain.com): &quot;)\n    if not hostname:\n        print(&quot;Hostname is required.&quot;)\n        return\n    port_str = prompt(&quot;Enter port &#x5B;default 443]: &quot;, &quot;443&quot;)\n    try:\n        port = int(port_str)\n    except ValueError:\n        print(&quot;Invalid port.&quot;)\n        return\n\n    print(f&quot;\\nTesting TLS chain for {hostname}:{port} ...&quot;)\n    chain, err = fetch_presented_chain(hostname, port)\n    print(&quot;\\nPresented chain:&quot;)\n    if err:\n        print(f&quot;  &#x5B;ERROR] {err}&quot;)\n    elif not chain:\n        print(&quot;  &#x5B;No certificates captured]&quot;)\n    else:\n        for i, ci in enumerate(chain, 1):\n            print(f&quot;  &#x5B;{i}] Subject: {ci&#x5B;&#039;subject&#039;]}&quot;)\n            print(f&quot;       Issuer:  {ci&#x5B;&#039;issuer&#039;]}&quot;)\n            print(f&quot;       SHA1:    {ci&#x5B;&#039;sha1&#039;]}&quot;)\n            nb_val = ci.get(&quot;not_before&quot;)\n            na_val = ci.get(&quot;not_after&quot;)\n            nb_str = nb_val.isoformat() if isinstance(nb_val, datetime.datetime) else str(nb_val)\n            na_str = na_val.isoformat() if isinstance(na_val, datetime.datetime) else str(na_val)\n            print(f&quot;       Not Before: {nb_str}&quot;)\n            print(f&quot;       Not After:  {na_str}&quot;)\n            dte = ci.get(&quot;days_to_expiry&quot;)\n            if dte is not None:\n                print(f&quot;       Expires In: {dte} days&quot;)\n\n    trusted, msg = check_trust(hostname, port)\n    print(f&quot;\\nTrust result: {&#039;TRUSTED&#039; if trusted else &#039;NOT TRUSTED&#039;} - {msg}&quot;)\n\nif __name__ == &quot;__main__&quot;:\n    main()\n<\/pre><\/div>","protected":false},"excerpt":{"rendered":"<p>Browsers implement AIA which &#8220;helps&#8221; by repairing the certificate chain and forming a trust even without proper server configuration. Which is great for user experience, but causes a lot of challenges to people troubleshooting SSL connection failures from devices, old equipment, etc. It&#8217;s fine when I try it from my laptop! This python script reports &hellip;<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[30],"tags":[664,236,2171],"class_list":["post-11993","post","type-post","status-publish","format-standard","hentry","category-system-administration","tag-python","tag-ssl","tag-trust-chains"],"_links":{"self":[{"href":"https:\/\/www.rushworth.us\/lisa\/index.php?rest_route=\/wp\/v2\/posts\/11993","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.rushworth.us\/lisa\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.rushworth.us\/lisa\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.rushworth.us\/lisa\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.rushworth.us\/lisa\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=11993"}],"version-history":[{"count":1,"href":"https:\/\/www.rushworth.us\/lisa\/index.php?rest_route=\/wp\/v2\/posts\/11993\/revisions"}],"predecessor-version":[{"id":11997,"href":"https:\/\/www.rushworth.us\/lisa\/index.php?rest_route=\/wp\/v2\/posts\/11993\/revisions\/11997"}],"wp:attachment":[{"href":"https:\/\/www.rushworth.us\/lisa\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=11993"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.rushworth.us\/lisa\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=11993"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.rushworth.us\/lisa\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=11993"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}