使用 Python 验证 SSL 证书

Posted

技术标签:

【中文标题】使用 Python 验证 SSL 证书【英文标题】:Validate SSL certificates with Python 【发布时间】:2010-11-08 09:41:06 【问题描述】:

我需要编写一个脚本,通过 HTTPS 连接到我们公司 Intranet 上的一堆站点,并验证它们的 SSL 证书是否有效;它们没有过期,它们是为正确的地址颁发的,等等。我们对这些站点使用我们自己的内部公司证书颁发机构,因此我们有 CA 的公钥来验证证书。

默认情况下,Python 在使用 HTTPS 时只接受并使用 SSL 证书,因此即使证书无效,urllib2 和 Twisted 等 Python 库也会愉快地使用该证书。

是否有一个好的库可以让我通过 HTTPS 连接到站点并以这种方式验证其证书?

如何在 Python 中验证证书?

【问题讨论】:

您对 Twisted 的评论不正确:Twisted 使用 pyopenssl,而不是 Python 的内置 SSL 支持。虽然默认情况下它不会在其 HTTP 客户端中验证 HTTPS 证书,但您可以使用 getPage 和 downloadPage 的“contextFactory”参数来构建验证上下文工厂。相比之下,据我所知,无法说服内置的“ssl”模块进行证书验证。 使用 Python 2.6 及更高版本中的 SSL 模块,您可以编写自己的证书验证器。不是最佳的,但可行。 情况发生了变化,Python 现在默认验证证书。我在下面添加了一个新答案。 Twisted 的情况也发生了变化(实际上在 Python 发生之前);如果您从 14.0 版本开始使用 treqtwisted.web.client.Agent,则默认情况下 Twisted 会验证证书。 【参考方案1】:

我已向 Python 包索引添加了一个分发包,它使 Python 3.2 ssl 包中的 match_hostname() 函数可用于以前版本的 Python。

http://pypi.python.org/pypi/backports.ssl_match_hostname/

你可以安装它:

pip install backports.ssl_match_hostname

或者您可以将其设置为项目的setup.py 中列出的依赖项。无论哪种方式,它都可以这样使用:

from backports.ssl_match_hostname import match_hostname, CertificateError
...
sslsock = ssl.wrap_socket(sock, ssl_version=ssl.PROTOCOL_SSLv3,
                      cert_reqs=ssl.CERT_REQUIRED, ca_certs=...)
try:
    match_hostname(sslsock.getpeercert(), hostname)
except CertificateError, ce:
    ...

【讨论】:

我遗漏了一些东西...您能否填写上面的空白或提供一个完整的示例(对于像 Google 这样的网站)? 该示例看起来会有所不同,具体取决于您用于访问 Google 的库,因为不同的库将 SSL 套接字放在不同的位置,而 SSL 套接字需要调用其 getpeercert() 方法,因此输出可以传递给match_hostname() 我为 Python 感到尴尬,任何人都必须使用它。 Python 的内置 SSL HTTPS 库默认情况下不验证开箱即用的证书是完全疯狂的,想象一下现在有多少不安全的系统因此而变得痛苦。 @Glenn - 另见New SSL module doesn't seem to verify hostname against commonName in certificate。【参考方案2】:

您可以使用 Twisted 来验证证书。主要API是CertificateOptions,可以作为contextFactory参数提供给listenSSL和startTLS等各种函数。

不幸的是,Python 和 Twisted 都没有提供实际进行 HTTPS 验证所需的一堆 CA 证书,也没有 HTTPS 验证逻辑。由于a limitation in PyOpenSSL,您目前还不能完全正确地做到这一点,但由于几乎所有证书都包含一个主题 commonName,您可以足够接近。

这是一个验证 Twisted HTTPS 客户端的简单示例实现,它忽略通配符和 subjectAltName 扩展,并使用大多数 Ubuntu 发行版中“ca-certificates”包中存在的证书颁发机构证书。用你最喜欢的有效和无效证书网站试试吧:)。

import os
import glob
from OpenSSL.SSL import Context, TLSv1_METHOD, VERIFY_PEER, VERIFY_FAIL_IF_NO_PEER_CERT, OP_NO_SSLv2
from OpenSSL.crypto import load_certificate, FILETYPE_PEM
from twisted.python.urlpath import URLPath
from twisted.internet.ssl import ContextFactory
from twisted.internet import reactor
from twisted.web.client import getPage
certificateAuthorityMap = 
for certFileName in glob.glob("/etc/ssl/certs/*.pem"):
    # There might be some dead symlinks in there, so let's make sure it's real.
    if os.path.exists(certFileName):
        data = open(certFileName).read()
        x509 = load_certificate(FILETYPE_PEM, data)
        digest = x509.digest('sha1')
        # Now, de-duplicate in case the same cert has multiple names.
        certificateAuthorityMap[digest] = x509
