TLS1.3的最终版本,在2018年8月发布,它包含着很多不同以往版本的改进,相对于之前版本安全性以及性能具有极大的提高,同时它也具备了更多的扩展和握手模式,那么从实现完整TLS1.3结构的角度去学习TLS,我们应该从哪些方面入手呢?
什么是TLS
TLS代表传输层安全性,并且是SSL(安全套接字层)的后继者。TLS提供了Web浏览器和服务器之间的安全通信。连接本身是安全的,因为使用对称密码术对传输的数据进行加密。密钥是为每个连接唯一生成的,并且基于在会话开始时协商的共享机密(也称为TLS握手)。HTTP + TLS = HTTPS
TLS历史
TLS1.2
TLS1.2握手原理
握手的过程主要包括两部分:
- 参数协商
客户端向服务器发送client Hello消息,里面包含client所支持的参数(密码套件等等),还包含一些有用的参数(version、random、sessionId等等),server会从中选取自己支持的密码套件、版本,通过server Hello发送给client,其中也包含一个随机数还有一些其他字段。 - 密钥交换
之后server会通过Server Key Exchange消息向client发送自己用于协商的公钥,用于协商的算法是:ECDHE或DHE,同样client通过 Client Key Exchange 发送自己的公钥,这样双方都具有了彼此的公钥,用它们生成临时私钥。client在收到server的Certificate消息之后会验证server的身份,验证通过才会发送Client Key Exchange,其中还包含着pre-Masterkey,是对client生成的随机数加密得来,最后使用三个随机数生成Masterkey用于会话密钥。
观察图片我们可以看出,TLS1.2整个握手过程需要2个RTT时间,而且每次握手都用到了非对称加密算法签名或者解密的操作,比较耗时和耗 CPU,每次都要传输证书,证书比较大会消耗带宽。
- RTT
Round-Trip Time,往返时延,在计算机网络中它也是一个重知要的性能指标,它表示从发送端发道送数据开始,到发送端收到来自接收端的确认版(接收端收到数据后便立即发送确认),总共经历的时延。
TLS1.2会话恢复
- SessionID
将协商好的会话参数缓存在客户端与服务器中,client下次握手时会带上上次握手的SessionID,server对其查询,若存在直接复用。 - SessionTicket
server将协商好的会话参数和密钥加密发送给客户端,client下次握手会将这个SessionTicket带上,如果server解密成功就复用上次的会话参数和密钥。
在TLS1.3中没有了SessionID这种会话恢复模式,但是在client Hello中还会存在该字段,主要是为了兼容版本。并且在SessionTicket模式中,添加了Ticket age,指的是会话是存在时间限制的,如果超过了该时间,那么也就不能进行会话恢复。
我们可以看到,使用SessionID恢复会话的时候,需要花费1个RTT的时间,在TLS1.3中一定情况下恢复会话只需要花费0个RTT!
在握手的过程中很多数据都会临时计算,如果我们把这些数据提前计算出来,然后存入扩展当中,这样就可以减少握手的时间为1个RTT,TLS1.3实现的主要思想就是这样的。
TLS1.3
TLS1.3握手
更快的访问速度
这是一张TLS1.2的握手过程图片,前面也分析过,它需要2个RTT的时间才能完成整个握手过程,下面我们看一下TLS1.3的握手过程图:
我们会发现,其中存在一些以前版本从来没有出现过的extension,比如:key_share、signature_algorithms等等,这只是一部分,还包含很多扩展,我会一一细说。正因为这些扩展才使得TLS1.3的握手速度大大提高。
注:
- +:上一消息的扩展消息
- *:可选发送
- {}:用握手层流密钥加密
- []:用流密钥加密
client Hello
当client第一次连接server的时候,它需要向server发送client Hello 消息。
clientHello消息的结构:
1 | uint16 ProtocolVersion; |
2 | opaque Random[32]; |
3 | |
4 | uint8 CipherSuite[2]; /* Cryptographic suite selector */ |
5 | |
6 | struct { |
7 | ProtocolVersion legacy_version = 0x0303; /* TLS v1.2 */ |
8 | Random random; |
9 | opaque legacy_session_id<0..32>; |
10 | CipherSuite cipher_suites<2..2^16-2>; |
11 | opaque legacy_compression_methods<1..2^8-1>; |
12 | Extension extensions<8..2^16-1>; |
13 | } ClientHello; |
简单介绍一下比较重要的几个字段的含义:
- legacy_version
在 TLS 以前的版本里,这个字段被用来版本协商和表示 Client 所能支持的 TLS 最高版本号。经验表明,很多 Server 并没有正确的实现版本协商,导致了 “version intolerance” —— Sever 拒绝了一些本来可以支持的 ClientHello 消息,只因为这些消息的版本号高于 Server 能支持的版本号。在TLS1.3中,设置了一个supported_version的扩展来表明client所支持的版本。legacy_version 字段必须设置成 0x0303,这是 TLS 1.2 的版本号。在 TLS 1.3 中的 ClientHello 消息中的 legacy_version 都设置成 0x0303,supported_versions 扩展设置成 0x0304。主要是为了兼容之前的TLS版本。 - legacy_session_id
前面我也提到过,TLS1.3中不再使用SessionID进行会话恢复,这一特性已经和预共享密钥PSK合并了,设置这个字段的意义,主要也是为了兼容之前版本,如果 Client 有 TLS 1.3 版本之前的 Server 设置的缓存 Session ID,那么这个字段要填上这个 ID 值。兼容模式下,这个值必须是非空的,所以如果Client不能提供之前版本的值,那么需要重新生成一个32字节的值。
还有cipher_suites、legacy_compression_methods,包含的是Client支持的密码套件和压缩算法,压缩算法TLS1.3也已经不再支持了,这个字段主要还是为了兼容版本,对于每个 ClientHello,该向量必须包含一个设置为 0 的一个字节,它对应着 TLS 之前版本中的 null 压缩方法。
supported_groups
这个扩展表明了 Client 支持的用于密钥交换的命名组。按照优先级从高到低。这个扩展中的 “extension_data” 字段包含一个 “NamedGroupList” 值:
1 | enum { |
2 | |
3 | /* Elliptic Curve Groups (ECDHE) */ |
4 | secp256r1(0x0017), secp384r1(0x0018), secp521r1(0x0019), |
5 | x25519(0x001D), x448(0x001E), |
6 | |
7 | /* Finite Field Groups (DHE) */ |
8 | ffdhe2048(0x0100), ffdhe3072(0x0101), ffdhe4096(0x0102), |
9 | ffdhe6144(0x0103), ffdhe8192(0x0104), |
10 | |
11 | /* Reserved Code Points */ |
12 | ffdhe_private_use(0x01FC..0x01FF), |
13 | ecdhe_private_use(0xFE00..0xFEFF), |
14 | (0xFFFF) |
15 | } NamedGroup; |
16 | struct { |
17 | NamedGroup named_group_list<2..2^16-1>; |
18 | } NamedGroupList; |
key_share
这个扩展我觉得是TLS1.3的重大改变,它里面包含了Client对应于supported_groups中参数的公钥集,如果使用了曲线,则会表明所使用的曲线以及对应的公钥。
1 | struct { |
2 | NamedGroup group; |
3 | opaque key_exchange<1..2^16-1>; |
4 | } KeyShareEntry; |
- group:
要交换的密钥的命名组。 - key_exchange:
密钥交换信息。这个字段的内容由特定的组和相应的定义确定。主要包含特定组的公钥等信息。
在 ClientHello 消息中,”key_share” 扩展中的 “extension_data” 包含 KeyShareClientHello 值:
1 | struct { |
2 | KeyShareEntry client_shares<0..2^16-1>; |
3 | } KeyShareClientHello; |
- client_shares:
按照 Client 偏好降序顺序提供的 KeyShareEntry 值列表。
在golang中该结构的实现:
1 | type KeyShareEntry struct { |
2 | group NamedGroup |
3 | length uint16 |
4 | keyExchange []byte |
5 | } |
6 | |
7 | type KeyShareClientHello struct { |
8 | length uint16 |
9 | clientShares []KeyShareEntry |
10 | } |
如果我们只实现椭圆曲线的话,首先需要选择要使用的椭圆曲线,之后再选取随机数生成公钥,将公钥存入keyExchange字段中,也就是说这个扩展已经将Client用于协商会话密钥的参数提前计算出来,并存储了起来,这与之前版本的形式server先选择参数,然后发给Client,然后Client再计算相比较,极大的节省了时间和握手过程中占用的CPU。
Client 可以提供与其提供的 support groups 一样多数量的 KeyShareEntry 的值。每个值都代表了一组密钥交换参数。例如,Client 可能会为多个椭圆曲线或者多个 FFDHE 组提供 shares。每个 KeyShareEntry 中的 key_exchange 值必须独立生成。Client 不能为相同的 group 提供多个 KeyShareEntry 值。Client 不能为,没有出现在 Client 的 “supported_group” 扩展中列出的 group 提供任何 KeyShareEntry 值。Server 会检查这些规则,如果违反了规则,立即发送 “illegal_parameter” alert 消息中止握手。
当选用PSK密钥协商模式时,即使在supported_groups中不存在支持的算法也不会终止握手,这时候,server会向Client发送和serverhello具有相同结构的消息:HelloRetryRequest,它的目的主要是想让Client作出一些改变以使得握手正常进行,在这种情况下,在 HelloRetryRequest 消息中,”key_share” 扩展中的 “extension_data” 字段包含 KeyShareHelloRetryRequest 值。
1 | struct { |
2 | NamedGroup selected_group; |
3 | } KeyShareHelloRetryRequest; |
- selected_group
表明server所选择的NamedGroup中组
Client收到此消息之后也会对其进行验证,selected_group是否在NamedGroup中出现了,selected_group 没有在原始的 ClientHello 中的 “key_share” 中出现过。如果上面 的检查都失败了,那么 Client 必须通过 “illegal_parameter” alert 消息来中止握手。否则,在发送新的 ClientHello 时,Client 必须将原始的 “key_share” 扩展替换为仅包含触发 HelloRetryRequest 的 selected_group 字段中指示的组,这个组中只包含新的 KeyShareEntry。
在 ServerHello 消息中,”key_share” 扩展中的 “extension_data” 字段包含 KeyShareServerHello 值。
1 | struct { |
2 | KeyShareEntry server_share; |
3 | } KeyShareServerHello; |
- server_share:
与 Client 共享的位于同一组的单个 KeyShareEntry 值。
我们再来看一下ECDHE的参数,对于 secp256r1,secp384r1 和 secp521r1,内容是以下结构体的序列化值:
1 | struct { |
2 | uint8 legacy_form = 4; |
3 | opaque X[coordinate_length]; |
4 | opaque Y[coordinate_length]; |
5 | } UncompressedPointRepresentation; |
对端还要验证对方的公钥以确保为有效的点:
- 验证公钥不是无穷大点
- 两个整数x、y中间有正确的间隔
- x、y是椭圆曲线方程的正确的解
小结
TLS 1.3 中优化握手:
- client发送clientHello(extension)消息,extension中的support_groups中携带client支持的椭圆曲线的类型,并且在扩展key_share中计算出了相对应的公钥,一起发送给server
- server收到clientHello后会首先选择相应的椭圆曲线参数计算自身的公钥,从key_share扩展中提取相应的公钥作为密钥协商的参数,计算主密钥,并且把自身计算出的公钥放到serverHello的扩展key_share中,然后发送serverHello等消息给client,Client从key_share中取出公钥计算主密钥。
TLS1.3会话恢复
在本文前面我提到过TLS1.3已经不再使用SessionID进行会话恢复了,现在主要使用的是SessionTicket进行会话恢复,但是又不同于TLS1.2中使用SessionTicket进行会话恢复的过程,也做出了一些改变,或者说是进行了一些更新(PSK)。
会话恢复所花费的时间是1个RTT,这与整个的握手时间是一样的。在TLS1.3中采用的会话恢复机制是PSK它与SessionTicket有些类似,Client通过PSK发送被server加密的会话缓存参数,如果server解密成功就可以直接复用会话,不需要再重新传输证书和协商密钥了。
密钥交换模式
- PSK-Only
- (EC)DHE
- PSK with (EC)DHE(暂时还没出现)
PSK handshake
在使用PSK密钥交换模式时我们首先要了解几个ClientHello的其它扩展:
Pre-Shared Key Exchange Modes
为了使用PSK,client还需要发送Pre-Shared Key Exchange Modes扩展,它的含义是Client 仅支持使用具有这些模式的 PSK,这就限制了在这个 ClientHello 中提供的 PSK 的使用,也限制了 Server 通过 NewSessionTicket 提供的 PSK 的使用。
如果Client提供了 pre_shared_key扩展,那么就必须提供该扩展
1 | enum { psk_ke(0), psk_dhe_ke(1), (255) } PskKeyExchangeMode; |
2 | |
3 | struct { |
4 | PskKeyExchangeMode ke_modes<1..255>; |
5 | } PskKeyExchangeModes; |
- psk_ke:
仅 PSK 密钥建立。在这种模式下,Server 不能提供 key_share 值 - psk_dhe_ke:
PSK 和 (EC)DHE 建立。在这种模式下,Client 和 Server 必须提供 key_share值。
这样的话就可以进行模式选择,并作出相应的改变。未来分配的任何值都必须要能保证传输的协议消息可以明确的标识 Server 选择的模式。目前 Server 选择的值由 ServerHello 中存在的 key_share 表示。
Pre-Shared Key
该扩展是用来协商标识的,该标识是与PSK密钥相关联的给定握手所使用的预共享密钥的标识。或者说是New Session Ticket+binders,由于在TLS1.3中,New Session Ticket可以在握手结束后可能多次发送,所以Pre-Shared Key可能会存储多组对应的值,下面我们具体来了解一下它的结构。
1 | struct { |
2 | opaque identity<1..2^16-1>; |
3 | uint32 obfuscated_ticket_age; |
4 | } PskIdentity; |
5 | |
6 | opaque PskBinderEntry<32..255>; |
7 | |
8 | struct { |
9 | PskIdentity identities<7..2^16-1>; |
10 | PskBinderEntry binders<33..2^16-1>; |
11 | } OfferedPsks; |
12 | |
13 | struct { |
14 | select (Handshake.msg_type) { |
15 | case client_hello: OfferedPsks; |
16 | case server_hello: uint16 selected_identity; |
17 | }; |
18 | } PreSharedKeyExtension; |
- identity:
一个预共享密钥的标签。 - obfuscated_ticket_age:
SessionTicket的寿命的混淆版本,为了防止一些相关连接的被动观察者。而在TLS1.2中是不存在这样的字段,即不会标识出客户端已存在的时间,server收到后主要靠里面的内容来判断Ticket是否过期,而在TLS1.3中就增加了这样一个字段来表示Ticket的寿命,因为是明文传输所以会被观察者发现,所以给时间加了一些调味品,是New Session Ticket中的ticket_age_add,因为New Session Ticket本身就是被加密的,所以这个ticket_age_add只有通信两端才知道。 - 混淆的方法:
用 ticket 时间(毫秒为单位)加上 “ticket_age_add” 字段,最后对 2^32 取模。注意,NewSessionTicket 消息中的 “ticket_lifetime” 字段是秒为单位,但是 “obfuscated_ticket_age” 是毫秒为单位。 - identities:
Client 愿意和 Server 协商的 identities 列表,其内容就是NewSessionTicket中的ticket部分。如果和 early_data 一起发送,第一个标识被用来标识 0-RTT 的。有关early_data后面还会说到。 - selected_identity:
server选择的标识,是server在自己的 Pre-Shared Key扩展中自己设置的选择的标识,表明正常解析了Client的扩展,其实选择的话就是一个序号。
0-RTT
前面已经提到过TLS1.3已经将握手时间优化到了1-RTT,对比之前版本的速度已经快了很多,但是TLS1.3最终极的做法是0-RTT,即握手的时间是0-RTT。
Client发送ClientHello消息,除了在PSK模式中提到的那些扩展外,还应该具有一个early_data扩展,同理server发送serverHello中也应该包括该扩展,并表示其愿意接受early_data,client发送完early_data后,发送End_Of_Early_Data报文表示client自己发送完了early_data。
如果 Server 提供了 early_data 扩展,Client 必须验证 Server 的 selected_identity 是否为 0。如果返回任何其他值,Client 必须使用 “illegal_parameter” alert 消息中止握手。下面我们看一下它的结构:
1 | struct {} Empty; |
2 | |
3 | struct { |
4 | select (Handshake.msg_type) { |
5 | case new_session_ticket: uint32 max_early_data_size; |
6 | case client_hello: Empty; |
7 | case encrypted_extensions: Empty; |
8 | }; |
9 | } EarlyDataIndication; |
其中的max_early_data_size字段表明,允许Client发送的最大0-RTT的数据量。
发生错误会导致0-RTT降级到1-RTT。
New Session Ticket
Post-Handshake Messages在 Server 接收到 Client 的 Finished 消息以后的任何时刻,它都可以发送 NewSessionTicket 消息。此消息在 ticket 值和从恢复主密钥派生出来的 PSK 之间创建了唯一的关联。
1 | struct { |
2 | uint32 ticket_lifetime; |
3 | uint32 ticket_age_add; |
4 | opaque ticket_nonce<0..255>; |
5 | opaque ticket<1..2^16-1>; |
6 | Extension extensions<0..2^16-2>; |
7 | } NewSessionTicket; |
- ticket_lifetime:
这个字段表示 ticket 的生存时间,这个时间是以 ticket 发布时间为网络字节顺序的 32 位无符号整数表示以秒为单位的时间。Server 禁止使用任何大于 604800秒(7 天)的值。值为零表示应立即丢弃 ticket。无论 ticket_lifetime 如何,Client 都不得缓存超过 7 天的 ticket,并且可以根据本地策略提前删除 ticket。Server 可以将 ticket 视为有效的时间段短于 ticket_lifetime 中所述的时间段。 - ticket_age_add:
安全的生成的随机 32 位值,用于模糊 Client 在 “pre_shared_key” 扩展中包含的 ticket 的时间。Client 的 ticket age 以模 2 ^ 32 的形式添加此值,以计算出 Client 要传输的值。Server 必须为它发出的每个 ticket 生成一个新值。 - ticket_nonce:
每一个 ticket 的值,在本次连接中发出的所有的 ticket 中是唯一的。初始值是0,发送一个则++ - ticket:
这个值是被用作 PSK 标识的值
TLS1.3一些其他扩展和机制
降级保护机制
主要通过随机数来实现,当协商TLS1.2或更老的版本,为了响应ClientHello在random后8个字节填入特定的随机值,若为TLS1.2则后8个字节的值为:
1 | 44 4F 57 4E 47 52 44 01 |
supported_version
主要功能的话前面也有提到,对Client标明所支持的TLS版本,对Server标明正在使用的TLS版本,如果协商TLS之前的版本,这个扩展必须带上,若不存在该扩展,server要协商之前的版本,则中止握手,若存在server将禁止使用ClientHello中的legacy_version作为版本协商的值,只能使用supported_versions中的值。
对于server:
- 版本<TLS1.3 则设置serverHello.version,不能发送supported_version
- 版本>=TLS1.3则必须发送supported_version扩展,还要设置serverHello.legacy_version为0x0303,若扩展存在Client会忽略serverHello.legacy_version,而去读取supported_version的值。
1 | struct { |
2 | select (Handshake.msg_type) { |
3 | case client_hello: |
4 | ProtocolVersion versions<2..254>; |
5 | |
6 | case server_hello: /* and HelloRetryRequest */ |
7 | ProtocolVersion selected_version; |
8 | }; |
9 | } SupportedVersions; |