python ssl(相当于 openssl s_client -showcerts )如何从服务器获取客户端证书的 CA 列表

python ssl (eqivalent of openssl s_client -showcerts ) How to get list of CAs for client certs from server

我有一组接受客户端证书的 nginx 服务器。 他们有 ssl_client_certificate 选项,文件包含一个或多个 CA

如果我使用网络浏览器,那么网络浏览器似乎会收到客户端证书的有效 CA 列表。浏览器仅显示由这些 CA 之一签署的客户端证书。

以下 openssl 命令为我提供了 CA 证书列表:

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

我感兴趣的行如下所示:

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

如何获取与python相同的信息?

我确实有以下代码片段,它允许获取服务器的证书,但此代码不return客户端证书的 CA 列表。

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

我希望找到 getpeercert() 的功能等效项,例如 getpeercas(),但没有找到任何东西。

当前解决方法:

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

更新:使用 pyopenssl 的解决方案(感谢 Steffen Ullrich)

@Steffen Ulrich 建议使用 pyopenssl,它有一个方法 get_client_ca_list(),这帮助我编写了一个小代码片段。

以下代码似乎有效。不知道能不能改进,有没有坑。

如果在接下来的几天内没有人回答,我会 post 这作为一个潜在的答案。

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

更新时间:2021-03-31

以上建议的使用 pyopenssl 的解决方案在大多数情况下都有效。 但是 sock.get_client_ca_list()) 不能在执行 sock.connect((hostname, port)) 后立即调用 在这两个命令之间似乎需要执行一些操作。

最初我用的是sock.send(b"G"),现在我用的是sock.do_handshake(),好像比较干净

更奇怪的是,该解决方案不适用于 TLSv1.3,所以我不得不排除它。

@Stof 的解决方案比这个更完整。 所以我把他的答案选为'official'答案。

这个回答早于他的回答,但可能仍然有些意思。

在@Steffen Ullrich 的帮助下,我找到了以下解决方案, 它适用于我测试过的所有(具有 ssl_client_certificate 设置的 nginx)服务器。

需要安装外部包

pip install pyopenssl

然后将进行以下工作:

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

需要行 sock.do_handshake() 才能触发足够的 SSL 协议。否则 client_ca_list 信息似乎没有填充。

至少对于我测试的服务器,我必须确保 使用 TLSv1.3。我不知道这是否是错误、功能或使用 TLSv1.3 时是否必须在调用 get_client_ca_list()

之前调用另一个函数

我不是 pyopenssl 专家,但可以想象,有一种更优雅/更明确的方式来获得相同的行为。

但到目前为止,这适用于我遇到的所有服务器。

作为 python

中的通用示例
  1. 首先您需要联系服务器以了解它接受哪个颁发者 CA 主题:
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

这没有客户端证书,因此 TLS 连接会失败。这里有很多不好的做法,但不幸的是,它们是必要的,也是在我们真正想要使用正确的证书尝试客户端身份验证之前从服务器收集消息的唯一方法。

  1. 接下来根据服务器加载证书:
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

在真实的应用程序(不是示例代码段)中,您将拥有某种数据库来获取服务器主题并查找要加载的证书的位置 - 此示例只是为了演示而反向执行。

  1. 建立 TLS 连接,并捕获任何 OpenSSL 错误:
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)

只要确保处理这些 OPENSSL_CODES 错误(如果遇到任何错误),查找字典 is here.

许多验证发生 pre 验证在 OpenSSL 本身内部,所有 PyOpenSSL 将做的是基本验证。因此,如果我们想进行客户端身份验证,我们需要从 OpenSSL 访问这些代码,即在客户端上,如果不可信服务器在客户端的任何身份验证检查失败,则丢弃来自不受信任的服务器的响应,根据客户端授权或相互 TLS 指示