首发于AIGC开发者
Python网络编程中的TLS/SSL。

Python网络编程中的TLS/SSL。

传输层安全协议(TLS)算是如今互联网上应用最广泛的加密方法。

TLS的前身是安全套接层(SSL),现代互联网的许多协议基础协议都是使用TLS来验证服务器身份,并保护传输过程中的数据。

TLS能保护的信息包括:与请求URL之间的HTTPS链接以及以及返回内容、密码或cookie等可能在套接字双向传递的认证信息。

下面的信息无法使用TLS保护:

  • 本机与远程主机都是可见的,地址信息在每个数据包的IP头信息中以纯文本的形式表示。
  • 客户端与服务器的端口号同样在每个TCP头信息中可见。
  • 客户端为了获取服务器的IP地址,可能会先进行DNS查询。该查询在通过网络发送时也是可见的。

通过TLS加密的套接字向任何一方传递数据块的时候,观察者都可以看到数据块的大小。尽管TLS会试图隐藏确切的字节数,但是观察者仍然能看到传输数据块的大致规模。同样,也可以看到请求和响应的整体模式。


关于TLS怎么被设计出来的,那些问题这里就不说,下面说一下生成证书。

Python标准库中并没有提供私钥生成或者证书签名的相关操作。如果需要进行与这两项相关的操作,那么必须使用其他工具。openssl命令行工具就很流行而且很好用。

自己创建证书,通常要先生成两部分信息:第一部分是人工生成的,另一部分是由机器生成。人工生成的信息。人工生成的信息对证书中的描述的实体进行了文本说明,而机器会使用操作系统提供的真正的随机算法精心生成一个秘钥。

你也可以把手写的实体描述保存在一个版本控制文件中,以便今后查看。当然,你也可以直接在弹出的openssl命令提示符中输入实体描述的相关字段。


然后我们说一下TLS负载移除。

这里面先说另外一个点,为什么要直接在Python应用程序中直接进行加密操作,而不是直接使用工具。如果在另外一个端口运行这些工具的话,就可以通过它们对客户端的连接作出响应。

因此,在Python应用程序提供TLS支持的时候有两种选择:方案一是使用一个单独的守护进程或者服务提供TLS支持。方案二则是直接在Python编写的服务器代码中使用提供TLS功能的OpenSSL库。相比较于方案二,方案一更易于升级或者维护。


下面说下Python3.4之后的默认上下文,Python标准库是对OpenSSL库进行封装。当然,Python社区也在研究其他密码学的项目,包括pyOpenSSL。

Python3.4引入了ssl.create_default_context()函数,这样我们就可以轻松在Python应用程序中安全使用TLS。

这是一个简单的客户端和服务器,通过TLS套接字进行安全通信的方法。

import argparse, socket, ssl

def client(host, port, cafile=None):
    purpose = ssl.Purpose.SERVER_AUTH
    context = ssl.create_default_context(purpose, cafile=cafile)

    raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    raw_sock.connect((host, port))
    print('Connected to host {!r} and port {}'.format(host, port))
    ssl_sock = context.wrap_socket(raw_sock, server_hostname=host)

    while True:
        data = ssl_sock.recv(1024)
        if not data:
            break
        print(repr(data))

def server(host, port, certfile, cafile=None):
    purpose = ssl.Purpose.CLIENT_AUTH
    context = ssl.create_default_context(purpose, cafile=cafile)
    context.load_cert_chain(certfile)

    listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    listener.bind((host, port))
    listener.listen(1)
    print('Listening at interface {!r} and port {}'.format(host, port))
    raw_sock, address = listener.accept()
    print('Connection from host {!r} and port {}'.format(*address))
    ssl_sock = context.wrap_socket(raw_sock, server_side=True)

    ssl_sock.sendall('Simple is better than complex.'.encode('ascii'))
    ssl_sock.close()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Safe TLS client and server')
    parser.add_argument('host', help='hostname or IP address')
    parser.add_argument('port', type=int, help='TCP port number')
    parser.add_argument('-a', metavar='cafile', default=None,
                        help='authority: path to CA certificate PEM file')
    parser.add_argument('-s', metavar='certfile', default=None,
                        help='run as server: path to server PEM file')
    args = parser.parse_args()
    if args.s:
        server(args.host, args.port, args.s, args.a)
    else:
        client(args.host, args.port, args.a)