class HTTPSVerifyingContextFactory(ContextFactory):
    def __init__(self, hostname):
        self.hostname = hostname
    isClient = True
    def getContext(self):
        ctx = Context(TLSv1_METHOD)
        store = ctx.get_cert_store()
        for value in certificateAuthorityMap.values():
            store.add_cert(value)
        ctx.set_verify(VERIFY_PEER | VERIFY_FAIL_IF_NO_PEER_CERT, self.verifyHostname)
        ctx.set_options(OP_NO_SSLv2)
        return ctx
    def verifyHostname(self, connection, x509, errno, depth, preverifyOK):
        if preverifyOK:
            if self.hostname != x509.get_subject().commonName:
                return False
        return preverifyOK
def secureGet(url):
    return getPage(url, HTTPSVerifyingContextFactory(URLPath.fromString(url).netloc))
def done(result):
    print 'Done!', len(result)
secureGet("https://google.com/").addCallback(done)
reactor.run()

【讨论】:

你能做到非阻塞吗? 谢谢;我现在有一个注释,我已经阅读并理解了这一点:验证回调应该在没有错误时返回 True,在有错误时返回 False。当 commonName 不是 localhost 时,您的代码基本上会返回错误。我不确定这是否是您的意图,尽管在某些情况下这样做是有意义的。我只是想我会对此发表评论,以使该答案的未来读者受益。 "self.hostname" 在这种情况下不是 "localhost";注意URLPath(url).netloc: 这意味着传递给secureGet 的URL 的主机部分。换句话说,它正在检查主题的 commonName 是否与调用者请求的相同。 我一直在运行这个测试代码的一个版本,并使用 Firefox、wget 和 Chrome 来访问测试 HTTPS 服务器。不过,在我的测试运行中,我看到每个连接都会调用回调 verifyHostname 3-4 次。为什么它不只运行一次? URLPath(blah).netloc is 总是 localhost:URLPath.__init__ 采用单独的 url 组件,您将整个 url 作为“方案”传递并获取默认的 netloc 'localhost' 来配合它。您可能打算使用 URLPath.fromString(url).netloc。不幸的是,这暴露了对 verifyHostName 的检查是向后的:它开始拒绝https://www.google.com/,因为其中一个主题是“www.google.com”,导致函数返回 False。如果名称匹配,它可能意味着返回 True(接受),如果不匹配则返回 False?【参考方案3】:

PycURL 做得很好。

以下是一个简短的示例。如果有问题,它会抛出一个pycurl.error,在那里你会得到一个带有错误代码和人类可读消息的元组。

import pycurl

curl = pycurl.Curl()
curl.setopt(pycurl.CAINFO, "myFineCA.crt")
curl.setopt(pycurl.SSL_VERIFYPEER, 1)
curl.setopt(pycurl.SSL_VERIFYHOST, 2)
curl.setopt(pycurl.URL, "https://internal.stuff/")

curl.perform()

您可能想要配置更多选项,例如存储结果的位置等。但无需将示例与非必需项混在一起。

可能引发的异常示例:

(60, 'Peer certificate cannot be authenticated with known CA certificates')
(51, "common name 'CN=something.else.stuff,O=Example Corp,C=SE' does not match 'internal.stuff'")

我发现一些有用的链接是 setopt 和 getinfo 的 libcurl-docs。

http://curl.haxx.se/libcurl/c/curl_easy_setopt.html http://curl.haxx.se/libcurl/c/curl_easy_getinfo.html

【讨论】:

【参考方案4】:

从发布版本 2.7.9/3.4.3 开始,Python默认会尝试执行证书验证。

这已在 PEP 467 中提出,值得一读:https://www.python.org/dev/peps/pep-0476/

这些更改会影响所有相关的 stdlib 模块(urllib/urllib2、http、httplib)。

相关文档:

https://docs.python.org/2/library/httplib.html#httplib.HTTPSConnection

这个类现在默认执行所有必要的证书和主机名检查。要恢复到之前未验证的行为,可以将 ssl._create_unverified_context() 传递给 context 参数。

