Validar certificados SSL con Python

85

Necesito escribir un script que se conecte a varios sitios en nuestra intranet corporativa a través de HTTPS y verifique que sus certificados SSL sean válidos; que no están vencidos, que se emiten para la dirección correcta, etc. Usamos nuestra propia Autoridad de Certificación corporativa interna para estos sitios, por lo que tenemos la clave pública de la CA para verificar los certificados.

Python de forma predeterminada solo acepta y usa certificados SSL cuando se usa HTTPS, por lo que incluso si un certificado no es válido, las bibliotecas de Python como urllib2 y Twisted simplemente usarán felizmente el certificado.

¿Existe una buena biblioteca en algún lugar que me permita conectarme a un sitio a través de HTTPS y verificar su certificado de esta manera?

¿Cómo verifico un certificado en Python?

Eli Courtwright
fuente
10
Su comentario sobre Twisted es incorrecto: Twisted usa pyopenssl, no el soporte SSL integrado de Python. Si bien no valida los certificados HTTPS de forma predeterminada en su cliente HTTP, puede usar el argumento "contextFactory" para getPage y downloadPage para construir una fábrica de contexto de validación. Por el contrario, que yo sepa, no hay forma de que se pueda convencer al módulo "ssl" incorporado para que realice la validación del certificado.
Glifo
4
Con el módulo SSL en Python 2.6 y posterior, puede escribir su propio validador de certificados. No óptimo, pero factible.
Heikki Toivonen
3
La situación cambió, Python ahora valida los certificados por defecto. He agregado una nueva respuesta a continuación.
Dr. Jan-Philip Gehrcke
La situación también cambió para Twisted (algo antes que para Python, de hecho); Si usa treqotwisted.web.client.Agent desde la versión 14.0, Twisted verifica los certificados de forma predeterminada.
Glifo

Respuestas:

19

Desde la versión de lanzamiento 2.7.9 / 3.4.3 en adelante, Python intenta de forma predeterminada realizar la validación del certificado.

Esto se ha propuesto en PEP 467, que vale la pena leer: https://www.python.org/dev/peps/pep-0476/

Los cambios afectan a todos los módulos stdlib relevantes (urllib / urllib2, http, httplib).

Documentación relevante:

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

Esta clase ahora realiza todas las comprobaciones necesarias de certificados y nombres de host de forma predeterminada. Para volver al comportamiento anterior, no verificado, ssl._create_unverified_context () se puede pasar al parámetro de contexto.

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

Modificado en la versión 3.4.3: esta clase ahora realiza todas las comprobaciones necesarias de certificado y nombre de host de forma predeterminada. Para volver al comportamiento anterior, no verificado, ssl._create_unverified_context () se puede pasar al parámetro de contexto.

Tenga en cuenta que la nueva verificación incorporada se basa en la base de datos de certificados proporcionada por el sistema . En oposición a eso, el paquete de solicitudes envía su propio paquete de certificados. Los pros y los contras de ambos enfoques se analizan en la sección de la base de datos Trust de PEP 476 .

Dr. Jan-Philip Gehrcke
fuente
¿Alguna solución para garantizar las verificaciones del certificado para la versión anterior de Python? No siempre se puede actualizar la versión de Python.
vaab
no valida los certificados revocados. Por ejemplo, revooked.badssl.com
Raz
¿Es obligatorio usar la HTTPSConnectionclase? Estaba usando SSLSocket. ¿Cómo puedo hacer la validación con SSLSocket? ¿Tengo que validar explícitamente el uso pyopensslcomo se explica aquí ?
anir
31

He agregado una distribución al índice de paquetes de Python que hace que la match_hostname()función del sslpaquete de Python 3.2 esté disponible en versiones anteriores de Python.

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

Puedes instalarlo con:

pip install backports.ssl_match_hostname

O puede convertirlo en una dependencia enumerada en el archivo setup.py. De cualquier manera, se puede usar así:

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:
    ...