从上面看出,为一个套接字提供安全通信只需要三个步骤。

  1. 第一步是TLS上下文对象。对象中保存了我们对证书的认证与加密算法选择的偏好设置。
  2. 第二步是调用上下文对象的wrap_socket()方法,表示让OpenSLL库负责控制我们的TCP链接。然后与通信对方交换必要的握手信息,并建立加密链接。
  3. 最后一步是使用wrap_socket()调用返回的ssl_sock对象,进行所有的后续通信。


另外,套接字包装的变体有很多,这里就不再说了。另外不再详细说的就是,如果对数据安全性要求很高的话,可能需要自己指定OpenSLL确切使用的加密算法,而不使用create_default_context()函数提供的默认值。


有一个问题就是,如何配置TLS加密算法及选项,以防止通信对方使用这些协议,以防止通信对方使用较弱的协议版本、加密算法或者是像压缩 这种可能降低协议安全性的选项。

这个配置可以通过下面的方式来解决:

  • 第一种是特定于库的API调用
  • 第二种是直接传递一个包含了配置选项的SSLContext对象


然后再看支持TLS的协议:

  • http.client
  • smtplib
  • poplib
  • imaplib
  • ftplib
  • nntplib


下面看一个脚本,这个脚本创建了一个加密链接,然后打印出这个链接的特性。

先看一下如何获取配置信息:

  • getpeercert()
  • cipher()
  • compression()


为了尽可能打印出这些特性,所以使用了ctypes来获取正在使用的TLS协议的信息。这段代码是让我们连接到一个自己构建的客户端或服务器,并了解它们支持的或不支持的加密算法与协议。

import argparse, socket, ssl, sys, textwrap
import ctypes
from pprint import pprint

def open_tls(context, address, server=False):
    raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    if server:
        raw_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        raw_sock.bind(address)
        raw_sock.listen(1)
        say('Interface where we are listening', address)
        raw_client_sock, address = raw_sock.accept()
        say('Client has connected from address', address)
        return context.wrap_socket(raw_client_sock, server_side=True)
    else:
        say('Address we want to talk to', address)
        raw_sock.connect(address)
        return context.wrap_socket(raw_sock)

def describe(ssl_sock, hostname, server=False, debug=False):
    cert = ssl_sock.getpeercert()
    if cert is None:
        say('Peer certificate', 'none')
    else:
        say('Peer certificate', 'provided')
        subject = cert.get('subject', [])
        names = [name for names in subject for (key, name) in names
                 if key == 'commonName']
        if 'subjectAltName' in cert:
            names.extend(name for (key, name) in cert['subjectAltName']
                         if key == 'DNS')

        say('Name(s) on peer certificate', *names or ['none'])
        if (not server) and names:
            try:
                ssl.match_hostname(cert, hostname)
            except ssl.CertificateError as e:
                message = str(e)
            else:
                message = 'Yes'
            say('Whether name(s) match the hostname', message)
        for category, count in sorted(context.cert_store_stats().items()):
            say('Certificates loaded of type {}'.format(category), count)

    try:
        protocol_version = SSL_get_version(ssl_sock)
    except Exception:
        if debug:
            raise
    else:
        say('Protocol version negotiated', protocol_version)

    cipher, version, bits = ssl_sock.cipher()
    compression = ssl_sock.compression()

    say('Cipher chosen for this connection', cipher)
    say('Cipher defined in TLS version', version)
    say('Cipher key has this many bits', bits)
    say('Compression algorithm in use', compression or 'none')

    return cert