https://docs.python.org/3/library/http.client.html#http.client.HTTPSConnection

在 3.4.3 版中更改:该类现在默认执行所有必要的证书和主机名检查。要恢复到之前未验证的行为,可以将 ssl._create_unverified_context() 传递给 context 参数。

请注意,新的内置验证基于系统提供的证书数据库。与此相反,requests 软件包提供了自己的证书包。 Trust database section of PEP 476 讨论了这两种方法的优缺点。

【讨论】:

任何解决方案来确保验证以前版本的 python 的证书?不能总是升级python的版本。 它不验证吊销的证书。例如。 revoked.badssl.com 必须使用HTTPSConnection类吗?我正在使用SSLSocket。如何使用SSLSocket 进行验证?我是否必须按照here 的说明使用pyopenssl 进行明确验证?【参考方案5】:

或者使用requests 库让您的生活更轻松:

import requests
requests.get('https://somesite.com', cert='/path/server.crt', verify=True)

A few more words about its usage.

【讨论】:

cert 参数是客户端证书,而不是要检查的服务器证书。您想使用 verify 参数。 请求验证默认。无需使用verify 参数,除非更明确或禁用验证。 它不是一个内部模块。您需要运行 pip install requests【参考方案6】:

这是一个演示证书验证的示例脚本:

import httplib
import re
import socket
import sys
import urllib2
import ssl

class InvalidCertificateException(httplib.HTTPException, urllib2.URLError):
    def __init__(self, host, cert, reason):
        httplib.HTTPException.__init__(self)
        self.host = host
        self.cert = cert
        self.reason = reason

    def __str__(self):
        return ('Host %s returned an invalid certificate (%s) %s\n' %
                (self.host, self.reason, self.cert))

class CertValidatingHTTPSConnection(httplib.HTTPConnection):
    default_port = httplib.HTTPS_PORT

    def __init__(self, host, port=None, key_file=None, cert_file=None,
                             ca_certs=None, strict=None, **kwargs):
        httplib.HTTPConnection.__init__(self, host, port, strict, **kwargs)
        self.key_file = key_file
        self.cert_file = cert_file
        self.ca_certs = ca_certs
        if self.ca_certs:
            self.cert_reqs = ssl.CERT_REQUIRED
        else:
            self.cert_reqs = ssl.CERT_NONE

    def _GetValidHostsForCert(self, cert):
        if 'subjectAltName' in cert:
            return [x[1] for x in cert['subjectAltName']
                         if x[0].lower() == 'dns']
        else:
            return [x[0][1] for x in cert['subject']
                            if x[0][0].lower() == 'commonname']

    def _ValidateCertificateHostname(self, cert, hostname):
        hosts = self._GetValidHostsForCert(cert)
        for host in hosts:
            host_re = host.replace('.', '\.').replace('*', '[^.]*')
            if re.search('^%s$' % (host_re,), hostname, re.I):
                return True
        return False

    def connect(self):
        sock = socket.create_connection((self.host, self.port))
        self.sock = ssl.wrap_socket(sock, keyfile=self.key_file,
                                          certfile=self.cert_file,
                                          cert_reqs=self.cert_reqs,
                                          ca_certs=self.ca_certs)
        if self.cert_reqs & ssl.CERT_REQUIRED:
            cert = self.sock.getpeercert()
            hostname = self.host.split(':', 0)[0]
            if not self._ValidateCertificateHostname(cert, hostname):
                raise InvalidCertificateException(hostname, cert,
                                                  'hostname mismatch')


class VerifiedHTTPSHandler(urllib2.HTTPSHandler):
    def __init__(self, **kwargs):
        urllib2.AbstractHTTPHandler.__init__(self)
        self._connection_args = kwargs

    def https_open(self, req):
        def http_class_wrapper(host, **kwargs):
            full_kwargs = dict(self._connection_args)
            full_kwargs.update(kwargs)
            return CertValidatingHTTPSConnection(host, **full_kwargs)

        try:
            return self.do_open(http_class_wrapper, req)
        except urllib2.URLError, e:
            if type(e.reason) == ssl.SSLError and e.reason.args[0] == 1:
                raise InvalidCertificateException(req.host, '',
                                                  e.reason.args[1])
            raise

    https_request = urllib2.HTTPSHandler.do_request_