Brandon Rhodes
fuente
1
Me falta algo ... ¿podría completar los espacios en blanco anteriores o proporcionar un ejemplo completo (para un sitio como Google)?
smholloway
El ejemplo se verá diferente dependiendo de la biblioteca que esté usando para acceder a Google, ya que diferentes bibliotecas colocan el socket SSL en diferentes lugares, y es el socket SSL el que necesita que se getpeercert()llame a su método para que se pueda pasar la salida match_hostname().
Brandon Rhodes
12
Me avergüenza en nombre de Python que alguien tenga que usar esto. Las bibliotecas SSL HTTPS incorporadas de Python que no verifican los certificados de forma predeterminada son completamente una locura, y es doloroso imaginar cuántos sistemas inseguros existen ahora como resultado.
Glenn Maynard
26

Puede utilizar Twisted para verificar certificados. La API principal es CertificateOptions , que se puede proporcionar como contextFactoryargumento para varias funciones como listenSSL y startTLS .

Desafortunadamente, ni Python ni Twisted vienen con el montón de certificados de CA necesarios para realizar la validación HTTPS, ni la lógica de validación HTTPS. Debido a una limitación en PyOpenSSL , todavía no puede hacerlo completamente correctamente, pero gracias al hecho de que casi todos los certificados incluyen un sujeto commonName, puede acercarse lo suficiente.

Aquí hay una implementación de muestra ingenua de un cliente HTTPS Twisted de verificación que ignora los comodines y las extensiones subjectAltName, y usa los certificados de autoridad de certificación presentes en el paquete 'ca -ificates' en la mayoría de las distribuciones de Ubuntu. Pruébelo con sus sitios favoritos de certificados válidos y no válidos :).

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()
Glifo
fuente
¿Puedes hacerlo sin bloqueo?
sean riley
Gracias; Tengo una nota ahora que he leído y entendido esto: verificar que las devoluciones de llamada deben devolver True cuando no hay error y False cuando lo hay. Su código básicamente devuelve un error cuando commonName no es localhost. No estoy seguro de si eso es lo que pretendía, aunque tendría sentido hacerlo en algunos casos. Simplemente pensé que dejaría un comentario sobre esto en beneficio de los futuros lectores de esta respuesta.
Eli Courtwright
"self.hostname" en ese caso no es "localhost"; tenga en cuenta URLPath(url).netloc: eso significa que la parte del host de la URL pasada a secureGet. En otras palabras, está comprobando que el commonName del sujeto es el mismo que el que está solicitando la persona que llama.
Glifo
He estado ejecutando una versión de este código de prueba y he usado Firefox, wget y Chrome para acceder a un servidor HTTPS de prueba. Sin embargo, en mis ejecuciones de prueba, veo que la devolución de llamada verifyHostname se llama 3-4 veces en cada conexión. ¿Por qué no se ejecuta solo una vez?
themaestro
2
URLPath (blah) .netloc es siempre localhost: URLPath .__ init__ toma componentes de URL individuales, estás pasando una URL completa como "esquema" y obteniendo el netloc predeterminado de 'localhost' para acompañarlo. Probablemente quisiste usar URLPath.fromString (url) .netloc. Desafortunadamente, eso expone que el cheque en verifyHostName está al revés: comienza a rechazar https://www.google.com/porque uno de los sujetos es 'www.google.com', lo que hace que la función devuelva False. Probablemente pretendía devolver Verdadero (aceptado) si los nombres coinciden, y Falso si no lo hacen.
mzz
25

PycURL hace esto maravillosamente.

A continuación se muestra un breve ejemplo. Arrojará unpycurl.error si algo está sospechoso, donde obtendrá una tupla con un código de error y un mensaje legible por humanos.

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

Probablemente desee configurar más opciones, como dónde almacenar los resultados, etc. Pero no es necesario saturar el ejemplo con cosas que no son esenciales.

Ejemplo de las excepciones que se pueden generar:

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

Algunos enlaces que encontré útiles son libcurl-docs para setopt y getinfo.

plundra
fuente
15

O simplemente hazle la vida más fácil usando la biblioteca de solicitudes :

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

