The reason requests can work with just a root CA while the ssl module's socket connection requires the intermediate CA is likely due to differences in how they handle certificate chains.
Why requests Works with Only the Root CA
The requests library is built on top of urllib3, which, in turn, relies on OpenSSL. OpenSSL includes a feature called certificate chain building, which attempts to retrieve missing intermediate certificates from the server if they are not provided in the certificate chain. This feature allows requests to work as long as the root CA is trusted because requests can fetch any missing intermediate certificates on its own.
Why ssl Requires the Intermediate CA
The ssl module in Python does not automatically retrieve missing intermediate certificates. For SSL/TLS handshakes, it requires a complete chain of trust to be locally available. This means you need to provide both the root and intermediate certificates in your custom CA bundle if the server’s response is missing them.
Solutions
Provide a Complete Certificate Chain in the CA Bundle: You can combine the root and intermediate certificates into a single file as a workaround:
cat rootCA.pem intermediateCA.pem > full_chain_ca_bundle.pem
Then, load this full_chain_ca_bundle.pem in your ssl socket configuration:
context.load_verify_locations(cafile="full_chain_ca_bundle.pem")
Request the Server to Include the Intermediate Certificates: If you have control over the server configuration, configure it to send the full certificate chain. This will allow the ssl client to validate the certificate with only the root CA, similar to how requests works.
Use requests as a Fallback: If using requests is an option, consider leveraging it to handle SSL/TLS connections directly, as it provides built-in handling for such cases.
Example with Full Chain Bundle
Here’s how to modify your function to load a complete certificate chain:
def __connect_socket(self) -> None:
"""Establish tcp connection"""
context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.verify_mode = ssl.CERT_REQUIRED
context.load_verify_locations(cafile="full_chain_ca_bundle.pem") # Load full chain
# Load client cert and key if needed
if self.ssl_client_cert:
context.load_cert_chain(certfile=self.ssl_client_cert[0], keyfile=self.ssl_client_cert[1])
# Create and connect the socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.__socket_client = context.wrap_socket(sock, server_hostname=self.tcp_address[0])
self.__socket_client.connect(self.tcp_address)
This approach should enable your ssl-based socket client to connect successfully with both the root and intermediate certificates available, creating a full chain of trust.
$ openssl s_client -connect example.com:8883 -CAfile server.pem
# Show more detailed certificate verification
$ openssl s_client -connect localhost:8883 -CAfile server.pem -verify 8 -verify_return_error
#
sudo openssl x509 -in server.pem -text -noout | grep "Issuer\|Subject"
The error message "Verify return code: 20 (unable to get local issuer certificate)" you’re encountering, unable to get local issuer certificate, indicates that the SSL/TLS client is unable to verify the server’s certificate because it cannot find the appropriate issuer certificate in its trust store. Here are some steps to troubleshoot and resolve this issue:
Check Certificate Chain:
Ensure that the server is presenting the complete certificate chain, including intermediate certificates. You can use tools like openssl s_client -connect <host>:<port> -showcerts to see the full chain.