if __name__ == "__main__":
    if len(sys.argv) != 3:
        print "usage: python %s CA_CERT URL" % sys.argv[0]
        exit(2)

    handler = VerifiedHTTPSHandler(ca_certs = sys.argv[1])
    opener = urllib2.build_opener(handler)
    print opener.open(sys.argv[2]).read()

【讨论】:

@tonfa:很好的收获;我最终也添加了主机名检查,并且我编辑了我的答案以包含我使用的代码。 我无法访问原始链接(即“此页面”)。搬家了吗? @Matt:我猜是这样,但 FWIW 原始链接不是必需的,因为我的测试程序是一个完整的、独立的、工作示例。我链接到帮助我编写该代码的页面,因为提供归属似乎是一件不错的事情。但由于它不再存在,我将编辑我的帖子以删除链接,感谢您指出这一点。 这不适用于代理处理程序等附加处理程序,因为CertValidatingHTTPSConnection.connect 中的手动套接字连接。有关详细信息(和修复),请参阅 this pull request。 Here 是一个经过清理和工作的解决方案 backports.ssl_match_hostname【参考方案7】:

M2Crypto 可以do the validation。如果您愿意,也可以使用M2Crypto with Twisted。 Chandler 桌面客户端uses Twisted for networking and M2Crypto for SSL,包括证书验证。

根据 Glyphs 的评论,默认情况下,M2Crypto 的证书验证似乎比您目前使用 pyOpenSSL 所做的更好,因为 M2Crypto 也会检查 subjectAltName 字段。

我还写了一篇关于如何get the certificates Mozilla Firefox 附带 Python 并与 Python SSL 解决方案一起使用的博客。

【讨论】:

【参考方案8】:

Jython 默认执行证书验证,因此使用标准库模块,例如带有 jython 的 httplib.HTTPSConnection 等将验证证书并给出失败异常,即身份不匹配、证书过期等。

事实上,您必须做一些额外的工作才能让 jython 表现得像 cpython,即让 jython 不验证证书。

我写了一篇关于如何在 jython 上禁用证书检查的博文,因为它在测试阶段等方面很有用。

在 java 和 jython 上安装全信任安全提供程序。http://jython.xhaus.com/installing-an-all-trusting-security-provider-on-java-and-jython/

【讨论】:

【参考方案9】:

以下代码允许您从所有 SSL 验证检查(例如日期有效性、CA 证书链...)中受益,但可插入验证步骤除外,例如验证主机名或执行其他额外的证书验证步骤。

from httplib import HTTPSConnection
import ssl


def create_custom_HTTPSConnection(host):

    def verify_cert(cert, host):
        # Write your code here
        # You can certainly base yourself on ssl.match_hostname
        # Raise ssl.CertificateError if verification fails
        print 'Host:', host
        print 'Peer cert:', cert

    class CustomHTTPSConnection(HTTPSConnection, object):
        def connect(self):
            super(CustomHTTPSConnection, self).connect()
            cert = self.sock.getpeercert()
            verify_cert(cert, host)

    context = ssl.create_default_context()
    context.check_hostname = False
    return CustomHTTPSConnection(host=host, context=context)


if __name__ == '__main__':
    # try expired.badssl.com or self-signed.badssl.com !
    conn = create_custom_HTTPSConnection('badssl.com')
    conn.request('GET', '/')
    conn.getresponse().read()

【讨论】:

【参考方案10】:

pyOpenSSL 是 OpenSSL 库的接口。它应该提供你需要的一切。

【讨论】:

OpenSSL 不执行主机名匹配。它计划用于 OpenSSL 1.1.0。【参考方案11】:

我遇到了同样的问题,但想尽量减少第 3 方的依赖(因为这个一次性脚本将由许多用户执行)。我的解决方案是包装一个curl 调用并确保退出代码是0。像魅力一样工作。

【讨论】:

我会说 ***.com/a/1921551/1228491 使用 pycurl 是一个更好的解决方案。

以上是关于使用 Python 验证 SSL 证书的主要内容,如果未能解决你的问题,请参考以下文章

Python爬虫编程思想(23):使用requests验证ssl证书

Python 3.6 SSL - 使用TLSv1.0而不是TLSv1.2密码 - (2路身份验证和自签名证书)

[转]关于python出现ssl:certificate_verify_failed问题

python爬虫——SSL证书与Handler处理器

当 HTTPS 站点使用“ISRG Root X1”的 CA 时,Python3.4+requests 2.26 无法验证 SSL 证书 - 为啥?

如何验证在Python SSL证书