Algunas palabras más sobre su uso.

OVNI
fuente
10
El certargumento es el certificado del lado del cliente, no un certificado de servidor para verificar. Quieres usar el verifyargumento.
Paŭlo Ebermann
2
solicitudes valida por defecto . No es necesario utilizar el verifyargumento, excepto para ser más explícito o deshabilitar la verificación.
Dr. Jan-Philip Gehrcke
1
No es un módulo interno. Necesita ejecutar solicitudes de instalación de pip
Robert Townley
14

Aquí hay un script de ejemplo que demuestra la validación del certificado:

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()
Eli Courtwright
fuente
@tonfa: Buena captura; Terminé agregando también la verificación del nombre de host y edité mi respuesta para incluir el código que usé.
Eli Courtwright
No puedo llegar al enlace original (es decir, "esta página"). ¿Se ha movido?
Matt Ball
@Matt: Supongo que sí, pero FWIW el enlace original no es necesario, ya que mi programa de prueba es un ejemplo completo, autónomo y funcional. Me vinculé a la página que me ayudó a escribir ese código, ya que parecía lo decente proporcionar atribución. Pero como ya no existe, editaré mi publicación para eliminar el enlace, gracias por señalar esto.
Eli Courtwright
Esto no funciona con controladores adicionales como los controladores de proxy debido a la conexión de socket manual en CertValidatingHTTPSConnection.connect. Consulte esta solicitud de extracción para obtener más detalles (y una solución).
schlamar
2
Aquí hay una solución limpia y funcional con backports.ssl_match_hostname.
schlamar
8

M2Crypto puede hacer la validación . También puede usar M2Crypto con Twisted si lo desea. El cliente de escritorio de Chandler usa Twisted para redes y M2Crypto para SSL , incluida la validación de certificados.

Según el comentario de Glyphs, parece que M2Crypto realiza una mejor verificación de certificados de forma predeterminada que lo que puede hacer con pyOpenSSL actualmente, porque M2Crypto también verifica el campo subjectAltName.

También escribí en un blog sobre cómo obtener los certificados con los que se envía Mozilla Firefox en Python y que se pueden usar con las soluciones SSL de Python.

Heikki Toivonen
fuente
4

Jython SÍ lleva a cabo la verificación del certificado de forma predeterminada, por lo que el uso de módulos de biblioteca estándar, por ejemplo, http: http: // www. HTTPSConnection, etc., con jython verificará los certificados y dará excepciones para fallas, es decir, identidades no coincidentes, certificados vencidos, etc.

De hecho, tienes que hacer un trabajo adicional para que jython se comporte como cpython, es decir, para que jython NO verifique los certificados.

Escribí una publicación de blog sobre cómo deshabilitar la verificación de certificados en jython, porque puede ser útil en las fases de prueba, etc.

Instalación de un proveedor de seguridad de confianza en java y jython.
http://jython.xhaus.com/installing-an-all-trusting-security-provider-on-java-and-jython/

Alan Kennedy
fuente
2

El siguiente código le permite beneficiarse de todas las comprobaciones de validación SSL (por ejemplo, validez de fecha, cadena de certificados de CA ...) EXCEPTO un paso de verificación conectable, por ejemplo, para verificar el nombre de host o realizar otros pasos de verificación de certificados adicionales.

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()
Carl D'Halluin
fuente
-1

pyOpenSSL es una interfaz para la biblioteca OpenSSL. Debe proporcionar todo lo que necesita.

DesplazadoAussie
fuente
OpenSSL no realiza la coincidencia de nombres de host. Está previsto para OpenSSL 1.1.0.
jww
-1

Tenía el mismo problema pero quería minimizar las dependencias de terceros (porque muchos usuarios iban a ejecutar este script único). Mi solución fue terminar una curlllamada y asegurarme de que el código de salida fuera 0. Trabajado como un encanto.

Ztyx
fuente
Yo diría que stackoverflow.com/a/1921551/1228491 usando pycurl es una solución mucho mejor entonces.
Marian