Facebook App对TLS的魔改造:实现0-RTT

大愚若智 译 移动开发前线 2017-02-08
我们爱HTTPS,然而它建立连接耗时太长,在移动网络环境下这个问题尤为突出,Facebook为了解决这个问题,对QUIC协议和TLS进行了一些改造,实现了0-RTT协议,大幅提升了TLS连接效率,让我们来看看它是怎么做的。

每天都有数十亿人在Android和iOS设备上通过Facebook与朋友建立联系。对我们移动应用和服务器之间传输的数据提供保护,可以帮助人们更安全地使用Facebook。

我们的移动应用使用了一种名为Mobile Proxygen的自定义网络栈,这是一种使用C++14开发的跨平台HTTP客户端,使用我们自己的开源Proxygen库构建而来。借此我们可以在服务器和客户端之间共享同一套代码,更快速地提供新的安全和性能改进。

我们在移动应用中使用了传输层安全(TLS)1.2协议,并使用带OpenSSL的Folly作为TLS的具体实现。由于增加了至少一轮往返,TLS 1.2会延长建立连接所需的时间,为了降低TLS的延迟,过去多年来人们提出了多种新的协议和改进。Facebook传输安全团队曾通过多种方式设法尽可能改善TLS的速度,包括通过技术手段在距离用户最近的边缘位置终止TLS连接,重复使用HTTP2连接,使用会话重用(Session resumption)和TLS抢跑(False start),推断式连接启动,以及使用现代化的Cipher套件。从我们的移动应用到Facebook建立的大部分TLS连接仅额外增加了一轮往返(1-RTT)。

回顾一年前的数据,我们发现在建立连接的过程中,1-RTT优化后的安全握手过程依然需要较长的时间。例如在印度等新兴市场,用户往往要花费600ms(75百分位数)的时间才能建立TLS连接。我们认为有必要采取一些措施,降低这些请求的延迟,进而减少建立安全连接所需的时间。

我们打算使用零往返(0-RTT)安全协议进行一些实验。与TLS 1.2等1-RTT安全协议不同,这些协议意在确保安全性,并且不产生额外的往返延迟的前提下建立安全的连接。TCP已经深度融入到我们的基础架构中,为了避免一次性对整个基础架构进行较大的调整,我们希望逐步进行这样的实验。同样基于TCP协议的TLS 1.3目前提供了0-RTT功能,然而在我们研究各类选项的时候,TLS 1.3还处于萌芽状态,暂未提供0-RTT功能。此外还可以选择基于UDP的QUIC,这也是一种0-RTT协议,在分析过该协议的安全模式后,很多学术机构对该协议的加密模式产生了一定的关注。我们希望让基于UDP的QUIC所具备的低延迟特性能够适用于TCP,借此更快速地建立安全连接,因此我们使用QUIC加密协议构建了一个基于TCP的实验性零往返协议。

过去一年来,我们已经为移动应用和负载均衡器构建并部署了零往返协议,并获得了显著的性能改进,例如连接延迟降低了41%,处理请求的总时间降低了2%。在有关0-RTT协议的实践过程中,我们还收获了很多宝贵的工程经验,例如API设计、安全属性,以及部署,并将我们的一些成果贡献给了业已成熟的TLS 1.3。希望我们通过本文分享的经验也能适用于未来打算部署TLS 1.3的应用。

对QUIC协议进行的改动

为了使其更加安全和高效,我们在零往返协议中对QUIC加密模式进行了大量改动。此外我们还设法让该协议可以通过TCP运行。因此可以认为,我们的零往返协议是在原本QUIC加密规范基础上进行的一系列改进。本节将介绍有关加该协议密码学的相关细节,以及帮助大家理解这一加密模式所需掌握的相关知识。

概括来看,QUIC的加密协议是这样工作的:如果某个客户端以前从未与服务器通信过,会发送一则Inchoate Client Hello消息并通过1-RTT下载一个名为Server Config(SCFG)的暂存消息。该消息中包含一个Diffie-Hellman共享,下一次客户端将使用该共享派生初始密钥(或0-RTT密钥),并立刻使用该密钥加密数据。1-RTT完成后,服务器将发出一个新的暂存Diffie-Hellman共享,借此派生出一组名为前向(Forward)安全密钥的新密钥。


QUIC密钥派生过程的变化

