6.TLS/SSL
6.0. 传输层安全协议(TLS, Transport Layer Security)是如今web上应用最广泛的加密方法了,1999年成为互联网标准。前身是安全套接层(SSL, Secure Sockets Layer),1995年发布。现代web的许多基础协议都使用TLS来验证Serv身份,并保证传输过程中的数据。
6.0.1. TLS的使用及部署方法一直都在变化。每年都会出现针对TLS加密算法的新型攻击,也就出现了新的加密技术来应对攻击。
6.1. TLS无法保护的信息
只要配置好了TLS-socket,通过该socket发送的数据对其他任何人都只是"胡言乱语"。此外,除非TLS设计者使用的数学出现问题,否则即使是PC/政府特工也无法攻破TLS保护的数据。
TLS能保护的信息包括:与请求URL间的HTTPS连接及返回内容、密码/cookie等可能在socket双向传递的任意认证信息。窃听者无法偷取这些TLS保护的信息。
6.1.0.除了数据外,一个连接中有哪些信息是无法通过TLS保护,并对任意第三方可见的:
1)本机与远程主机的地址都是可见的,地址信息在每个数据包的IP头信息中,以纯文本的形式表示
2)Serv和Cli的port在每个TCP头信息中可见
3)Cli为了获取Serv的IP,会先进行DNS查询。该查询在通过网络发送时,也可见
通过TLS加密的socket,向任一方向传递数据块时,观察者都可看到数据块的大小。尽管TLS会试图隐藏确切的字节数,但观察者仍然能看到传输数据块的大致规模。也可看到进行请求和响应的整体模式。
6.1.1.通过一个例子说明上述弱点。
场景:通过一家咖啡店的wifi,使用一个安全的HTTPS-Cli(浏览器)访问https://pypi.python.org/pypi/skyfield/。在咖啡店中,"观察者"可能是连接到咖啡店wifi的任何人,也可能是控制了咖啡店与外网间的路由器的某个人,观察者可能会了解到哪些信息?
1)PC想pypi.python.org发出了一个DNS查询。除非返回的IP上,还托管了许多其他网站,否则,观察者会猜测我们与该IP的port:443进行的后续通信,都是为了查看https://pypi/oython.org的网页。
2)HTTP是一个支持锁步的协议,Serv完整读取请求后,才会返回响应,观察者同意能区分我们的HTTP请求与Serv响应。
3)观察者还知道返回文档的大致大小,我们获取这些文档的顺序
6.1.2. 不同页面大小不同,观察者可使用web-spyider扫描该网站进行编目。不同样式的页面包含的图片和其他素材也不同,在HTML中对这些资源进行引用。
尽管观察者可能并不具体了解进行的搜索详情,及最终访问/下载的资源,但常常能通过观察到的文件的大致大小,做出很精准的猜测
6.2. 可能出现问题的地方
在建立一个连接时,协议本身要面临哪些挑战?
协议如何克服这些挑战?
假设想要与web上的某个特定hostname与port建立一个TCP对话,尽管不希望让外界知道,将进行hostname的DNS查询,但是连接到的port仍将暴露(除非连接到的Serv拥有者,将Serv绑定到了非标准/有误导性的port,否则port会暴露采用的协议)。
1)向该IP和port发起一个标准的TCP连接。
2) 使用的协议需要在一开始使用几字节的信息来说明要启用加密,任何人都能看到这些说明信息。(HTTPS不会在启用加密前,发送任何信息,但SMTP会来回发送几行文字)
一旦建立并运行了socket,同时完成了表示协议启用加密的几次交互后,TLS就会负责接下来的工作。它能保证,窃听者无法破解通信对方的数据。同时,在与对方的通信过程中,窃听者也无法破解传输的数据。
6.2.1. TLS-Cli需要的第一样东西就是:远程Serv提供的一个二进制文档,证书(certificate):包含了公钥(public key),是一个int,用于对数据加密。只有拥有与公钥对应的私钥(也是int),才能解密并理解相应的信息。如果远程Serv配置正确,且没有被破解,它将是web上唯一拥有该私钥的Serv(相同集群中的其他机器,可能也会拥有该私钥)
TLS如何验证远程Serv确实拥有该私钥?
TLS库会向Serv发送一些已经用公钥加密过的信息,要求Serv返回一个校验码,表示Serv能够使用密钥,成功解密接收到的数据。
此时,TLS栈的实现,要考虑远程证书是否被伪造的问题。只要使用openssl命令行工具,任何人都可创建一个通用名为cn=www.google.com、cn=pypi.python.org等其他任何名字的证书。
为什么要信任发自Serv的证书?
TLS会话保存了一个证书机构(CA)列表,包含了在对web主机进行身份验证时信任的机构。默认情况下,OS/浏览器的TLS库会使用一个标准的CA列表,包含全世界几百个证书机构,表示负责可信网站认证的机构。
6.2.2. 如果对默认列表不满意/使用自己的机构生成的私有CA,来为私有主机证书签名。任何时候都可以提供自己的CA列表。如果只需支持自己服务间的连接,不需将服务提供给外部Serv连接,那么提供私有CA是常用选择。
6.2.3. 为证明一个证书的合法性,CA会为证书加上一个数学标记---签名(signature)。TLS库使用相应CA证书的公钥,验证了证书的签名后,才会认为该证书是合法的。
6.2.4. 确认了证书的内容,确实提交给了可信的第三方机构,且由该机构进行签名后,TLS就可以处理证书的数据字段了。处理的过程中,TLS会关注两种类型的字段:notBefore日期与notAfter日期,表示证书的有效期。即使私钥被盗,属于该私钥的证书也不会永久生效。
1)TLS实现时,会通过OS时钟检查证书是否超出有效期。如果时钟时间有误/没有正确配置,TLS通信可能会受到影响。
2)证书的通用名应与尝试连接的hostname相符
6.2.5. 多个hostname可以共享一个证书。现代证书除了在subject字段中,提供单个值的通用名外,还在subjectAltName字段中,存储了额外的名字作为补充。可使用通配符来匹配多个hostname,而并非一个名字对应一个hostname,如*.python.org。现代TLS算法能自动进行这样的匹配。Py的ssl模块也有这样的功能。
6.2.6. Serv与Cli的TLS程序,会协商好密钥与加密方法,对实际通过该连接传输的数据进行加密。
这是最后一个TLS可能失败的地方。如果配置正确的软件,认为加密算法/密钥长度不当,会拒绝使用相应的加密算法/密钥。这种情况发生的原因有两点:
1)通信某一方希望使用的TLS协议版本太旧/不安全
2)通信某一方支持的加密算法不够强大,不够可信。
6.2.7. 一旦通信双方同意使用某种加密算法,且生成了相应的密钥为数据和签名进行加密,程序会重新负责接下来的工作。
每个发送的数据块,都通果加密密钥加密了,加密后的数据块也通过签名密钥进行了签名,这样就能确认签名确实由通信对方生成,而不是中间人(man-in-the-middle)攻击的某方生成。
此时的通信,与TCP-socket一样,在TLS关闭,且socket关闭/返回到纯文本模式前,可以无限制地在两个方向上传输数据。
6.3. 生成证书
6.3.1. Py标准库中没有提供私钥生成/证书签名的相关操作。如果需要进行这两项有关的操作,那么必须使用其他工具--openssl命令行工具,一些调用openssl的例子:https://github.com/brandon-rhodes/fopnp/tree/m/playground/certs/Makefile
在通过TLS使用其他证书时,会令Py信任ca.crt证书所定义的证书机构,其他所有证书都由ca.crt证书来签名。
要创建证书,通常要生成两部分信息:
1)人工生成:对证书中描述的实体进行了文本说明
2)机器生成:使用OS提供的真正的随机算法,精心生成一个私钥。
6-1展示了为网络实验环境中的www.example.com网络Serv生成证书的www.cnf文件
# 一个供OpenSSL命令行使用的X.509证书的配置文件
[ req ]
prompt = no
distinguished_name = req_distinguished_name
[ req_distinguished_name ]
countryName = us
stateOrProvinceName = New York
localityName = New York
O.organizationName = Example from Apress Media LLC
organizationalUnitName = Foundations of Python Network Programming 3rd Ed
commonName = www.example.com
emailAddress = [email protected]
[ ssl_client ]
basicConstraints = CA:FALSE
nsCertType = client
keyUsage = digitalSignature, keyEncipherment
extendeKeyUsage = clientAuth
由于TLS需要确定其是否在与正确的主机通信,会将证书中的commonName何任意subjectAltName这两个重要的字段与hostname比较
6.3.2. 要为证书签名,需要一个私钥,通过命令行创建一个RSA密钥,使用目前比较常见的密钥长度:
$ openssl genrsa -out www.key 4096
Generating RSA private key, 4096 bit long modulus
...............................................................................++
...........................................................................................................................................................................................................++
e is 65537 (0x10001)
准备好这两部分信息后,OS-MA就可以创建一个证书签名请求(CSR),并提交给OS-MA自己/某个第三方的证书机构了。
$ openssl req -new -key www.key -config www.cnf -out www.csr
当使用的是共有证书机构时,会通过email收到www.crt,也可能要用我们的账户从机构的网站上下载签名后的证书。为了便于Py使用我们的证书,最后一步都要把证书与私钥组合起来,保存在单个文件中。如果两个文件都是通过上述命令生成符合PEM格式,组合方式:
$ cat www.crt www.key > www.pem
生成的文件包含3部分信息:
1)证书内容的文字概述
2)证书本身
3)私钥
只要www.key/这个PEM文件www.pem其中一方泄露/被第三方获取,那么第三方在密钥过期前,能一直冒充我们的服务。
6.3.3. 除了直接为证书进行签名,以供Serv使用的CA外,还有更复杂的机制。
如,有些机构只使用有效期几天/星期的短期证书,不会每隔几天就联系/支付给CA,会请求CA签名一个临时证书,有效期较长。CA会严密保存临时证书的私钥,并使用该私钥为用户可见的证书签名,实际分配给Serv的就是这些用户可见的证书。
6.3.4. 这一做法形成了一条证书链/信任链,使我们不仅能想拥有自己的CA一样的灵活性(在任何时候都能为新的证书签名),还能具备公共CA的优点(不需在每个想要,与我们通信的浏览器/Cli上安装自定义的CA)。只要在使用TLS的Serv的同时,向Cli提供特定于该Serv的证书与临时证书,且将临时证书的密码链接,设置为Cli已知的可信CA证书,Cli就可正确地验证他们的身份。
6.4. TLS负载移除
6.4.1. 在Py程序汇总提供TLS支持时,有两种选择:
6.4.1.1. 使用一个单独的守护进程/服务,提供TLS支持
6.4.1.2. 直接在Py编写的Serv代码中,使用提供TLS功能的OpenSSl库
方案一更易于升级与修改,无法通过Py的ssl模块,对某些TLS功能进行自定义,使用第三方工具可以达到目的。
如,普通的ssl模块并不支持ECDSA椭圆曲线签名与会话重新协商的设置。会话重新协商能显著减少TLS引起的CPU开销。配置不当,会影响提供完美前向安全的能力。
6.4.2. 前端HTTPS-Serv是使用第三方守护进程,提供TLS支持的例子。HTTPS标准指明,Cli和Serv在发送任何特定于协议的消息前,都需对加密方案进行协商。使用第三方工具,对HTTP进行包装是容易的。
无论是Py的前端部署了Apache、nginx等反向代理,作为额外的防护层,还是Fastly的内容分发网络,将请求通过隧道发送至自己的Serv,都会发现,Py中没有任何与TLS相关的内容。其他相关的基础服务提供了对TLS的支持。
6.5. Py默认上下文
TLS的开源实现有很多,Py标准库对最流行的OpenSSL库进行封装。Py标准库提供TLS功能的模块,使用的名字是旧式风格的ssl。相较ssl,pyOpenSSl封装了更多OpenSSL库的API。
6.5.1. Py3.4引入了ssl.create_default_context()函数,能够在Py程序中安全使用TLS,比以前版本的Py实现起来容易得多。新版本的Py的ssl模块不会破坏向下兼容,不过,如果使用中的TLS加密算法/密钥已经被认为是不安全的,下次升级Py时,create_default_context()就会抛出一个异常。
6.5.2. 升级Py时,create_default_context()并不能确保应用程序的行为不变,会仔细选择要支持的加密算法。每次升级后,一定要重新检测程序,确保能够连接上TLS通行对方。
如何创建及使用一个默认上下文?
6-3展示了一个简单的Cli和Serv通过TLS-socket进行安全通信的方法:
# 6-3 safe_tls.py
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='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.s)
else:
client(args.host, args.port, args.a)
6.5.2.1. 为一个socket提供安全通信,只需3个步骤:
1)创建一个TLS上下文(context)对象。保存了对证书认证与加密算法选择的偏好设置
2)调用上下文对象的wrap_socket()方法。让OpenSSL库负责控制TCP连接,然后与对方交换必要的握手信息,建立加密连接。
3)使用wrap_socket()调用返回的ssl_sock对象,进行所有的后续通信。TLS层始终都能先对数据进行加密,然后再将其发送。
包装后的ssl_socket对象,提供了所有普通socket都能提供的方法,且方法名相同,如send()、recv()、close()
6.5.3. 创建上下文时,需指定创建该上下文对象的目的。
6.5.3.1. 可将ssl.create_default_context()的第一个参数设为Purpose.SERVER_AUTH,表示该上下文对象为Cli所用,用于验证其连接的Serv;
6.5.3.2. 也可将ssl.create_default_context()的第一个参数设为Purpose.CLIENT_AUTH,表示该上下文对象,用于一个需要接受Cli连接的Serv。
这一选择会影响到返回的新建上下文的多个设置,为了兼容旧版本的加密算法。Py3.4的一些设置选择:
1)create_default_context()新建对象时,将协议设置为PROTOCOL_SSLv23,Cli和Serv都可对药使用的TLS版本进行协商
2)旧版本的协议SSLv2和SSLv3都有已知的弱点,Cli和Serv都会拒绝使用,会坚持让对方使用TLSv1/更新的协议版本。(排除最常见的Cli就是WinXP的IE6)
3)可能引起攻击,TLS压缩被关闭
4)Cli和Serv设置间的第一个区别。web上的TLS会话中的Cli并不拥有自己的签名证书(浏览器),与之通信的Serv则拥有证书(PyPI/google),因此Py不需要Serv对通信对方证书进行验证(上下文的verify_mode被设置为ssl.CERT_NONE),但Cli则一定要验证远程证书,如果验证失败,就抛出异常(ssl.CERT_REQUIRED)
5)Serv与Cli间的另一个区别:对加密算法的选择。Cli的设置中支持更多的加密算法,甚至很久的RC4流加密算法。Serv的设置严格很多,坚决采用完美前向安全(PFS)的现代加密算法,即使Serv密钥泄露,之前的会话信息也不会泄露。
6.5.4. 6-3中,构造上下文对象时,需要提供cafile选项。表示脚本验证远程证书时,信任的证书机构。默认为None,此时create_default_context()会自动调用,新建上下文对象的load_default_certs()方法,在返回该对象。该方法会尝试加载所有默认CA证书。
6.5.5. OS上的浏览器,在连接远程站点时,会信任这些CA,足以用来认证公共网站及其他拥有知名公共证书机构颁布的证书的服务。
6.5.6. 如果将cafile设置为一个str,str中指定了一个文件名,就不会从OS中加载证书。相反,在验证TLS连接的远程端点时,只信任文件中包含的CA证书。(只要先创建上下文,并把cafile设置为None,然后调用load_verify_locations()安装其他证书,就可同时使用上述两种证书)
6.5.7. 6-3中,wrap_socket()有两个重要的参数选项。一个用于Serv,一个用于Cli。
6.5.7.1. Serv使用参数server_side=True。通信双方必须有一方负责Serv的功能,否则通信协商失败,抛出错误。
6.5.7.2. Cli需要的调用信息更为具体--通过connect()连接的hostname,提供hostname后,就可将它与Serv提供的证书的subject字段进行对比。把server_hostname关键字提供给wrap_socket(),检查就会自动进行。
6.5.8. 6-3中的Cli和Serv都没在循环中运行。只是在会话中做了单独的尝试。chapter06目录中有简单的localhost证书+对该证书进行签名的CA,可下载ca.crt、localhost.pem进行测试。
只要localhost别名能在OS上作为IP127.0.0.1的同义词正确运行,就可成功运行6-3。先用-s选项在终端窗口汇总运行Serv,并提供Serv-PEM文件的路径
$ /usr/bin/python3.4 safe_tls.py -s localhost.pem '' 1060
空hostname''表示希望Serv监听所有可用的接口。打开另一个窗口,通过浏览器在公共网络上运行时,使用的默认系统CA列表运行Cli
$ /usr/bin/python3.4 safe_tls.py localhost 1060
Connected to host 'localhost' and port 1060
Traceback (most recent call last):
...
ssl.SSLError: [SSL: CERIFICATE_VERIFY_FAILED] certificate verify failed (_ssl.c:598)
没有公共机构对localhost.pem中的证书进行签名,所以Cli拒绝信任Serv。同时,Serv也停止了运行。输出信息提示,Cli开始了一次连接尝试,但又中断了此次尝试。重启Serv,使用-a重新运行Cli,表示Cli信任已经由ca.crt签过名的任何证书。
$ /usr/bin/python3.4 safe_tls.py -a ca.crt localhost 1060
Connected to host 'localhost' and port 1060
b'Simple is better than complex.'
可看到Serv向Cli发送了一条简单的消息,会话成功。打开一个tcpdump的数据包嗅探器,发现无法将任何捕捉到的数据包内容解密为纯文本消息。可通过使用root运行下面的命令来监控会话(获取在PC上使用tcpdump、WireShark进行数据包捕获的方法)
# tcpdump -n port 1060 -i lo -X
前几个数据包包含清晰的信息--证书与公钥。公钥可通过明文安全地传输,捕获的数据包中展示了通过数据包传输的清晰的公钥片段。
0x00e0: 5504 0a123 2045 7861 6d70 6c65 2043 4120 U...Example.CA.
...
只要使用了加密算法,第三方就再也不可能看明白数据包的内容了。下面是PC捕获的从Serv发送至CLi的字节'Simple is better than complex'
16:49:26.545897 IP 127.0.0.1.1060 > 127.0.0.1.40220:
Flags [P.], seq 2082:2141, ack 426, win 350, options
[nop, nop, TS val 51288448 ecr 51285953], length 59
0x0000: 4500 006f 645f 4000 d827 7f00 0001 E..od_@.@..'....
...
Serv和CLi的IP及port是完全通过明文传输的,只有发送的数据本身才收到保护,无法被外界查看。
6.5.9. 套接字包装的变体
使用ssl模块来提供TLS功能的简单且通用的步骤:
1)建立一个配置好的SSLContext对象,来描述安全需求
2)使用一个普通socket建立从Cli到Serv的连接
3)调用上下文对象的wrap_socket()方法,进行实际的TLS协商
该模型健壮性好,效率高,且是使用该模块API的最灵活的方法。始终可以在Py程序中成功使用这一模式。通过该模式编写的Cli和Serv代码一致性高,方便与给出的例子做比对,可读性高
标准库的ssl模块也提供了一些简要形式的变体,以下为变体及缺点:
6.5.9.1. 没有线创建上下对象,就调用模块函数ssl.wrap_socket(),Py3.2才首次加入了上下文对象。在此之前,这种模式是创建TLS连接的唯一方法。至少有4个缺点:
1)效率较低。每次调用时,该模式都会隐式新建一个,包含所有配置选项的上下文对象。现在做法是:先新建并配置自己的上下文,就可不断复用该上下文,创建操作只进行一次。
2)无法提供真正的上下文的灵活性。提供了9个不同的可选关键字参数,仍然忽略一些方面。如,无法指定想要的加密算法。
3)为了提供对10年前旧版本Py的兼容性,过度允许使用较弱的加密算法
4)没有进行hostname检查,无法提供真正的安全性,除非在"成功"连接后,运行match_hostname(),否则无法知道对方提供的证书,是否和认为已连接的hostname相符。
6.5.9.2. 应该避免使用ssl.wrap_socket(),并从正在维护的旧代码中移除这种用法,用6-3中的模式取而代之
6.5.9.3. 另一种主要的简要形式: 在连接socket前,先包装socket。
1)如果是CLi-socket,先包装socket,然后运行connect();
2)如果是Serv-socket,先包装socket,然后运行accept();
无论是哪种情况,包装后的socket都无法立即进行TLS协商,而是继续等待,直到socket连接完毕,再开始TLS协商。 这种做法只对于HTTPS这种在连接后,直接激活TLS的协议是有效的。像SMTP需要通过一些明文来启动会话的协议,不能使用这一方法。这也是在包装socket时,有一个关键字选项do_handshake_on_connect的原因。可将其设为False,表示一直等待,直到通过socket的do_handshake()方法启动TLS协商为止。
6.5.9.4. 事先包装好socket的做法,本身不会降低代买的安全性,但有3个代码可读性的问题,不推荐:
1)没有在真正进行TLS协商的地方,调用socket包装的函数,而是在代码中的其他地方进行该调用。使得人们在阅读到connect和accept时,不知道其中使用到了TLS协议
2)connect和accept除了在发生普通socket/DNS异常时会失败外,协商过程中出现问题,也会造成失败。在一个方法调用背后,隐式进行了两种迥异的操作,在使用try...except将这两个调用包装起来的时候,要考虑两种完全独立的错误类型。
3)SSLSocket对象,实际上可能没有进行任何加密操作。只有在连接已经建立/显式调用了do_handshake()(关闭自动协商选项)时,SSLSocket才真正提供了加密功能。推荐模式中,只有在真正进行加密操作时,才创建SSLSocket,使得当前socket对象的类型,与正在进行的连接之间的语义更为清晰
6.6. 手动选择加密算法与完美前向安全
对数据安全性要求很高的话,需要指定OpenSSL确切使用的加密算法,而不使用create_default_context()函数提供的默认值
6.6.1. 完美前向安全:如果有人在未来获取/破解了以前使用的私钥,能否捕捉并读取以前的TLS会话,并保存,在未来进行解密?
现在最流行的加密算法,就能提供这一保护,使用一个临时密钥,来对每个新建的socket进行加密。对PFS保证的需要,也是想要手动指定上下文对象属性的主要原因之一。
6.6.2. 尽管ssl模块的默认上下文并不强制使用一个提供PFS保证的加密算法。但Serv和Cli运行的OpenSSL版本够新,就可以提供PFS保证的加密算法。
如,用Serv模式运行6-3的safe_tls.py,使用6-4中的test_tls.py连接Serv,即使不特别要求,也可看到Py会优先使用PFS保证的ECDHE加密算法
$ python3.4 test_tls.py -a ca.crt localhost 1060
...
Cipher chosen for this connection ... ECDHE-RSA-AES256-GCM-SHA384
...
此外,Py经常会在不需特别指定时,作出很好的选择。如想要保证使用某个特定的协议版本/算法,可对上下文对象进行自定义。
一个优秀的Serv配置(不需Cli提供TLS证书的Serv,可将验证模式设置为CERT_NONE)
context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
context.verify_mode = ssl.CERT_NONE
context.options |= ssl.OP_CIPHER_SERVER_PREFERENCE # choose *our* favorite cipher
context.options |= ssl.OP_NO_COMPRESSION # avoid CRIME exploit
context.options |= ssl.OP_SINGLE_DH_USE # for PFS
context.options |= ssl.OP_SINGLE_ECDH_USE # for PFS
context.set_ciphers('ECDH+AES128') # choose over AES256, says Schneier
可编写像6-3一样的程序,在创建Serv-socket时进行上述配置,显式指定了很少几个要使用的TLS版本和加密算法。试图进行连接的Cli只要不支持这些选择,就无法成功建立连接。把上面的代码加入6-3,替换默认的上下文,当尝试连接Serv的Cli的TLS版本稍旧/加密算法稍弱,该Cli的连接请求就会被拒绝。
$ python3.4 test_tls.py -p TLSv1_1 -a ca.crt localhost 1060
Address we want to talk to ...('localhost', 1060)
Traceback (most recent call last):
...
ssl.SSLError: [SSL: TLSV1_ALERT_PROTOCOL_VERSION] tlsv1 alert protocol version(_ssl.c:598)
$ python3.4 test_tls.py -C 'ECDH+3DES' -a ca.crt localhost 1060
Address we want to talk to ...('localhost', 1060)
Traceback (most recent call last):
...
ssl.SSLError: [SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] sslv3 alert handshake failure(_ssl.c:598)
无论哪种,Serv都会抛出一个Py异常,对失败信息进行诊断。如果连接成功,该连接就能使用最新的功能最强大的TLS版本(1.2)及最好的加密算法来保护数据了。
6.6.3. 如果同时编写Serv与Cli/至少同时维护Serv与CLi的话,自己选择加密算法就简单的多。一种可能的情况是,在自己的机房内/Serv间建立加密通信。使用三方维护软件时,不灵活的加密算法配置,会增加其他人与服务交互的难度。在三方工具使用其他TLS实现时尤为严重,确实进行了自定义配置,将加密算法及协议限制在了较小范围,要试着为编写/配置Cli的用户编写文档,他们就能对以前的Cli可能出现的连接问题进行诊断
6.7. 支持TLS的协议
大多数web协议都加入了对TLS的支持。无论是通过Py的标准库/通过第三方库来使用这些协议,都需要研究一个问题:
如何配置TLS加密算法及选项,防止对方使用较弱的协议版本/机密算法/压缩,这种可能会降低协议安全性的选项。配置过程可通过下面两种形式进行:
1)特定于库的API调用
2)直接传递一个包含了配置选项的SSLContext对象
Py标准库提供的支持TLS的协议:
1)http.client:构造一个HTTPSConnect对象时,可把构造器中的context关键字,设置为一个自己配置过的SSLContext。无论是urllib.request,还是三方库Requests,都无法提供能接受SSLContext作为参数的API
2)smtplib: 构造一个SMTP_SSL对象时,可把构造器中的context关键字设置为一个自己配置过的SSLContext。如果创建的是一个普通SMTP对象,只有在调用它的starttls()方法时,才可向该方法提供context参数。
3)poplib: 构造一个POP3_SSl对象时,可把构造器中的context关键字设置为设置过的SSLContext。如果创建的是一个普通POP3对象,那么只有在之后调用它的stls()方法时,才可向该方法调用提供context参数
4)imaplib: 构造一个IMAP4_SSl对象时,可把构造器中的context关键字设置为一个自己配置过的SSLContext。想创建的是一个普通IMAP4对象,只有在之后调用它的starttls()时,才可向该方法调用提供context参数
5)ftplib: 构造一个FTP_TLS对象时,可把构造器中的context关键字设置为一个自己配置过的SSLContext。FTP会话的第一行/前两行,始终会通过明文传输(包含Serv-hostname的"220"欢迎消息),此时无法打开加密选项。FTP_TLS对象在通过login()方法发送hostname与密码前,会自动打开加密选项。如果不准备登录到远程Serv,但是仍然希望打开加密选项,则在连接后立刻手动调用auth()方法
6)nntplib: 不涉及NNTP网络新闻(Usenet)协议,它也是安全的。在构造NNTP_SSL对象时,可把构造器中的context设置为配置过的SSLContext。如果创建的是普通IMAP4对象,只有在之后调用它的starttls()方法时,才可向该方法提供context参数。
所有这些协议都要处理一个相同的问题:
两种不同的方法可以对旧的普通文本协议进行扩展,使之支持TLS,需选择其中一种
1)在协议汇总增加一个新的命令,允许先使用协议的常用port,建立一个旧式风格的纯文本连接,在会话过程中,将原会话升级为TLS保护的会话。
2)web为提供TLS保护的协议版本分配另一个知名TCP-port。连接成功后,无需请求,就可立即开始TLS协商。HTTP协议被设计为无状态协议,所以只支持第二种方法。
6.8 了解细节
6-4给出了一个用Py3.4编写的脚本。该脚本创建了一个加密连接,打印出了该连接的特性。脚本中使用了标准库ssl模块SSLSocket对象的一些最新特性。这些新特性使得Py脚本现在能通过查看OpenSSL连接的状态,来获取配置信息
获取配置信息的方法如下:
1)getpeercert(): 返回一个Py-dict,包含从TLS会话对方的X.509证书中选出的字段。
2)cipher(): 返回OpenSSl与对方的TLS实现最终协商,确认并正在连接中使用的加密算法。
3)compression(): 返回正在使用的压缩算法名称/Py的单例对象None
为完整打印这些特性,6-4中的脚本也试着通过使用ctypes来获取正在使用的TLS协议的信息,把所有这些组合起来,连接到一个,自己构建的Cli/Serv,了解它们支持的/不支持的加密算法与协议
# 6-4 连接至任意TLS终端并打印出协商通过的加密算法
# test_tls.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
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 connect 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 'subjectAllName' in cert:
names.extend(name for (key, name) in cert['subjectAltName'] if key == 'DNS')
say('Names(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 Py'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', metavar='certfile', default=False,
help='debug mode: do not hide "ctypes" exceptions')
parser.add_argument('-v', metavar='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)
6.8.1. 要了解这个工具程序支持的命令行选项,可通过-h运行它。这个程序试着通过命令行选项给出SSLContext的所有主要特征,可针对特征做一些实验,来了解是如何影响协商过程的。从chapter06下载ca.crt、localhost.pem后
$ python safe_tls.py -s localhost.pem 127.0.0.1 1060
Listening at interface '127.0.0.1' and port 1060
Connection from host '127.0.0.1' and port 61667
该Serv能成功接受使用最新协议版本/加密算法的连接。如果有机会,该Serv会协商选择一个提供完美前向安全的较强的配置。直接选用Py的默认配置,使用6-4进行连接:
$ python test_tls.py -a ca.crt localhost 1060
Address we want to talk to.......... ('localhost', 1060)
Peer certificate.................... provided
Names(s) on peer certificate........ localhost
whether name(s) match the hostname.. Yes
Certificates loaded of type crl..... 0
Certificates loaded of type x509.... 1
Certificates loaded of type x509_ca. 1
Cipher chosen for this connection... ECDHE-RSA-AES256-GCM-SHA384
Cipher defined in TLS version....... TLSv1/SSLv3
Cipher key has this many bits....... 256
Compression algorithm in use........ none
与ECDHE-RSA-AES256-GCM-SHA384的结合使用是现在OpenSSL能提供的最佳加密方案。safe_tls.py-Serv会拒绝与只提供win-XP加密级别的Cli进行通信。过时的SSLv3、RC4协议会被拒绝。
如果以Cli模式启动6-3的“安全”脚本,结果就会不同。连接的安全程度,是由Serv决定的,Cli的作者通常只是希望在不完全暴露数据的情况下,让操作尽可能地按照他们的想法进行。
6.9. 小结
很少有人真正精通的话题--使用密码学保护TCP-socket传输的数据,通过Py使用TLS协议(SSL)
6.9.1. 在一个典型的TLS交换场景中,Cli向Serv索要证书---表示身份的电子文件。Cli和Serv共同信任的某个机构对证书进行签名。证书包含一个公钥,Serv需要证明其确实拥有对应的私钥。Cli要对证书中申明的身份进行验证,确认该身份是否与想连接的hostname匹配。Cli与Serv就加密算法、压缩、密钥这些设定进行协商,使用协商通过的方案,对socket上双向传输的数据进行保护。
6.9.2. 许多MA没有尝试在程序中支持TLS,把程序隐藏在了前端工具后,如Apache、nginx等自己可以提供TLS功能的工具。在前端使用了内容分发网络的服务,也必须把支持TLS功能的责任留给三方工具,而不是将其嵌入自己的应用中。
6.9.3. Py标准库的ssl模块已内置对OpenSSL的支持,只需一个Serv的证书,就可建立基本的加密连接
6.9.4. Py3.4以上版本编写的程序,通常会遵循以下模式:
1)创建一个“上下文”对象
2)打开连接,调用上下文对象的wrap_socket()方法,表示使用TLS协议来负责后续的连接
上下文-连接-包装 模式是最通用,也是最灵活的