网络协议有很多种,但对互联网来说,用的最多的就是HTTP协议。HTTP主要有1.0、1.1、2三个版本,在HTTP之上有HTTPS。
1996年,HTTP1.0协议规范RFC 1945发布;
1999年,HTTP1.1协议规范RFC 2616发布。
2015年,HTTP/2协议规范RFC 7540/7541发布。
HTTP/2还比较新,目前远没有达到普及的程度。在过去的近20年间,主流的协议一直是http1.1。接下来将对HTTP协议的发展脉络进行梳理。
HTTP协议的基本特点是“一来一回”。什么意思呢?客户端发起一个TCP连接,在连接上面发一个HTTP Request到服务器,服务器返回一个HTTP Response,然后连接关闭。每来一个请求,就要开一个连接,请求完了,连接关闭。
这样的协议有两个问题:
为了解决上面提及的第一个问题,HTTP1.0设计了一个Keep-Alive机制来实现TCP连接的复用。具体来说,就是客户端在HTTP请求的头部加上一个字段Connection:Keep-Alive。服务器收到带有这样字段的请求,在处理完请求之后不会关闭连接,同时在HTTP的Response里面也和加上该字段,然后等待客户端在该连接上发送下一个请求。
当然,这会给服务器带来一个问题:连接数有限。如果每个连接都不关闭的话,一段时间之后,服务器的连接数就耗光了。因此,服务器会有一个Keep-Alive timeout参数,过一段时间之后,如果该链接上上没有新的请求进来,则连接就会关闭。
连接复用之后又产生了一个新问题:以前一个连接就只发送一个请求,返回一个响应,服务器处理完毕,把连接关闭,这个时候客户端就知道连接的请求处理结束了。但现在,即使一个请求处理完了,连接也不关闭,那么客户端怎么知道连接处理结束了呢?或者说,客户端怎么知道接收回来的数据包是完整的呢?
答案是在HTTP Response的头部,返回一个Content-Length:xxx的字段,这个字段可以告诉客户端HTTP Response的Body共有多少个字节,客户端接收到这么多字节之后,就知道响应成功接收完毕。
从上面的分析可以看出,连接复用非常有必要,所以到了HTTP 1.1之后,就把连接复用变成了一个默认属性。即使不加Connection:Keep-Alive属性,服务器也会在请求处理完毕之后不关闭连接。除非在请求头部显示地加上Connection:Close属性,服务器才会在请求处理完毕之后主动关闭连接。
在HTTP 1.0里面可以利用Content-Length字段,让客户端判断一个请求的响应成功是否接收完毕。但Content-Length有个问题,如果服务器返回的数据时动态语言生成的内容,则要计算Content-Length,这点对服务器来说比较困难。即使能够计算,也需要服务器在内存中渲染出整个页面,然后计算长度,非常耗时。
为此,在HTTP1.1中引用了Chunk机制(Http Streaming)。具体来说,就是在响应的头部加上Transfer-Encoding:chunked属性,其目的是告诉客户端,响应的Body是分成一块块的,块与块之间有分隔符,所有块的结尾也有一个特殊标记。这样,即使没有Content-Length字段,也能方便客户判断出响应的末尾。
下面显示了一个简单的具体Chunk机制的HTTP响应,头部没有Content-Length字段,而是Transfer-Encoding:chunked字段。该响应包含4个chunk,数字25(16机制)表示第一个chunk的字节数,1C(16进制)表示第二个chunk的字节数…最后的数字0表示整个响应的末尾。
HTTP/1.1 200 OK
Content-Type:text/plain
Transfer-Encoding:chunked
25
This is the data in the first chunk
1C
and this is the second one
3
con
8
sequence
0
有了“连接复用”之后,减少了建立连接、关闭连接的开销。但还存在一个问题,在同一个连接上,请求是串行的,客户端发送一个请求,收到响应,然后发送下一个请求,再收到响应。这种串行的方式导致并发度不够。
为此,HTTP1.1引入了Pipeline机制。在同一个TCP连接上面,可以在一个请求发出去之后、响应没有回来之前,就可以发送下一个、再下一个,这样就提高了在同一个TCP连接上面的处理请求的效率。如下图所示,展示了在同一个TCP连接上面,串行和Pipeline的对比。
从上图可以明显看出,Pipeline提高了请求的处理效率。但Pipeline有个致命问题,就是Head-of-Line Blocking翻译成中文叫做“对头阻塞”。什么意思呢?
客户端发送的请求顺序是1,2,3,虽然服务器是并发处理的,但客户端端接收响应的顺序必须是1,2,3,如此才能把响应和请求成功匹对,跟队列一样,先进先出。一旦队列头部请求1发生延迟,客户端迟迟收不到请求1的响应,则请求2、请求3响应也会被阻塞。如果请求2、请求3不和请求3在一个TCP连接上面,而是在其他的TCP连接上面发出去的话,说不定早返回了,现在因为请求1处理的慢,也影响了请求2、请求3。
也正因为如此,为了避免Pipeline带来的副作用,很多浏览器默认把Pipeline关闭了。
TCP协议是先进先出的协议。
报文段在一个TCP连接上,如果按照seq=1,seq=2,seq=3的顺序发送到服务器,服务器端必定是按照seq=1,seq=2,seq=3的顺序接收报文段。如果过程中seq=1的数据包延迟或者丢包,服务端会一直等待seq=1的包。
因为报文有seq和ack序号,比如服务端发送一个包ack=1,那么客户端发送的包seq=1必须先到服务端,之后的seq=2的包才能被服务端接收,这就是TCP协议的报文先进先出规则,这注意是为了放置丢包等问题。
在介绍HTTPS之前,需要先深入探讨SSL/TLS,因为HTTPS是构建在这个基础上的。
SSL/TLS的历史几乎互联网历史一样长:SSL(Secure Socket Layer)的中文名词为安全套接层,TLS(Transport Layer Security)的中文名词为传输层安全协议。
1994年,网景(NetScape)公司设计了SSL1.0;
1995年,网景公司发布SSL2.0,但很快发现存在严重漏洞;
1996年,SSL3.0问世,得到大规模应用;
1999年,互联网标准化组织IETF对SSL进行标准化,发布了TLS1.0;
2006年和2008年,TLS进行了两次升级,分别为TLS1.1和TLS1.2。
所以,TLS1.0相当于SSL3.1;TLS1.1、TLS1.2相当于SSL3.2、SSL3.3。在应用层里,习惯将两者并称为SSL/TLS。
如下图,SSL/TLS处在TCP层的上面,它不仅可以支撑HTTP协议,也能支撑FTP、IMAP等其他各种应用层的协议。
接下来从最基础的对称加密讲起,一步步分析SSL/TLS背后的原理和协议本身。
对称加密的想法很简单,如下图所示。客户端和服务器知道同一个密钥,客户端给服务器发消息,客户端用此密钥加密,服务器用此密钥解密;反过来,服务器给客户端发消息时,是相反的过程。
这种加密方式在互联网上有两个问题:
如下图所示,客户端为字节准备一对公私钥(PubA,PriA),服务器为自己准备一对公私钥(PubB,PriB)。公私钥有关关键特性:公钥PubA是通过私钥PriA计算出来的,但反过来无法通过PubA计算出PriA,不能根据PubA推算出PriA!对于公私钥之间的数学关系,此处就不再展开讨论。
客户端、服务器把自己的公钥公开出去,自己保留私钥。这样一来客户端就知道了服务器的公钥,服务器也知道了客户端的公钥。
当客户端给服务器发送信息时,就用自己的私钥PriA签名,再用服务器的公钥PubB加密。所谓的“签名”相当于自己盖了一个章,或者说签了一个字,证明这个信息是客户端发送的,客户端不能抵赖;用服务器的公钥PubB加密,意味着只有服务器B可以用自己的私钥PriB解密。即使这个信息被C截获了,C没有B的私钥,也无法解密这个信息。
服务器收到信息后,先用自己的私钥PriB解密,再用客户端的公钥验签(证明信息是客户端发出的)。反向过程同理:服务器给客户端发送信息时,先用自己的私钥PriB签名,然后用PubA加密;客户端收到服务器的信息后,先用自己的私钥PriA解密,再用服务器的公钥PubB验签。
在这个过程中,存在着签名和验签与加密和解密两个过程。
在互联网上,网站对外是完全公开的,网站的提供者没有办法去验证每个客户端的合法性;只有客户端可以验证网站的合法性。比如用户访问百度或者淘宝网站,需要验证所访问的是不是真的百度或者淘宝,防止被钓鱼。
在这种情况下,客户端并不需要公钥和私钥对,只有服务器有一对公钥和私钥。如下图所示,客户端没有公钥和私钥对,只有服务器有。服务器把公钥给到客户端,客户端给服务器发送消息时,使用公钥加密,然后服务器私钥解密。反过来,服务器给客户端发送的消息,采用明文发送。
当然,对于安全性要求很高的场景,比如银行的个人网银,不仅客户端要验证服务器的合法性,服务器也要验证每个 访问的客户端的合法性。对于这种场景,往往会给客户端发一个U盘,里面装的就是客户端一方的公钥和私钥对,用的就是上面的双向非对称加密。
对于单向非对称加密,只有客户端到服务器的单向传输时加密的,服务器的返回是明文的,这怎么能保证安全呢?接着往下看。
假设PubB的传输过程是安全的,客户端知道了服务器的公钥。客户端就可以利用加密通道给服务器发送一个对称加密的密钥。如下图所示:
客户端对服务器说:“Hi,我们的对称加密密钥是xxx,接下来就用这个密钥通信。”这句话是通过Pub加密的,所以只有服务器能用自己的PriB解密。然后服务器回复一句明文:“好的,我知道了”。虽然是明文,但没有任何密钥信息在里面,所以采用明文发送也没关系。接下来,双方就可以基于对称加密的密钥进行通信了,这个密钥在内存里面,不会落地存储,所以也不存在被盗的问题,而这就是SSL/TLS的原型。
通过上面的分析可以发现,我们并不需要双向的非对称加密,而用单向的非对称加密就能达到传输的目的。
但无论是双向还是单向,都存在着公钥如何安全传输的问题。下面就以一个典型的“中间人攻击”的案例为例,来看一下这个问题是如何被解决的。
如下图所示,本来客户端和服务器要交换公钥,各自把自己的公钥发给对方。但被中间人劫持了,劫持过程如下:
客户端本来是要把自己的公钥发给服务器:“Hi,我是客户端1,我的公钥是PubA。”
被中间人C劫持之后,C用自己的公钥替换客户端的公钥,然后发给服务器:“Hi,我是客户端1,我的公钥是PubC。”
反过来,服务器本来是要把自己的公钥发送给客户端“Hi,我是服务器,我的公钥是PubB。”
被C劫持之后,C用自己的公钥替换服务器的公钥,然后发给客户端:“Hi,这是服务器,我的公钥是PubC。”
最终结果是:客户端和服务器都以为自己是在和对方通信,但其实他们都是在和中间人C通信!接下来,客户端发给服务器的信息,会用PubC加密,C当然可以解密这个信息;同样服务器发给客户端的信息也会被PubC加密,C也可以加密。
这个问题为什么会出现呢?是因为公钥的传输过程是不安全的。客户端和服务器在网络上,互相没见过对方,又没有根据,怎么知道收到的公钥就是对方发出的,而不是被中间人篡改过的呢?
需要像个办法证明服务器收到的公钥,的确就是客户端发出的,中间没有人可以篡改这个公钥,反过来也一样。这就是接下来要讲的数字证书。
如下图所示,引入一个中间机构CA。当服务器把公钥发给客户端时,不是直接发送公钥,而是发送公钥对应的证书。那么证书是怎么来的呢?
从组织上来说,CA类似显示中的“公证处”,从技术上讲,就是一个服务器。服务器先把自己的公钥PubB发给CA,CA给服务器颁发一个数字证书(Certificate),这个证书相当于服务器的身份证。之后,服务器把证书给客户端,客户端可以验证证书是否为服务器下发的。
反过来也同理,如下图所示,客户端用自己的公钥PubA通过CA换取一个证书,相当于客户端的身份证,客户端把这个证书发给服务器,服务器就能验证整个证书是否为客户端下发的。
当然,对于通常的互联网应用,只需要客户端验证服务器,不需要服务器验证客户端。
具体的验证过程是如何操作的呢?
CA有一对公钥和私钥,私钥只有CA知道,公钥在网络上,谁都可以知道。服务器把个人信息 + 服务器的公钥发给CA,CA用自己的私钥为服务器生成一个数字证书。通俗地讲,服务器把自己的公钥发给CA,让CA加盖公章,之后别人就不能再伪造公钥了。如果被中间人伪造了,客户端拿着CA的公钥验证这个证书,验证将无法通过。
但有会出现类似鸡生蛋、蛋生鸡的问题,如果让客户端、服务器都信任CA,但CA是个假的怎么办?CA的公钥如何被安全地在网络上传输?假如是一个假的CA在中间,客户端和服务器都和假的CA通信,互相还以为和真正的CA通信。
CA面临和客户端、服务器同样的问题:客户端和服务器需要证明公钥的确是由自己发出去的,不是被伪造的;CA同样需要证明,自动的公钥是由自己发出去的,不是被伪造的。
答案是给CA颁发证书!CA的证书谁来颁发呢?CA的上一级CA。最终形成下图所示的证书信用链。关于证书信任链,有两点说明:
客户端要验证服务器的合法性,需要拿着服务器的证书C3,到CA2处去验证(C3是CA2颁发的,验证方法是拿着CA2的公钥,去验证证书C3的有效性);
客户端要验证CA2的合法性,需要拿着CA2的证书C2,到CA1处去验证(C2是CA1颁发的);
客户端要验证CA1的合法性,需要拿着CA1的证书C1,到CA0处去验证(C1是CA0颁发的);
而CA0呢,只能无条件信任。怎么做到无条件信任呢?Root CA机构都是一些世界上公认的机构,在用户的操作系统、浏览器发布的时候,里面就已经嵌入了这些机构的Root证书。你信任这个操作系统,信任这个浏览器,也就信任了这些Root证书。
颁发过程与验证过程刚好是逆向的,上一级CA给下一级CA颁发证书。从根CA(CA0)开始,CA0给CA1颁发证书,CA1给CA2颁发证书,CA2给应用服务器颁发证书。
最终,证书成为网络上每个通信实体的“身份证”,在网络上传输的都是证书,而不再是原始的那个公钥。把这套体系标准化之后,就是在网络安全领域经常见到的一个词,PKI(public key infrastructure)。
想一想在现实生活中的例子:
到此为止,我们理解了对称加密、非对称加密、证书、根证书等概念后,再来看看SSL/TLS协议就很简单了,如下图所示:
在建立TCP连接之后、数据发送之前,SSL/TLS协议通过四次握手、两个来回,协商出客户端和服务器之间的对称加密密钥。第一个来回,是公钥的传输与验证过程(通过数字证书);第二个来回基于第一个来回得到的公钥,协商出对称加密的密钥。接下来,就是正常的应用层数据包的发送操作了。
当然,为了协商出对称加密的密钥,SSL/TLS协议引入了几个随机数,具体细节不在展开,这里主要讨论SSL/TLS的核心思路。
理解了SSL/TLS,再来看HTTPS就很简单了,HTTPS = HTTP + SSL/TLS。整个HTTPS的传输过程大致可以分层三个阶段,如下图所示。
说到TCP/UDP,众所周知,UDP是不可靠的,而TCP是可靠的。什么是不可靠的呢?
客户端发送了数据包1,2,3,这三个数据包经过互联网的传输,到了服务器端,接收到的是3和1。其中发生了两件事情:
网络丢包是一定会出现的,对上层应用来说,如何确保不丢呢?只有一个办法,重发!服务器每次收到一个包,就要对客户端进行确认,反馈给客户端已经收到了数据包;如果客户端在超时时间内没有收到ACK,则重发数据。
当客户端发送数据包1后,服务器确认,收到数据包1了;当客户端发送数据包2后,服务器确认,收到数据包2…这样每个数据包都要一一确认,效率太低了。怎么办呢?客户端对发送的每个数据包编一个号,编号由小到大单调递增,基于编号就能就能进行顺序的确认。
比如服务器几乎同时收到数据包1、2、3,它只用回复客户端(ACK=3),意思是所有小于或等于3的数据都已经收到了;又过了一会,服务器收到了数据包4、5、6,它只用回复客户端(ACK=6),意思是说所有小于或等于6的数据包都收到了。
因为只要超过了约定时间,客户端还没有收到服务器的确认,客户端就会重发。但可能此时服务器的ACK已经在网络上了,只是还没达到客户端而已。如果客户端重发,则服务器会收到重复消息,就需要判重。
如何判重呢?和已经收到的数据包逐一比对,核实是否有重复?这显然不显示。其实解决方法很简单,就是顺序ACK。服务器给客户端回复ACK = 6,意思是所有大于或等于6的数据包全部收到了,之后凡是再收到这个范围的数据包,则判定为重复的包,服务器收到后丢弃即可。
假设服务器收到了数据包1、2、3,回复客户端(ACK=3),之后收到了数据包5、6、7,而数据包4迟迟没有收到,这个时候怎么办呢?
服务器会把数据包5、6、7暂时存放,直到数据包4的到来,再给客户端回复ACK=7;如果数据包不来,服务器的ACK进度会一直停在那(保存ACK=3),等到客户端超时,会把数据包4、5、6、7全部重新发送,这样服务器收到了数据包4,回复ACK=7,同时数据包5、6、7重复了,通过上面的判重的办法,丢弃掉数据包5、6、7。
总之,服务器虽然接收数据包是并发的,但数据包的ACK是按照编号从小到大逐一确认的,所以数据包的时序是有保证的。
最终,通过消息顺序编号 + 客户端重复 + 服务器顺序ACK,实现了客户端到服务器的数据包不重、不漏、时序不乱;反过来,从服务器到客户端的数据包的发送与接收用相同的原理来实现,从而实现TCP的全双工。
TCP的这种思想可以说朴素而深刻,分布式系统中消息中间件的消息不重,不漏的实现机制和它有异曲同工之妙。
可以看到,在物理层面,数据包1、2、3走的是不同的网络链路,在客户端和服务器之间并不存在一条可靠的“物理管道”。只是在逻辑层面,通过一定的机制,让TCP之上的应用层就像在客户端和服务器之间架起了一个“可靠的连接”。但实际上这个连接是假的,是通过数据包的不重、不漏、时序不乱的机制,给上层应用造成的一个假象。
具体来说,每条连接用(客户端IP,客户端Port,服务器IP,服务器Port)4元组唯一确定,在代码中是一个个的Socket。其中有一个关键问题要解决,既然“连接”是假的,在物理层面不存在;但在逻辑层面连接是存在,每条连接都需要经历建立阶段、正常数据传输阶段、关闭阶段,要完整地维护在这三个阶段过程中连接的每种可能的状态。
如下图所示,每条连接都是一个“状态机”,客户端和服务器都需要针对这条连接维护不同的状态变迁,在不同的状态下执行相应的操作。下图呈现了一个TCP连接在三个阶段经历的11中状态变迁。
从上向下看,整张图可以分层三个阶段;对于建立连接来说,都是客户端发起的,所有客户端是主动方,服务器是被动方;但对于关闭连接,既可以由客户端发起关闭,也可以由服务器发起关闭,主动发起的一方就是主动方,另外一方就是被动方。不过通常来说,都是由客户端发起连接、关闭连接,服务器一直处于被动一方。
首先,客户端和服务器都处于CLOSED状态;连接建立好之后,双方都处于ESTABLISHED状态,开始传输数据;最后连接关闭,双方再次回到CLOSED状态的。为什么开始是处于CLOSED状态,而没有一个INIT(初始)状态呢?因为连接是复用的,每个连接由4元组唯一标识,关闭之后,后面又会开启,所以没有必要引入“初始”状态。
接下来,将详细探讨每个阶段的状态转移过程。
下图展示了TCP建立连接的三次握手过程,以及对应的客户端和服务器的状态。这里首先有两点要说明:
相比于建立连接的三次握手,关闭连接的四次挥手根据复杂。如下图所示:
假设客户端主动发起关闭连接,客户端的状态转移过程为:ESTABLISHED->FIN_WAIT_1->FIN_WAIT_2->TIME_WAIT->CLOSED;服务器的状态转移过程为:ESTABLISHED->CLOSE_WAIT->LAST_WAIT->CLOSED。
为什么是四次握手呢?因为TCP是全双工的,可以处于Half-Close状态。
第一次:客户端对服务器说:“Hi,服务器,我要关了。”
第二次:服务器回复:“好的,你的信息我收到了。”
第三次:服务器回复:“Hi,客户端我也要关了。”
第四次:客户端回复:“好的,你的信息我也收到了。”
如果只发生第一次和第二次,意味着该连接处于Half-close状态,此时客户端处于FIN_WAIT_2状态,服务器处于CLOSE_WAIT状态,客户端通往服务器的通道关闭了,但服务器通往客户端的通道还未关闭。等第三次、第四次发生,连接才会处于完全的CLOSED状态。
还有一种场景,客户端和服务器同时主动发起了关闭,双方都会处于FIN_WAIT_1状态,此时又收到了对方的ACK,这是双方都会切到CLOSED状态,CLOSING状态,之后一起进入TIME_WAIT状态,经过一段时间后进入CLOSED状态。
这里有一个问题:关了就关了,为何不能直接进入CLOSED状态,而要做一个TIME_WAIT状态,非要等待一段时间之后,才能进入CLOSED状态呢?有两个原因: