返回 登录
7

HTTPS 与 HTTP2 协议分析

阅读13502

本文为作者投稿,版权归作者所有,未经允许,请勿转载。
作者:赵岘,目前就职于58同城,Android高级工程师。主要负责性能优化与质量提高方向,主导了App端HTTPS改造项目。
CSDN 有奖征稿啦】技术之路,共同进步,有优质移动开发、VR/AR/MR、物联网原创文章欢迎发送邮件至 mobilehub@csdn.net

HTTPS协议原理分析

HTTPS协议需要解决的问题

HTTPS作为安全协议而诞生,那么就不得不面对以下两大安全问题:

  • 身份验证

    确保通信双方身份的真实性。直白一些,A希望与B通信,A如何确认B的身份不是由C伪造的。

    (由C伪造B的身份与A通信,称为中间人攻击)

  • 通信加密

    通信的机密性、完整性依赖于算法与密钥,通信双方是如何选择算法与密钥的。

能同时解决以上两个问题,就能确保真实有效的通信双方采取有效的算法与密钥进行通信,便完成了协议安全的初衷。

在介绍HTTPS协议如何解决两大安全问题前,我们首先了解几个概念。

  • 数字证书

    数字证书是互联网通信中标识双方身份信息的数字文件,由CA签发。

  • CA

    CA(certification authority)是数字证书的签发机构。作为权威机构,其审核申请者身份后签发数字证书,这样我们只需要校验数字证书即可确定对方的真实身份。

  • HTTPS协议、SSL协议、TLS协议、握手协议的关系

    HTTPS是Hypertext Transfer Protocol over Secure Socket Layer的缩写,即HTTP over SSL,可理解为基于SSL的HTTP协议。HTTPS协议安全是由SSL协议(目前常用的,本文基于TLS 1.2进行分析)实现的。

    SSL协议是一种记录协议,扩展性良好,可以很方便的添加子协议,而握手协议便是SSL协议的一个子协议。

    TLS协议是SSL协议的后续版本,本文中涉及的SSL协议默认是TLS协议1.2版本。

HTTPS协议的安全性由SSL协议实现,当前使用的TLS协议1.2版本包含了四个核心子协议:握手协议、密钥配置切换协议、应用数据协议及报警协议。

解决身份验证与通信加密的核心,便是握手协议,接下来着重介绍握手协议。

握手协议

握手协议的作用便是通信双方进行身份确认、协商安全连接各参数(加密算法、密钥等),确保双方身份真实并且协商的算法与密钥能够保证通信安全。

对握手协议的介绍限于客户端对服务端的身份验证,单向身份验证也是目前互联网公司最常见的认证方式。

首先我们看一下协议交互,如图1所示:


图1 握手协议

接下来以Wireshark抓取接口的握手协议过程为例,针对每条协议消息分析。

ClientHello消息

ClientHello消息的作用是,将客户端可用于建立加密通道的参数集合,一次性发送给服务端。

消息内容包括:期望协议版本(TLS 1.2)、可供采用的密码套件(Cipher Suites)、客户端随机数(Random)及扩展字段内容(Extension)等信息,如图2所示。


图2 ClientHello

ServerHello消息

ServerHello消息的作用是,在ClientHello参数集合中选择适合的参数,并将服务端用于建立加密通道的参数发送给客户端。

消息内容包括:采取的协议版本(TLS 1.2)、采用的密码套件(Cipher Suite)、服务端随机数(Random)、用于恢复会话的会话ID(Session ID)及扩展字段等信息,如图3所示。

自此客户端与服务端的协议版本、密码套件已经协商完毕。

这里服务端下发的会话ID可用于后续恢复会话。若客户端在ClientHello中携带了会话ID,并且服务端认可,则双方直接通过原主密钥生成一套新的密钥即可继续通信。将两个网络往返降低为一个网络往返,提高通道建立的效率。


图3 ServerHello

Certificate消息

