2

I have a group of nginx servers, that accept client certificates. They have the ssl_client_certificate option with a file containing one or more CAs

If I use a web browser, then the web browser seems to receive a list of valid CAs for client certs. The browser shows only client certs signed by one of these CAs.

Following openssl command gives me a list of CA certs:

openssl s_client -showcerts -servername myserver.com -connect myserver.com:443 </dev/null

The lines I am interested in look following way:

---
Acceptable client certificate CA names
/C=XX/O=XX XXXX
/C=YY/O=Y/OU=YY YYYYYL
...
Client Certificate Types: RSA sign, DSA sign, ECDSA sign

How can I get the same information with python?

I do have following code snippet, that allows to obtain a server's certificate, but this code does not return the list of CAs for client certs.

import ssl

def get_server_cert(hostname, port):
    conn = ssl.create_connection((hostname, port))
    context = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
    sock = context.wrap_socket(conn, server_hostname=hostname)
    cert = sock.getpeercert(True)
    cert = ssl.DER_cert_to_PEM_cert(cert)
    return cerft

I expected to find a functional equivalent of getpeercert(), something like getpeercas() but didn't find anything.

Current workaround:

import os
import subprocess


def get_client_cert_cas(hostname, port):
    """
    returns a list of CAs, for which client certs are accepted
    """

    cmd = [
        "openssl",
        "s_client",
        "-showcerts",
        "-servername",  hostname,
        "-connect",  hostname + ":" + str(port),
        ]

    stdin = open(os.devnull, "r")
    stderr = open(os.devnull, "w")

    output = subprocess.check_output(cmd, stdin=stdin, stderr=stderr)
    ca_signatures = []
    state = 0
    for line in output.decode().split("\n"):
        print(state, line)
        if state == 0:
            if line == "Acceptable client certificate CA names":
                state = 1
        elif state == 1:
            if line.startswith("Client Certificate Types:"):
                break
            ca_signatures.append(line)
    return ca_signatures

Update:Solution with pyopenssl (Thanks Steffen Ullrich)

@Steffen Ulrich suggested to use pyopenssl, which has a method get_client_ca_list() and this helped me to write a small code snippet.

Below code seems to work. Not sure if it can be improved or whether there are any pit falls.

If nobody is answering within the next days I will post this as a potential answer.

import socket
from OpenSSL import SSL

def get_client_cert_cas(hostname, port):
    ctx = SSL.Context(SSL.SSLv23_METHOD)
    # If we don't force to NOT use TLSv1.3 get_client_ca_list() returns
    # an empty result
    ctx.set_options(SSL.OP_NO_TLSv1_3)
    sock = SSL.Connection(ctx, socket.socket(socket.AF_INET, socket.SOCK_STREAM))
    # next line for SNI
    sock.set_tlsext_host_name(hostname.encode("utf-8"))
    sock.connect((hostname, port))
    # without handshake get_client_ca_list will be empty
    sock.do_handshake()  
    return sock.get_client_ca_list()

Update: 2021-03-31

Above suggested solution using pyopenssl works in most cases. However sock.get_client_ca_list()) cannot be called immediately after performing a sock.connect((hostname, port)) Some actions seem to be required in between these two commands.

Initially I used sock.send(b"G"), but now I use sock.do_handshake(), which seems a little cleaner.

Even stranger, the solution doesn't work with TLSv1.3 so I had to exclude it.