原始的QUIC规范包含两种类型的密钥:

  • 初始密钥(或0-RTT密钥),用于发送初始数据,可从长存的服务器配置中派生而来。前向安全密钥(或1-RTT密钥),用于在服务器向客户端发送Server Hello消息后传输数据所用。

  • 客户端发送Client Hello(CHLO)后,服务器使用加密的Server Hello(SHLO)消息作为回应。其中包一组新的公钥(PUBS),这是一种可用于派生出Forward安全密钥的Diffie-Hellman共享。该消息会使用初始0-RTT密钥进行加密,服务器通过正确解密SHLO可成功完成身份验证。

然而我们发现这种密钥派生方法存在密钥被重复使用的弱点。初始密钥以及对SHLO进行的现时(Nonce)加密完全是通过Client Hello消息派生而来的,因此如果攻击者“重播”相同的CHLO,服务器会使用相同密钥对不同的SHLO消息进行现时加密。AEAD密码算法的安全特性被破坏了,进而威胁到QUIC的安全性,除非我们能通过额外的有状态方法检测相同的CHLO消息。

在零往返协议中,我们引入了另一种通过明文方式传输的现时机制,并会通过一个新的密钥加密SHLO。此外我们也已经将该弱点报告给谷歌,他们为QUIC提供的“多样化现时(Diversification nonce)”解决了这个问题。

带内服务器配置轮换

我们对服务器配置(Server Config)的有效时间进行了限制,原因在于,如果该配置在有效时段内被盗,将能被一直用于冒充服务器。在QUIC协议中,让包含已缓存老旧SCFG的客户端使用新SCFG的唯一方法是继续使用原来的SCFG,被拒绝,然后获得新的SCFG。这种方法的不足之处在于,如果客户端发送了0-RTT数据,在轮换配置时必须丢弃这些数据并进行重播。

我们对协议进行了改进,使得我们可以在带内(In-band)直接发送新的SCFG。服务器随时维护着包含三个配置的清单:上一个配置、当前配置,以及下一个配置。如果检测到客户端在使用老的SCFG,我们会让客户端完成连接,随后通过加密的SHLO为客户端提供新的SCFG,进而客户端可以将自己的SCFG更新为新版本。这种方式可以避免客户端因为使用老旧配置而被拒绝的退化情况。

TLS 1.2还提供了一种通过刷新会话票证(Session Ticket)实现相同目的的做法,然而这种方法无法保障前向安全,因为新老会话票证会共享同一个主密钥。在QUIC协议中,新密钥需要另一个Diffie-Hellman操作,刷新后可以保证前向安全。

被拒后重试行为的安全性

就算通过带内的方式刷新服务器配置,依然会遇到客户端继续使用老配置的情况。此时无法避免要拒绝客户端并回退至1-RTT,但连接依然无法防范重播。客户端可以发送0-RTT数据,但不能立刻发送常规数据。

我们对零往返协议进行了性能优化,可以在客户端的服务器配置被拒绝的时间段内向客户端发送额外的服务器现时,这样即可使用该现时,立即开始发送常规的1-RTT非重播安全数据。

0-RTT数据的时效

相比通过1-RTT或TLS 1.2等协议发送的常规数据,0-RTT数据有着不同的安全特性。与常规的1-RTT数据不同,攻击者可以无穷尽地重播0-RTT数据,如果应用无法妥善地保护自己,这会造成一种非常有意思的攻击。例如,攻击者可以将一个HTTP POST请求重播两次,并在缺乏遏制机制的情况下让该请求被执行两次。攻击者还可以将发往银行的同一个GET请求重播任意次数,通过查看响应的长度判断银行账户余额的变化情况。0-RTT数据必须以截然不同的方式妥善应对。1-RTT完成后,客户端将可以发送任何数据,因为连接又可以防范重播了。

我们使用的一种缓解措施是减小0-RTT的有效时长。客户端可以向我们发送启动连接的时间,我们会将该时间与服务器时间进行对比,以确定该0-RTT数据是在多久之前创建的。如果0-RTT数据在有效期过期之后重播,服务器将拒绝这样的数据,借此禁止攻击者无穷尽地重播这些数据。然而随着降低有效期,我们发现很多客户端的时钟存在较大偏差,进而产生了很多误报。

为了解决这个问题,当客户端成功连接后,我们会下发一个时钟偏差校正值。客户端下一次连接时,需要这样计算自己的客户端时间:

client_time = client_real_time + clock_skew_correction

我们还发现客户端的时钟偏差存在一定的方差,但由于该方差并不是那么大,因此可以强制实施严格的0-RTT数据有效期。

对TCP的修改

为了兼容TCP,我们在零往返协议中取消了QUIC数据包的显式序列编号,并增加了显式长度字段。QUIC是基于UDP的,因此不需要长度字段,而由于UDP数据包可以重新排序,因此必须具备显式序列编号。