Certificate消息的作用是,将服务端证书的详细信息发送给客户端,供客户端进行服务端身份校验。

消息内容:服务端下发的证书链,如图4所示。

服务端为了保证下发的证书能够被客户端正确识别,就需要将签发此证书的CA证书一同下发,构成证书链,保证客户端可以根据证书链的信息在系统配置中找到根证书,并通过根证书的公钥逐层向下验证证书的合法性。
如图所示,五八服务器下发了两个证书:自己的证书与签发CA的证书。通过签发CA的证书信息,能够直接找到根证书。


图4 Certificate

客户端本地校验服务端证书,若校验通过,则客户端对服务端的身份验证便完成了。

Certificate这个阶段解决了两端的身份验证问题。借助CA的力量,通过CA签发证书,将身份验证的工作交给了CA处理。
只要是我们认可的CA,签发的证书我们均认可证书持有者的身份。由于CA的介入,解决了中间人攻击的问题,因为中间人并没有服务端的证书可供客户端验证。

ServerKeyExchange消息(可能不发送)

ServerKeyExchange消息的作用是,将需要服务端提供的密钥交换的额外参数,传给客户端。有的算法不需要额外参数,则ServerKeyExchange消息可不发送。

消息内容:用于密钥交换的额外参数,如图5所示。


图5 ServerKeyExchange

如图5,服务端下发了“EC Diffile-Hellman”密钥交换算法所需要的参数。

ServerHelloDone消息

ServerHelloDone消息的作用是,通知客户端ServerHello阶段的数据均已发送完毕,等待客户端下一步消息。

ClientKeyExchange消息

ClientKeyExchange消息的作用是,将客户端需要为密钥交换提供的数据发送给服务端。

当我们选用RSA密钥交换算法时,此消息的内容便是通过证书公钥加密的用于生成主密钥的预主密钥。

如图6所示,由于选用的密钥交换算法是“EC Diffie-Hellman”,所以ClientKeyExchange消息发送的是”EC Diffie-Hellman”算法需要的客户端参数。


图6 ClientKeyExchange

当发送了ClientKeyExchange后,两端均具有了生成主密钥的完整密钥数据与随机数,两端分别根据所选算法计算主密钥即可。

至此,ClientKeyExchange发送后,两端均可生成主密钥,密钥交换问题便解决了。

有的读者可能对随机数的采用有些疑惑,笔者觉得随机数的加入是为了提高密钥的随机性。
由于客户端直接生成的密钥很有可能不够随机,而通过预主密钥加上两端提供的两个随机数做种子,创建的主密钥可以保证更加贴近真实随机的密钥。

ChangeCipherSpec消息

经过以上六条消息,我们已经解决了身份认证问题、密码套件选取问题、密钥交换问题。双方也已经通过主密钥生成了实际使用的六个加解密密钥。

ChangeCipherSpec消息的作用,便是声明后续消息均采用密钥加密。在此消息后,我们在WireShark上便看不到明文信息了。

Finished消息

Finished消息的作用,是对握手阶段所有消息计算摘要,并发送给对方校验,避免通信过程中被中间人所篡改。

HTTPS协议总结

自此,HTTPS如何保证通信安全,通过握手协议的介绍,我们已经有所了解。

但是,在全面使用HTTPS前,我们还需要考虑一个众所周知的问题——HTTPS性能。

相对HTTP协议来说,HTTPS协议建立数据通道的更加耗时,若直接部署到App中,势必降低数据传递的效率,间接影响用户体验。

接下来,介绍HTTPS性能救星——HTTP2协议。

协议新宠-HTTP2

协议介绍

随着互联网的快速发展,HTTP1.x协议得到了迅猛发展,但当App一个页面包含了数十个请求时,HTTP1.x协议的局限性便暴露了出来:

  • 每个请求与响应需要单独建立链路进行请求(Connection字段能够解决部分问题),浪费资源。
  • 每个请求与响应都需要添加完整的头信息,应用数据传输效率较低。
  • 默认没有进行加密,数据在传输过程中容易被监听与篡改。