class PySSLSocket(ctypes.Structure):
    """The first few fields of a PySSLSocket (see Python's Modules/_ssl.c)."""

    _fields_ = [('ob_refcnt', ctypes.c_ulong), ('ob_type', ctypes.c_void_p),
                ('Socket', ctypes.c_void_p), ('ssl', ctypes.c_void_p)]

def SSL_get_version(ssl_sock):
    """Reach behind the scenes for a socket's TLS protocol version."""

    lib = ctypes.CDLL(ssl._ssl.__file__)
    lib.SSL_get_version.restype = ctypes.c_char_p
    address = id(ssl_sock._sslobj)
    struct = ctypes.cast(address, ctypes.POINTER(PySSLSocket)).contents
    version_bytestring = lib.SSL_get_version(struct.ssl)
    return version_bytestring.decode('ascii')

def lookup(prefix, name):
    if not name.startswith(prefix):
        name = prefix + name
    try:
        return getattr(ssl, name)
    except AttributeError:
        matching_names = (s for s in dir(ssl) if s.startswith(prefix))
        message = 'Error: {!r} is not one of the available names:\n {}'.format(
            name, ' '.join(sorted(matching_names)))
        print(fill(message), file=sys.stderr)
        sys.exit(2)

def say(title, *words):
    print(fill(title.ljust(36, '.') + ' ' + ' '.join(str(w) for w in words)))

def fill(text):
    return textwrap.fill(text, subsequent_indent='    ',
                         break_long_words=False, break_on_hyphens=False)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Protect a socket with TLS')
    parser.add_argument('host', help='hostname or IP address')
    parser.add_argument('port', type=int, help='TCP port number')
    parser.add_argument('-a', metavar='cafile', default=None,
                        help='authority: path to CA certificate PEM file')
    parser.add_argument('-c', metavar='certfile', default=None,
                        help='path to PEM file with client certificate')
    parser.add_argument('-C', metavar='ciphers', default='ALL',
                        help='list of ciphers, formatted per OpenSSL')
    parser.add_argument('-p', metavar='PROTOCOL', default='SSLv23',
                        help='protocol version (default: "SSLv23")')
    parser.add_argument('-s', metavar='certfile', default=None,
                        help='run as server: path to certificate PEM file')
    parser.add_argument('-d', action='store_true', default=False,
                        help='debug mode: do not hide "ctypes" exceptions')
    parser.add_argument('-v', action='store_true', default=False,
                        help='verbose: print out remote certificate')
    args = parser.parse_args()

    address = (args.host, args.port)
    protocol = lookup('PROTOCOL_', args.p)

    context = ssl.SSLContext(protocol)
    context.set_ciphers(args.C)
    context.check_hostname = False
    if (args.s is not None) and (args.c is not None):
        parser.error('you cannot specify both -c and -s')
    elif args.s is not None:
        context.verify_mode = ssl.CERT_OPTIONAL
        purpose = ssl.Purpose.CLIENT_AUTH
        context.load_cert_chain(args.s)
    else:
        context.verify_mode = ssl.CERT_REQUIRED
        purpose = ssl.Purpose.SERVER_AUTH
        if args.c is not None:
            context.load_cert_chain(args.c)
    if args.a is None:
        context.load_default_certs(purpose)
    else:
        context.load_verify_locations(args.a)

    print()
    ssl_sock = open_tls(context, address, args.s)
    cert = describe(ssl_sock, args.host, args.s, args.d)
    print()
    if args.v:
        pprint(cert)



到了这里,这篇文章就结束了。

要注意的是,一旦我们在自己的应用程序中实现了TLS,就应该始终使用工具对那些具有不同参数集的链接进行测试。

最后的最后,寒假快结束了。

祝大家天天开心,新的一年更加的万事胜意。

编辑于 2018-02-26 14:56