零往返协议的部署
端口的选择

我们决定将零往返协议运行在与TLS相同的443端口上。在服务器端,我们在已接受套接字(Accepted socket)上使用MSG_PEEK预览连接的前几个字节内容,并确定是要使用TLS或是零往返协议。我们还需要通过更细化的方法让客户端决定是否使用零往返协议,因此决定不使用Alt-svc。

Zero RTT API

我们还面临一个重大的问题:如何将0-RTT 集成于Mobile Proxygen。网络栈是一种复杂的猛兽,而我们非常有必要确保0-RTT以后必须能轻松地测试和维护。

我们考虑过两种可行的API:

  • 更改原有的套接字API,让connect()也能接受数据,例如connectWithData (ip, port, data)。

  • 让客户端继续使用相同的connect()和write()套接字API。为了启用0-RTT,客户端可调用新的enableZeroRTT()API。随后我们可以立即将调用返回至connect(),这样客户端就可以使用write()写入0-RTT数据。

在考虑如何将0-RTT集成到客户端时,我们发现以我们这种复杂度的网络栈来说,很难用可行的方式集成第一种API。该API实际上破坏了建立和使用连接的不同组件之间的分隔。诸如HTTP等组件本身是通过连接发送数据的,但也需要负责处理部分连接逻辑,这会使这些组件变得更复杂。这种方式还会妨碍数据的流动,例如,如果整个网络的RTT有较大差异,就很难判断需要等待缓冲多少数据再调用connectWithData。

因此我们选择构建第二种API。网络栈的其他部分可以像以前一样使用相同的API。这种方法的一个不足在于,整个方法的复杂度被转移到零往返协议本身的实现中,因为需要处理0-RTT的状态。但该方法的优势在于,可以让我们在获得Server Hello 消息之前持续流传输0-RTT数据。RTT的方差非常大,因此我们可以根据经验估算在等待服务器发送Server Hello的同时,我们可以发送多少数据,这就产生了1-RTT。此外我们发现该数据本身的方差也很大。如果不使用流式API,Mobile Proxygen就必须精确判断在绑定一个0-RTT connectWithData()之前,必须等待应用生成多少数据,这一点实现起来也很复杂。由于数据方差大,我们不需要流式API事先判断要等待的数据量,因此部署起来更简单,也更高效。

选择对0-RTT来说不会造成危险的请求

在决定构建流式API后,我们需要构建一种机制,以确保只通过0-RTT发送安全的请求。通过0-RTT发送非幂等请求是一种不安全的做法,因为攻击者可以重播这种请求,但就算幂等的请求,这样做也可能不够安全。举例来说,如果有个GET事务可以返回银行账户余额,攻击者可以将这样的0-RTT请求重播多次,通过查看回应的长度判断余额的变化情况。

只有应用本身的代码可以真正确定通过0-RTT发送数据是否是安全的做法。

因此我们为Mobile Proxygen增加了一个API:

setRequestIdempotency(RETRY_SAFE).

该API可以告诉网络栈数据不仅可以通过0-RTT安全地发送,而且可以执行其他操作,例如重试请求。我们与HTTP工作组就这个API进行了讨论,一致认同“重试安全”是一个必要的特性。我们只通过0-RTT发送符合重试安全要求的请求,并且只在应用的代码明确指定这是一种安全做法的情况下执行这种操作。而各种浏览器的计划是通过0-RTT发送所有数据。

决定发送“重试安全”请求的时机

我们的产品可以一次发送多种不同类型的请求。一旦确定了哪些请求是重试安全的,我们还需要知道什么时候可以安全地发送非重试安全的请求。在发送重试安全的请求,而非发送非重试安全请求的过程中,零往返协议的套接字必须处于一种特性状态下,因为此时还没有得到来自服务器的回应。

我们希望确保整个抽象尽可能简单,并且避免在内存中缓冲太多的数据。

在Mobile Proxygen中我们构建了一个可根据多种条件对请求进行调度的请求调度器。例如高优先级请求会比低优先级请求更快速进行调度。我们还为重试安全请求提供了一个自定义的请求调度器,如果某个请求是非重试安全的,并且传输工作尚未开始执行1-RTT,重试安全调度器会阻止这种请求调度自己的头部或正文,将其保留在队列中。

当传输符合重试安全要求时,重试安全调度器会得到一个回调,此时可以安全地调度非重试安全请求,并释放请求队列。

数据交付的可靠性

