共计 7008 个字符,预计需要花费 18 分钟才能阅读完成。
SSL 和 TLS 都是用于保障端到端之间连接的安全性。SSL 最初是由 Netscape 开发的,后来为了使得该安全协议更加开放和自由,更名为 TLS,并被标准化到 RFC 中,现在主流的是 TLS 1.2 版本。
从上图,可以看出 SSL/TLS 是介于应用层和传输层之间,并且分为握手层(Handshake Layer)和记录层(Record Layer)。
- 握手层:端与端之间协商密码套件、连接状态。
- 记录层:对数据的封装,数据交给传输层之前,会经过分片 - 压缩 - 认证 - 加密。
TLS 中可被配置的算法分类:
- 数字签名:RSA、DSA
- 流加密:RC4
- 分组加密:DES、AES
- 认证加密:GCM
- 公钥加密:RSA
- 消息认证码:SHA
- 密钥交换:Diffie–Hellman
密码套件决定了会使用到的算法,例如执行“openssl ciphers -v ‘ALL’ | grep ECDHE-RSA-AES128-GCM-SHA256”:
ECDHE-RSA-AES128-GCM-SHA256 TLSv1.2 Kx=ECDH Au=RSA Enc=AESGCM(128) Mac=AEAD
表明该算法是在 TLS 1.2 中支持的,密钥交换采用 ECDH(EC 是指采用椭圆曲线的 DH), 数字签名采用 RSA,加密采用 128 位密钥长度的 AESGCM,消息认证码采用 AEAD(AEAD 是一种新的加密形式,把加密和消息认证码结合到一起,而不是某个算法,例如使用 AES 并采用 GCM 模式加密,就能够为数据提供保密性、完整性的保障)。
如何理解完整性?
A 将明文 M 加密后为 MC,发给 B,B 解密,得到明文。如果此时有中间人 C,将 MC 替换为 CMC(虽然 C 不知道 A 怎么加密的,但这没关系),B 将 CMC 解密,得到明文(那么 B 拿到的其实是错误的明文)。所以需要引入消息认证码,B 才能够判断收到的密文是否被篡改过。这里你可能会问:那如果 C 同时伪造消息认证码呢?这个就得看 MAC 和加密是如何配合的了,详情可以查看认证加密中的 Approaches to Authenticated Encryption 章节。
在 TLS 握手和数据传输的不同阶段会采用相应的算法:
- 服务端身份验证:数字签名(RSA、ECDSA)
- 密钥交换:RSA/ 密钥交换算法(ECDH)
- 加密 / 解密:流加密(RC4)和分组加密(3DES/AES/AESGCM)
- 生成消息认证码:SHA/AEAD
不知是否有人发现并没有提到压缩算法,如果 google 下 TLS 压缩优化相关的内容,会发现没有,因为目前在 TLS 1.2 RFC 中,关于压缩方法的结构定义为 enum {null(0), (255) } CompressionMethod;,即只有 null 方法(不进行压缩)。目前存在对 TLS 压缩的攻击,可能是基于此原因,TLS 压缩目前只是个概念性的东西,没有被真正应用起来。
通常加密算法的安全性依赖于密钥的长度,且不同加密算法,即使密钥长度相同,但提供的安全性也可能是不同的,相关资料:key size。所以并没有一个标准的归一化方法去衡量所有的加密算法,但是有来自世界上各个组织 / 机构对不同类型算法安全性的评估,可以看下这个网站:https://www.keylength.com/。
执行“openssl ciphers -v ‘ALL’ | wc -l”会发现有 100+ 个密码套件(不同 openssl 版本提供的密码套件有点差异),然而,实际只会使用到其中一部分,因为 openssl 提供的不少算法是不安全的,需要排除掉。
执行“openssl ciphers -v ‘HIGH MEDIUM !aNULL !eNULL !LOW !MD5 !EXP !DSS !PSK !SRP !CAMELLIA !IDEA !SEED !RC4’ | wc -l”,发现只剩下 50+ 个密码套件。
筛选后剩下的密码套件还是挺多的,一个个做性能测试的话,会 GG 的 = =。其实可以根据需要支持的客户端,再筛选出主流的密码套件。网址:https://www.ssllabs.com/ssltest/clients.html,提供了绝大部分客户端对 TLS 的支持情况,点击相应的 User agent 可以查看到其支持的密码套件,并且各套件的安全性也被标注出来了。
网址:https://www.ssllabs.com/ssltest/,可以用于测试服务器的 SSL 配置情况,并会给出得分,如下图 google 的得分为 A:
以下性能测试都是选取主流的算法进行。
数字签名:ECDSA vs RSA
需要先分别生成采用 ECDSA 和 RSA 的签名证书。
生成 ECDSA 自签名的证书:
openssl ecparam -name prime256v1 -genkey -out ec_key.pem
openssl req -new -x509 -key ec_key.pem -out cert.pem -days 365
-param_enc 参数使用默认的 named_curve 就可以了,如果使用 explicit,会发现生成的证书 nginx 能配置成功,但客户端连接时会出现 handshake error。
生成 RSA 签名的证书:
openssl req -newkey rsa:2048 -nodes -keyout rsa_key.pem -x509 -days 365 -out cert.pem
执行 openssl speed rsa2048 ecdsap256 测试下:
sign verify sign/s verify/s
rsa 2048 bits 0.000834s 0.000024s 1198.9 41031.9
sign verify sign/s verify/s
256 bit ecdsa (nistp256) 0.0000s 0.0001s 21302.5 9728.5
可以看到签名性能 ECDSA > RSA,而验证性能 RSA > ECDSA。
测试环境:
- 服务端:1 台虚拟机 CentOS 4 核 openresty 2 个 worker
- 客户端:4 台虚拟机 CentOS 4/2/2/ 2 核(手头只有这些虚拟机 = =),用 shell 脚本模拟并发的 ab -c 800 -n 800(并发的 ab 实例数 =2*CPU_NUM),使用 time 命令获取消耗的时间
- 测试页面 562 字节,目标是测试数字签名的性能,所以页面小点,避免加密 / 解密、数据传输占用太多时间
多台客户端如何同时启动?ctrl+tab,命令 + 回车……
为什么不用 jmeter?我用了 1Master3Slave 的 jmeter 分布式压测发现,jmeter 对于在该场景(CPU bound)下的性能测试不行,服务端压力上不去。
在相同的请求量下,RSA 签名会使服务端 CPU 占用更高,所以这次测试需要在两种签名的压测下,服务端 CPU 都保持在 90% 以上(不然的话,对 ECDSA 就不公平了)。
为何 openresty 是 2 个 worker?因为开 4 个的话,ECDSA 的压测没法使 openresty4 个 worker 的 CPU 消耗达到 90%。
ECDHE-ECDSA-AES128-GCM-SHA256,服务端 CPU 占比 90%,结果:
客户端(CPU 核数标识) | 4 | 2 | 2 | 2 |
第一次 | 11.988 | 17.334 | 9.161 | 7.748 |
第二次 | 12.524 | 13.750 | 12.129 | 7.582 |
第三次 | 11.836 | 17.991 | 9.195 | 10.023 |
第四次 | 11.617 | 7.081 | 9.168 | 8.919 |
ECDHE-RSA-AES128-GCM-SHA256,服务端 CPU 占比 100%,结果:
客户端(CPU 核数标识) | 4 | 2 | 2 | 2 |
第一次 | 12.704 | 21.088 | 18.232 | 6.134 |
第二次 | 13.355 | 21.071 | 26.990 | 6.102 |
第三次 | 14.638 | 16.009 | 11.669 | 6.071 |
第四次 | 13.913 | 21.061 | 21.271 | 5.108 |
从表格中的数据可以看出 ECDSA 的性能要比 RSA 好点,这里 ECDSA 的测试尚未压榨完服务端呢。从 openssl speed 的结果也可以看出 ECDSA 的签名性能是要远超过 RSA 的,而且签名是在服务端做的,所以面对海量的客户端,服务端应该选择使用 ECDSA。
密钥交换:RSA vs ECDHE
测试环境同上,但只使用了 4 / 2 核两台客户端机器发请求。证书使用的是生成的 RSA 证书,ECDSA 证书能用到的密钥交换算法只能是 ECDHE。
AES256-GCM-SHA384,服务端 CPU 占比 100%,结果:
客户端(CPU 核数标识) | 4 | 2 |
第一次 | 12.144 | 15.737 |
第二次 | 12.133 | 15.452 |
第三次 | 11.902 | 16.145 |
第四次 | 11.614 | 16.133 |
ECDHE-RSA-AES256-GCM-SHA384,服务端 CPU 占比 100%,结果:
客户端(CPU 核数标识) | 4 | 2 |
第一次 | 11.950 | 16.213 |
第二次 | 12.488 | 16.666 |
第三次 | 12.167 | 16.378 |
第四次 | 13.784 | 16.484 |
从表格中的数据可以看出 ECDHE 与 RSA 的性能差不多。ECDHE 比 RSA 要多了一次端到端的传输,还会用到 RSA 对 DH 参数进行签名和验证;而 RSA 密钥交换则会使用到 RSA 的加密 / 解密,具体可看如下 CloudFlare 的两张图,图片来自 Keyless SSL: The Nitty Gritty Technical Details:
ECDHE 支持前向保密(Forward Secrecy),简单理解:中间人可以保存下来客户端和服务端之间的所有通信数据,如果使用 RSA 握手,那么未来某一天,中间人如果获取到了服务端的私钥,就可以解密所有之前采集的通信数据了;如果采用 ECDHE 握手的话,就可以避免这个问题。而且使用 ECDHE 握手的话,还有可能开启 TLS false start 的特性(下文中会提到)。
RSA 握手:
ECDHE 握手:
所以密钥交换算法 ECDHE 会更好些。
对称加密:AES256-GCM vs AES256 vs AES128-GCM vs 3DES
测试环境同上,但只使用了 4 核一台客户端机器发请求,ab 参数为 ab -n 2000 -c 10,ab 实例 4 个,测试页面 153K。因为是要压测对应用层数据的加密解密性能,所以连接数少,但每个连接的请求数多。
ECDHE-RSA-AES256-GCM-SHA384,服务端 CPU 占比 94%,结果:
客户端(CPU 核数标识) | 4 |
第一次 | 17.972 |
第二次 | 18.863 |
第三次 | 18.761 |
第四次 | 19.345 |
ECDHE-RSA-AES256-SHA384,服务端 CPU 占比 98%,结果:
客户端(CPU 核数标识) | 4 |
第一次 | 20.490 |
第二次 | 19.575 |
第三次 | 19.725 |
第四次 | 20.262 |
ECDHE-RSA-AES128-GCM-SHA256,服务端 CPU 占比 92%,结果:
客户端(CPU 核数标识) | 4 |
第一次 | 17.886 |
第二次 | 18.449 |
第三次 | 17.897 |
第四次 | 18.371 |
DES-CBC3-SHA,服务端 CPU 占比 100%,结果(太慢了,就测了两个 =。=):
客户端(CPU 核数标识) | 4 |
第一次 | 52.262 |
第二次 | 51.476 |
从表格中的数据可以看出 AES128GCM > AES256GCM > AES256 > 3DES。
消息认证码:SHA256 vs SHA1 vs AEAD
测试环境同上。
AES256-SHA256,服务端 CPU 占比 100%,结果:
客户端(CPU 核数标识) | 4 |
第一次 | 18.544 |
第二次 | 18.309 |
第三次 | 18.594 |
第四次 | 18.670 |
AES256-SHA,服务端 CPU 占比 98%,结果:
客户端(CPU 核数标识) | 4 |
第一次 | 15.418 |
第二次 | 15.071 |
第三次 | 16.614 |
第四次 | 16.146 |
AES256-GCM-SHA384,服务端 CPU 占比 95%,结果:
客户端(CPU 核数标识) | 4 |
第一次 | 14.443 |
第二次 | 15.669 |
第三次 | 15.880 |
第四次 | 15.960 |
从结果中可以看出 AES256-GCM-SHA384 > AES256-SHA > AES256-SHA256。
Session Cache
客户端希望恢复先前的 session,或者复制一个存在的 session,可以在 ClientHello 中带上 Session ID,如果服务端能够在它的 Session Cache 中找到相应的 Session ID 的 session-state(存储协商好的密码套件等信息),并且愿意使用该 Session ID 重建连接,那么服务端会发送一个带有相同 Session ID 的 ServerHello。
目前 Nginx 只支持单机 Session Cache,Openresty 支持分布式 Session Cache,但处于实验阶段。
Session Ticket
Session Cache 需要服务端缓存 Session 相关的信息,对服务端存在存取压力,而且还有分布式 Session Cache 问题。对于支持 Session Ticket 的客户端,服务端可以通过某种机制将 session-state 加密后作为 ticket 发给客户端。客户端凭借该 ticket 就可以恢复先前的会话了。
类似于 HTTP 中用 Json Web TOken 作为 cookie-session 的另一种选择。
当客户端在握手环节接受到服务端的证书时,除了对证书进行签名验证,还需要知道证书是否被吊销了,那么需要向证书中指定的 OCSP url 发送 OCSP 查询请求。
对于同一份服务端证书,如果每个客户端都自己去查询一次证书状态就浪费了。所以,OCSP stapling 就是为了解决这一问题,由服务端查询到证书状态(通常会缓存一段时间),并返回给客户端(客户端会在本地校验这个证书状态是否真实)。
在 nginx 的配置中,可以选择性的配置是否对 OCSP response 做校验,防止将非法的证书状态发送给客户端。如果设置了校验,ssl_trusted_certificate 参数需要为包含所有中间证书 + 根证书的文件。
如下图是对 nginx 请求 OCSP Server 的抓包,可以看到发了个 http 的 ocsp 请求:
下图是对 nginx 在发送证书给客户端时,带上的证书状态的抓包:
nginx 默认的 ssl_buffer_size 是 16K(TLS Record Layer 最大的分片),即一个 TLS Record 的大小,如果 HTTP 的数据是 160K,那么就会被拆分为 10 个 TLS Record(每个 TLS Record 会被 TCP 层拆分为多个 TCP 包传输)发送给客户端。
如果 TLS Record Size 过大的话,拆分的 TCP 包也会较多,传输时,如果出现 TCP 丢包,整个 TLS Record 到达客户端的时间就会加长,客户端必须等待完整的 TLS Record 收到才能进行解密。
如果 TLS Record Size 小一些的话,TCP 丢包影响的 TLS Record 占比就会小很多,到达客户端的 TLS Record 就会多些,客户端干等着的时间就相对少了。但是,TLS Record Head 的负载就增加了,可能还会降低连接的吞吐量。
假设 ssl_buffer_size 设置为 1460byte:
通常,在 TCP 慢启动的过程中,TLS Record Size 小点好,因为这个时候 TCP 连接的拥塞窗口 cwnd 较小,TCP 连接吞吐量也小。而在 TCP 连接结束慢启动之后,TLS Record Size 就可以增大一些了,因为这个时候吞吐量上来了。所以更希望能够动态的调整 nginx 中 ssl_buffer_size 的大小,目前官方 nginx 还不支持,不过 cloudflare 为 nginx 打了个 patch,以支持动态的调整 TLS Record Size:Optimizing TLS over TCP to reduce latency。
某一端在发送 Change Cipher Spec、Finished 之后,可以立即发送应用数据,无需等待另一端的 Change Cipher Spec、Finished。这样,应用数据的发送实际上并未等到握手全部完成,从而节省出一个 RTT 时间。
完整握手时,Client Side False Start:
简短握手时,Server Side False Start:
Client Side False Start 需要的条件:
- 客户端和服务端都需要支持 NPN/ALPN(浏览器要求)
- 需要采用支持前向保密的密码套件,即使用 ECDHE 进行密钥交换(RFC7918 中有规定)
- TCP 优化,毕竟 SSL 数据也是基于 TCP 进行传输的
- 证书优化,采用 ECDSA 证书、服务器发送给客户端的证书链包含所有中间证书
- 硬件配置优化,例如使用 SSL 加速器
本文是个人近段时间学习到的关于 HTTPS 性能优化的总结,推荐阅读 HTTPS 权威指南和 High Performance Browser Networking 以了解更多内容。
推荐的密码套件列表:
openssl ciphers -v 'ECDHE+ECDSA ECDHE AESGCM AES HIGH MEDIUM !kDH !kECDH !aNULL !eNULL !LOW !MD5 !EXP !DSS !PSK !SRP !CAMELLIA !IDEA !SEED !RC4 !3DES'
其他额外的密码套件,比如需要支持 IE6,可以放在密码套件列表末尾。
自己写了个 go 程序用于检测密码套件列表支持 / 不支持的客户端:sslciphersuitescheck