HTTP2正是为了解决HTTP1.x暴露出来的问题而诞生的。

说到HTTP2不得不提spdy。
由于HTTP1.x暴露出来的问题,Google设计了全新的名为spdy的新协议。spdy在五层协议栈的TCP层与HTTP层引入了一个新的逻辑层以提高效率。spdy是一个中间层,对TCP层与HTTP层有很好的兼容,不需要修改HTTP层即可改善应用数据传输速度。
spdy通过多路复用技术,使客户端与服务器只需要保持一条链接即可并发多次数据交互,提高了通信效率。
而HTTP2便士基于spdy的思路开发的。
通过流与帧概念的引入,继承了spdy的多路复用,并增加了一些实用特性。

HTTP2有什么特性呢?HTTP2的特性不仅解决了上述已暴露的问题,还有一些功能使HTTP协议更加好用。

  • 多路复用
  • 压缩头信息
  • 对请求划分优先级
  • 支持服务端Push消息到客户端

此外,HTTP2目前在实际使用中,只用于HTTPS协议场景下,通过握手阶段ClientHello与ServerHello的extension字段协商而来,所以目前HTTP2的使用场景,都是默认安全加密的。

下面介绍HTTP2协议协商以及多路复用与压缩头信息两大特性,实现部分采用okhttp源码(基于parent-3.4.2)进行分析与介绍。

okhttp是目前使用最广泛的支持HTTP2的Android端开源网络库,以okhttp为例介绍HTTP2特性也可方便读者提前了解okhttp,方便后续接入okhttp。

协议协商

HTTP2协议的协商是在握手阶段进行的。

协商的方式是通过握手协议extension扩展字段进行扩展,新增Application Layer Protocol Negotiation字段进行协商。

在握手协议的ClientHello阶段,客户端将所支持的协议列表填入Application Layer Protocol Negotiation字段,供服务端进行挑选。如图7所示:


图7 ALPN1

服务端收到ClientHello消息后,在客户端所支持的协议列表中选择适当协议作为后续应用层协议。如图8所示:


图8 ALPN2

这样,两端便完成了HTTP2协议的协商。

在HTTP2未出现时,spdy也是通过扩展字段,扩展出next_protocol_negotiation字段,以NPN协议进行spdy的协商。不过由于NPN协议协商过于复杂,对https协议侵入性较强,在出现ALPN协商协议后,便逐渐被淘汰了。所以,本文协议协商并为对NPN协议协商做介绍。

协议特性之多路复用

http2为了优化http1.x对TCP性能的浪费,提出了多路复用的概念。

多路复用的含义

在HTTP2中,同一域名下的请求,可通过同一条TCP链路进行传输,使多个请求不必单独建立链路,节省建立链路的开销。

为了达到这个目的,HTTP2提出了流与帧的概念,流代表请求与响应,而请求与响应具体的数据则包装为帧,对链路中传输的数据通过流ID与帧类型进行区分处理。图9便是多路复用的抽象图,每个块代表一帧,而相同颜色的块则代表是同一个流。


图9 http2_stream

那么HTTP2的多路复用是如何实现的呢?

由于网络请求的场景很多,我们选择其中一个路径来介绍:

  1. 客户端与服务端在某个域名的TCP通道已建立
  2. 新创建的客户端请求通过已连接的TCP通道进行请求发送与响应处理
多路复用实现

默认我们已经添加各参数创建了Request对象r,并通过Request对象创建了Call对象c。并在独立线程中,调用c.execute()方法,进行同步请求操作。

okhttp调用execute方法后,实际上是由一系列的interceptor来负责执行的。

interceptor根据添加顺序依此执行,其中我们关注的是RetryAndFollowUpInterceptor、ConnectInterceptor0、CallServerInterceptor。