在对TLS和零往返协议进行性能分析对比时,我们发现TLS连接的错误率比零往返协议略低一些。通过我们自己的网络栈数据,我们发现大部分请求错误发生在建立连接的过程中。由于使用了流式API,当我们知道可以发送0-RTT加密数据后,我们会立刻将零往返协议连接返回给Mobile Proxygen。在网络栈获得能够发送数据的连接时,零往返连接只进行了一次TCP往返,而TLS此时已经进行了两次往返(包括TCP)。

提到这个事情是因为,在建立连接时,网络栈会试图打开多个连接。TLS连接成功概率高于零往返连接是因为TLS连接会等待更多的往返,实际上这等同于进行了额外的连接重试。

为了让零往返协议与TLS实现相似的结果,我们在Mobile Proxygen中增加了重试行为,借此在我们知道请求在获得服务器回应前就已失败的情况下加快重试速度。该方法提高了零往返连接的可靠性,同时也能让TLS 1.3客户端从中获益。

为了适应不同的中间设备(Middlebox),我们还构建了从零往返协议到TLS 1.2的回退,但实际上这些设备的使用并不广泛,主要出现在少数几个ASN中。

重播缓存

缩短0-RTT数据有效期的时间窗口可以大幅降低攻击者无止境重播0-RTT请求可能造成的风险。然而在这个时间窗口内,依然有可能多次重播请求,因此攻击者依然有可能用统计学的方式分析回应的时间,进而对请求获得进一步了解。为了防范这种问题,我们实验了重播缓存,该技术可对每个时间窗口内发送的0-RTT Client Hello进行缓存,进而拒绝重复的消息。重播缓存并不能彻底禁止重播,毕竟我们的目标是让客户端自动将被拒绝的0-RTT请求以1-RTT数据的方式重新发送,但该技术可以将重播的次数限制为客户端的重试次数。通过使用Bloom筛选器,我们的重播缓存可以用最少量资源处理大量握手,而代价仅仅是很少量的误报率。我们尚未在零往返协议中全面启用重播缓存(对于零往返协议,我们可以细化地控制哪些请求可作为0-RTT数据发送,因为我们可以控制客户端选择重试安全请求的代码),不过我们认为可以在部署TLS 1.3 时开始部署重播缓存。

收益
性能

相比TLS 1.2,零往返协议有了显著的性能改进。我们发现建立连接所需的时间降低了41%(75百分位数),请求处理总时间整体减少了2%。各种请求有着自己的差异,而零往返协议对应用启动时因为无法重复使用连接而发出的请求能带来最大价值。这样改进也让我们应用的冷启动速度有了飞速提升。

针对TLS 1.3的贡献

零往返协议目前还是实验性的,但如我们预期,在性能改进放面取得了非常大的成功。从我们的Android和iOS应用中产生的大部分流量已经在使用零往返协议。同时我们还将这一过程中获得的经验贡献给了TLS 1.3和QUIC。例如,TLS 1.3中的票证寿命功能就得到了零往返协议的启发。我们还在TRON 2上介绍了自己的API设计,并就流传输功能进行了讨论,借此服务器无需等待上一个数据传输操作完成,即可开始发出响应。为了明确0-RTT对浏览器的影响,我们还针对重试安全进行了多次讨论。希望真个社区在未来可以通过TLS 1.3获得类似的性能收益。

未来计划

我们的传输安全团队正在构建自己的TLS 1.3实现,并会在可行时纳入零往返协议。我们认为在大量社区成员的贡献下,TLS 1.3的协议设计非常出色。TLS 1.3不仅改善了性能,同时提供了一种更简单并且更安全的设计。我们非常期待着在不远的未来能够实现并部署该协议。

相比TLS 1.3,我们更愿意让零往返协议成为一种实验和探索。我们为零往返协议进行的大部分工程抽象和设计都会立刻应用到TLS 1.3中。

任何在意安全性和性能的应用都应该考虑使用TLS 1.3,并考虑本文中提到的有关0-RTT数据的问题。零往返协议帮助我们更好地理解了0-RTT数据的影响,借此我们也对TLS 1.3的开发做出了自己的贡献。


觉得不错,分享给更多人看到
移动开发前线 热门文章:

高二Android大牛是这样炼成的    阅读/点赞 : 9282/34

2016移动开发技术巡礼    阅读/点赞 : 9025/77

Google开发者大会给我们带来了什么    阅读/点赞 : 5571/36

糯米移动组件架构演进之路    阅读/点赞 : 5179/42

移动开发前线招聘季活动    阅读/点赞 : 3854/35

网易客户端测试团队转型实践    阅读/点赞 : 3079/35

移动开发前线 微信二维码

移动开发前线 微信二维码

数据

阅读 2460
点赞 20
更新 2月10日 10:33