gelonida
  • 5,327
  • 2
  • 23
  • 41
  • I don't think this is offered by the standard SSL module (i.e. `import ssl`). One can do it with pyopenssl though: [OpenSSL.SSL.Connection.get_client_ca_list](https://www.pyopenssl.org/en/stable/api/ssl.html#OpenSSL.SSL.Connection.get_client_ca_list). – Steffen Ullrich Nov 02 '20 at 16:01
  • Hi Steffen, Thanks a lot. will try it ASAP. If it works, then this is definitely an acceptable answer. – gelonida Nov 02 '20 at 16:16
  • Hi Steffen, I needed a little time to get it 'right' but I have now a working solution. Could you check, potentially fix it it and post it perhaps as an answer? (My current solution is now part of my question, as I didn't want to answer my own question) – gelonida Nov 03 '20 at 23:06
  • The code looks good to me, but I don't have much experience with pyopenssl myself. I only knew the OpenSSL function and that it is more likely to find OpenSSL specific functions there than in ssl.py. I recommend that you answer your question yourself with your solution. This is actually not uncommon and this way you also help others which stumble over the same problem. – Steffen Ullrich Nov 04 '20 at 21:00
  • Thanks Steffen. will do this, but I always prefer to give opportunity to the ones who helped me to answer. The only thing, that doesn't look that elegant to me is that I have to send one byte. No idea if there is a smarter way to trigger of the data exchange. – gelonida Nov 05 '20 at 02:20
  • you should call `get_client_ca_list` on the client i.e. SSL.Connection `set_connect_state` for client vs `set_accept_state` for server. The results are then comparable following tls rfc5246 but not always true for rfc8446 (tls1.3) because your question indicates the client needs to determine if the server considers the connection to be unilaterally/mutually authenticated, this has to be provisioned by the application layer. The server should not send any CA information in this case as the onus should be on the client to validate as you explained – Stof Oct 05 '21 at 00:19
  • made minor changes to to the question. not sure if it adresses any of your (@Stof) feedback, but I'm trying – gelonida Oct 06 '21 at 11:53

2 Answers2

1

As a generic example in python

  1. first you need to contact the server to learn which issuer CA subjects it accepts:
from socket import socket, AF_INET, SOCK_STREAM
from OpenSSL import SSL
from OpenSSL.crypto import X509Name
from certifi import where
import idna


def get_server_expected_client_subjects(host :str, port :int = 443) -> list[X509Name]:
    expected_subjects = []
    ctx = SSL.Context(method=SSL.SSLv23_METHOD)
    ctx.verify_mode = SSL.VERIFY_NONE
    ctx.check_hostname = False
    conn = SSL.Connection(ctx, socket(AF_INET, SOCK_STREAM))
    conn.connect((host, port))
    conn.settimeout(3)
    conn.set_tlsext_host_name(idna.encode(host))
    conn.setblocking(1)
    conn.set_connect_state()
    try:
        conn.do_handshake()
        expected_subjects :list[X509Name] = conn.get_client_ca_list()
    except SSL.Error as err:
        raise SSL.Error from err
    finally:
        conn.close()
    return expected_subjects

This did not have the client certificate, so the TLS connection would fail. There are a lot of bad practices here, but unfortunately they are necessary and the only way to gather the message from the server before we actually want to attempt client authentication using hte correct certificate.

  1. Next you load the cert based on the server:
from pathlib import Path
from OpenSSL.crypto import load_certificate, FILETYPE_PEM
from pathlib import Path

def check_client_cert_issuer(client_pem :str, expected_subjects :list) -> str:
    client_cert = None
    if len(expected_subjects) > 0:
        client_cert_path = Path(client_pem)
        cert = load_certificate(FILETYPE_PEM, client_cert_path.read_bytes())
        issuer_subject = cert.get_issuer()
        for check in expected_subjects:
            if issuer_subject.commonName == check.commonName:
                client_cert = client_pem
                break
    if client_cert is None or not isinstance(client_cert, str):
        raise Exception('X509_V_ERR_SUBJECT_ISSUER_MISMATCH') # OpenSSL error code 29
    return client_cert

In a real app (not an example snippet) you would have a database of some sort to take the server subject and lookup the location of the cert to load - this example does it in reverse for demonstration only.

  1. Make the TLS connection, and capture any OpenSSL errors:
from socket import socket, AF_INET, SOCK_STREAM
from OpenSSL import SSL
from OpenSSL.crypto import X509, FILETYPE_PEM
from certifi import where
import idna


def openssl_verifier(conn :SSL.Connection, server_cert :X509, errno :int, depth :int, preverify_ok :int):
    ok = 1
    verifier_errors = conn.get_app_data()
    if not isinstance(verifier_errors, list):
        verifier_errors = []
    if errno in OPENSSL_CODES.keys():
        ok = 0
        verifier_errors.append((server_cert, OPENSSL_CODES[errno]))
    conn.set_app_data(verifier_errors)
    return ok

client_pem = '/path/to/client.pem'
client_issuer_ca = '/path/to/ca.pem'
host = 'example.com'
port = 443

ctx = SSL.Context(method=SSL.SSLv23_METHOD) # will negotiate TLS1.3 or lower protocol, what every is highest possible during negotiation
ctx.load_verify_locations(cafile=where())
if client_pem is not None:
    ctx.use_certificate_file(certfile=client_pem, filetype=FILETYPE_PEM)
    if client_issuer_ca is not None:
        ctx.load_client_ca(cafile=client_issuer_ca)
ctx.set_verify(SSL.VERIFY_NONE, openssl_verifier)
ctx.check_hostname = False
conn = SSL.Connection(ctx, socket(AF_INET, SOCK_STREAM))
conn.connect((host, port))
conn.settimeout(3)
conn.set_tlsext_host_name(idna.encode(host))
conn.setblocking(1)
conn.set_connect_state()
try:
    conn.do_handshake()
    verifier_errors = conn.get_app_data()
except SSL.Error as err:
    raise SSL.Error from err
finally:
    conn.close()

# handle your errors in your main app
print(verifier_errors)

Just make sure you handle those OPENSSL_CODES errors if any are encountered, the lookup dictionary is here.

Many validations occur pre verification inside OpenSSL itself and all PyOpenSSL will do is a basic validation. so we need to access these codes from OpenSSL if we want to do Client Authentication, i.e. on the client and throw away the response from an untrusted server if it fails any authentication checks on the client side, per Client Authorisation or rather mutual-TLS dictates

Stof
  • 610
  • 7
  • 16
  • p.s. You say `until the solution works with all https servers` but this is an oxymoron. the onus is on both client and server and therefore you are mistaken to think there will ever be a solution for all 'servers' ignorant of the client responsibility to have more 'control' and more pre-shared secrets to ensure client-authentication is authentic and the server response is trustworthy – Stof Oct 05 '21 at 04:38
  • I changed `with all https servers` to `with all https servers that I tried out` As you say I cannot try ou `all https servers`, but if I know already cases in which a proposed solution doesn't work, then I'll try to improve until I have a more generic solution – gelonida Oct 06 '21 at 11:40
  • In fact I removed the whole section, as the I edited the answer over time and at the moment the solution is working for all servers (that I was testing with) And as you see at the moment I marked my answer as correct one. As soon as I have an answer that implements in python the equivalent of the openssl command (as asked in the title of the question) I will accept it. I don't know, but have the impression, that browser don't do much more. This mechanism is just used to filter the list of certs stored in the browser so that one has to scroll less to choose the desired client cert. – gelonida Oct 06 '21 at 11:45
  • @gelonida please see your working solution – Stof Oct 07 '21 at 12:32
0

@Stof's solution is more complete than this one. So I selected his answer as 'official' answer.

This answer predates his, but might still be of some interest.

With @Steffen Ullrich's help I found following solution, which works for all the (nginx with a ssl_client_certificate setting) servers that I tested with.

It requires to install an external package

pip install pyopenssl

Then following work will work:

import socket
from OpenSSL import SSL

def get_client_cert_cas(hostname, port):
    ctx = SSL.Context(SSL.SSLv23_METHOD)
    # If we don't force to NOT use TLSv1.3 get_client_ca_list() returns
    # an empty result
    ctx.set_options(SSL.OP_NO_TLSv1_3)
    sock = SSL.Connection(ctx, socket.socket(socket.AF_INET, socket.SOCK_STREAM))
    # next line for SNI
    sock.set_tlsext_host_name(hostname.encode("utf-8"))
    sock.connect((hostname, port))

    # without handshake get_client_ca_list will be empty
    sock.do_handshake()  
    return sock.get_client_ca_list()

The line sock.do_handshake() is required to trigger enough of the SSL protocol. Otherwise client_ca_list information doesn't seem to be populated.

At least for the servers, that I tested I had to make sure TLSv1.3 is not used. I don't know whether this is a bug, a feature or whether with TLSv1.3 another function has to be called prior to calling get_client_ca_list()

I am no pyopenssl expert, but could imagine, that there is a more elegant / more explicit way to get the same behavior.

but so far this works for me for all the servers, that I encountered.

gelonida
  • 5,327
  • 2
  • 23
  • 41
  • carful using set_tlsext_host_name. If you visit https://wrong.host.badssl.com/ in a browser you will get NET::ERR_CERT_COMMON_NAME_INVALID but if you use a valid idna encoded host with set_tlsext_host_name it will actually negotiate a certificate with the "requested" common name which can be checked using the fingerprint and is not the experience of visitors using browsers – Stof Oct 02 '21 at 17:37
  • Not sure what you try to say. You mean, that you don't get the list of the CA's if the hostname is invalid for a given server? If that's it I can add comments doc strings to highlight that limitation. If it's something else, could you please try to explain differently? – gelonida Oct 02 '21 at 23:07
  • very very simply, using `set_tlsext_host_name` forces a connection to work in some cases when it shouldn't if you want to be safe or emulate the experience in a browser like chrome. – Stof Oct 03 '21 at 22:36
  • Apologies for being so slow. The goal of this (as the title says) is to get the list of CAs for client certs from a server. So you want to say, that above code would retrieve the CA list for '*.badssl.com' if I specify 'a.b.badssl.com' even if it shouldn't, as a browser would never be able to access that site? Is there an easy way to fix this (to make the behavior identical to the browser ones)? Pls note in my particular use case this wouldn't be an issue, but as one never knows who'll copy paste this code ... it could be good to cover the case. – gelonida Oct 03 '21 at 23:55
  • Yeah we can do that, make the code act like a browser. But which browser? which version? which site using which tls extensions? Perhaps it would make a good question. For the topic here, the question is poor quality, there is no self-enclosed test (no server to query or provided X.509 to inspect) therefore it is all guess work, the fact your answer was accepted is due to OP solving thier problem in a silo we cannot see, fact is get_client_ca_list yields different results on server vs client and it is a fluke the OP has a working solution based on the unknowns of the question – Stof Oct 05 '21 at 00:09
  • Harsh criticism on my post. Try to clarify: There are nginx web servers (with ` ssl_client_certificate` entry in the conf) that accept client side certs signed by a list of CAs (list of CAs depends on server) cant publish the server names. Browser (e.g.Firefox) know to display only client certs, that might be accepted by the given server. The openssl command posted in my question seems to return for all the servers I tested the information I need. I'm looking for a pure python solution to get the same information. What's missing in my question to make my intent clear? – gelonida Oct 06 '21 at 11:05
  • Worth noticing: I try to write code that gathers information, not code, that establishes an HTTP connection. It shall allow to obtain info about a server's certificate and about the CA's that can sign client certs. To actually connect with a client cert I use standard libs e.g. `requests`/`iohttp` I still don't see the guess work, that is required to understand my question but am open to any rephrasing / suggestions which make my question easier to understand. – gelonida Oct 06 '21 at 11:16
  • Please note I'm no SSL specialist at all. I am happy if the code I have gives me the correct answer, but if I have to do strange things like for example force to not use TLS1.3 in order to get the list of CA's I'd like to understand whether there is not another way more generic way and if there is to adapt the answer, that it can also be useful for others. – gelonida Oct 06 '21 at 11:29
  • @Stof By the way `tls-verify` looks interesting. Have to look at it in more detail – gelonida Oct 06 '21 at 12:58