1.在RetryAndFollowUpInterceptor中,okhttp为我们创建了一个StreamAllocation对象,StreamAllocation中含有基于url创建的Address对象。

Address类的url字段与Request类的url字段不同,Address类的url字段不包括path与query字段,只含有scheme与authority部分,这点在进行Connection复用的equal操作时起了很大作用。

2.在ConnectInterceptor中,StreamAllocation对象的Address与连接池中每个Connection对象的Address依次进行匹配,匹配成功并满足一些条件的Connection便可复用。基于匹配出的Connection创建Http2xStream,用于后续读写操作。

与连接池中Address匹配主要通过Address的url,url由于只含有scheme与authority所以可用于域名的匹配,这便是okhttp基于域名层面多路复用的基础。
实际上真正进行流读写操作的是FramedConnection与FramedStream,Connection与Http2xStream是抽象于具体操作的类,以方便上层使用。

3.在CallServerInterceptor中,Http2xStream创建FramedStream用于Request发送,并将FramedStream与对应的StreamID绑定缓存下来,以便Response到来时,能够根据StreamID索引到对应的FramedSteam进行后续操作。

在FramedStream发送完Request后,执行readResponseHeaders方法时进行调用了wait,将当前线程挂起。
并在FramedConnection读线程收到StreamID消息时,在缓存中查询FramedStream并将对应线程唤醒进行Response解码。

归纳下okhttp的多路复用实现思路:

  1. 通过请求的Address与连接池中现有连接Address依次匹配,选出可用的Connection。
  2. 通过Http2xStream创建的FramedStream在发送了请求后,将FramedStream对象与StreamID的映射关系缓存到FramedConnection中。
  3. 收到消息后,FramedConnection解析帧信息,在Map中通过解析的StreamID选出缓存的FramedStream,并唤醒FramedStream进行Response的处理。

在笔者看来,HTTP2便是一个良好兼容http协议格式的自定义协议,通过Stream将数据分发到各请求,通过Frame将请求数据详细细分。

协议特性之压缩头信息

HTTP2为了解决HTTP1.x中头信息过大导致效率低下的问题,提出的解决方案便是压缩头部信息。具体的压缩方式,则引入了HPACK。

HPACK压缩算法是专门为HTTP2头部压缩服务的。为了达到压缩头部信息的目的,HPACK将头部字段缓存为索引,通过索引ID代表头部字段。客户端与服务端维护索引表,通信过程中尽可能采用索引进行通信,收到索引后查询索引表,才能解析出真正的头部信息。

HPACK索引表划分为动态索引表与静态索引表,动态索引表是HTTP2协议通信过程中两端动态维护的索引表,而静态索引表是硬编码进协议中的索引表。

作为分析HPACK压缩头信息的基础,需要先介绍HPACK对索引以及头部字符串的表示方式。

索引

索引以整型数字表示,由于HPACK需要考虑压缩与编解码问题,所以整型数字结构定义如图10所示:


图10 int_strut

  • 类别标识

    通过类别标识进行HPACK类别分类,指导后续编解码操作,常见的有1,01,01000000等八个类别。

  • 首字节低位整型

    首字节排除类别标识的剩余位,用于表示低位整型。若数值大于剩余位所能表示的容量,则需要后续字节表示高位整型。

  • 结束标识

    表示此字节是否为整型解析终止字节。

  • 高位整型

    字节余下7bit,用于填充整型高位。

“结束标识+高位整型”字节可能有0个、也有可能有多个,依据数据大小而定。
譬如,若想表示类别为1,索引为2,则使用10000010即可,不需要额外字节增加高位整型。

头部字符串

头部字符串需要显式声明长度,所以数据首字节由“类型标识+数据长度”组成。如图11所示:


图11 string_strut

  • 类型标识

    是否选用哈夫曼编码,1为选用,0为不选用,okhttp默认不选用哈夫曼编码。

  • 数据长度

    标识数据长度,采用上面提到的整型表示法表示。

  • 数据内容

    二进制数据。

解码实例

下面综合okhttp源码分析HPACK解码头部字段过程。

对编码部分感兴趣的读者,可以查阅RFC 7541或直接分析OkHttp源码。

当我们需要解码头部字段时,首先解析头部字段首字节(HPACK头部字段首字节分为8个类别,摘选其中3个类别说明),首字节用于指导当前头部字段的解析规则:

  • 1xxxxxxx

    类别标识为1,代表收到一条K、V均为索引的头部字段。

    K、V值:通过解析HPACK整型获取KV对的索引值,并根据索引值映射对应的头部原字段即可,压缩效率最高。

  • 01xxxxxx

    类别标识为01,代表收到一条K为索引、V为原字段,且需要加入动态索引表的头部字段。

    K值:通过解析HPACK整型获取K值索引值,并通过索引值映射对应的头部原字段。

    V值:通过解析HPACK字符串获取V值原字段。

    获取K、V值后还需插入动态索引表中。

  • 01000000

    01000000代表收到一条K、V均为原字段,且需要加入动态索引表的头部字段。

    K、V值:通过解析HPACK字符串获取K、V原字段,并插入动态索引表中。

还有不加入动态索引表、调整索引表大小等类别,这里就不展开了,感兴趣的可以看okhttp源码实现。

okhttp解析头信息的核心方法实现如下:

void readHeaders() throws IOException {
  while (!source.exhausted()) {
    int b = source.readByte() & 0xff;
    if (b == 0x80) { // 10000000
      //类别标识为1,但索引为0
      throw new IOException("index == 0");
    } else if ((b & 0x80) == 0x80) { // 1NNNNNNN
      //类别为1,通过readIndexedHeader解析整型index。
      int index = readInt(b, PREFIX_7_BITS);
      //通过index获取完整头部字段
      readIndexedHeader(index - 1);
    } else if (b == 0x40) { // 01000000
      //01000000代表KV均为原字段,解析字符串依次获取K值、V值,并插入动态表中
      readLiteralHeaderWithIncrementalIndexingNewName();
    } else if ((b & 0x40) == 0x40) {  // 01NNNNNN
      //01xxxxxx代表K值为索引,V值为原字符串,依次解析整型index与字符串,并插入动态表中
      int index = readInt(b, PREFIX_6_BITS);
      readLiteralHeaderWithIncrementalIndexingIndexedName(index - 1);
    } else if ((b & 0x20) == 0x20) {  // 001NNNNN
      //类别为001,含义是更新动态列表容量
      maxDynamicTableByteCount = readInt(b, PREFIX_5_BITS);
      if (maxDynamicTableByteCount < 0
          || maxDynamicTableByteCount > headerTableSizeSetting) {
        throw new IOException("Invalid dynamic table size update " + maxDynamicTableByteCount);
      }
      adjustDynamicTableByteCount();
    } else if (b == 0x10 || b == 0) { // 000?0000 - Ignore never indexed bit.
      //这个类别代表KV均为原字符串,依次解析字符串,并不对解析后的KV值插入动态表。
      readLiteralHeaderWithoutIndexingNewName();
    } else { // 000?NNNN - Ignore never indexed bit.
      //与上一类别类似,但K值为索引,V值为原字符串
      int index = readInt(b, PREFIX_4_BITS);
      readLiteralHeaderWithoutIndexingIndexedName(index - 1);
    }
  }
}

压缩效果

K值为“accept-encoding”、V值为“gzip, deflate”的头部字段在HTTP2中可通过索引值15代替,从而达到头部字段压缩的效果。

“accept-charset”头部字段则通过14代表头部K值,而Value值根据HPACK规则编码写入流中。

通过HPACK,一个头部字段变化较少的App,每个头部字段将会缩减至4字节以内,压缩效果非常明显。

评论