从TLS看安全协议设计(下)

TLS1.3作为升级的版本,根据需求主要是在两个方面做了整改,一个是安全方面,一个就是性能,下面我们就来看一下。

升级之处

安全:

  • 删除了不安全的密码组件,例如MD5。
  • 为了前向安全性,密钥协商过程不再使用RSA和静态DH,ECDH,只选用DHE或者ECDHE。
  • 设计了新的密钥派生函数HKDF代替了PRF函数。
  • 记录层进行加密和MAC时固定为AEAD模式。

性能:

  • 重新设计了握手层流程,让冷启动从2RTT变化1RTT。
  • 设计了PSK会话恢复机制代替了原来的Session会话恢复机制。
  • 0-RTT

基本流程

对于TLS1.3的流程如下,最主要分为两种方式,一种是基于key_share和signature_algorithms来选择加密的算法,另一种是基于预共享密钥PSK的会话恢复。

握手层冷启动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Key  ^ ClientHello
Exch | + key_share*
| + signature_algorithms*
| + psk_key_exchange_modes*
v + pre_shared_key* -------->
ServerHello ^ Key
+ key_share* | Exch
+ pre_shared_key* v
{EncryptedExtensions} ^ Server
{CertificateRequest*} v Params
{Certificate*} ^
{CertificateVerify*} | Auth
{Finished} v
<-------- [Application Data*]
^ {Certificate*}
Auth | {CertificateVerify*}
v {Finished} -------->
<-------- [NewSessionTicket]
[Application Data] <-------> [Application Data]

我们首先来看第一种

  1. 客户端发送ClinetHello,在key_share和signature_algorithms中声明自己支持的DHE或者ECDHE算法类型对应的具体参数。
  2. 服务端返回ServerHello,选择自己支持的算法和自己的参数。
  3. 服务端发送EncryptedExtensions,包含不需要建立加密上下文并且和证书无关的拓展。注意从本条消息开始都会被加密传输。
  4. 如果想要验证客户端认证,服务端就会发送CertificateRequest消息。
  5. 服务端发送Certificate消息,包含自己的证书。
  6. 服务端发送CertificateVerify消息。对之前握手的消息做Hash后用证书私钥进行签名,进行显示认证表明自己持有证书私钥。
  7. 服务端发送Finished消息对之前握手消息做校验,以及验证协商密钥的正确性。
  8. 此时服务端已经可以发送加密的应用数据了。
  9. 如果需要客户端验证,就会发送客户端Certificate消息。
  10. 如果客户端发送了Certificate消息,就会发送CertificateVerify来表明客户端持有证书私钥。
  11. 客户端同样发送Finished消息,进行校验。
  12. 客户端可以发送加密的应用消息。

我们观察整个流程,冷启动仅需要1-RTT。因为在ClientHello时已经携带了DHE(ECDHE)的参数,让第一次握手有更多的作用。这也是因为在密钥协商时抛弃了RSA算法,因为对于RSA需要服务端先下发公钥,所以需要至少2-RTT。

握手层热启动

我们再看一下会话恢复的情况。对于1.3使用的是PSK机制,其中PSK可以从上次握手时生成,或者由使用者预置。

看一下流程:

1
2
3
4
5
6
7
8
9
10
11
12
ClientHello
+ key_share*
+ pre_shared_key -------->
ServerHello
+ pre_shared_key
+ key_share*
{EncryptedExtensions}
{Finished}
<-------- [Application Data*]
{Finished} -------->
[Application Data] <-------> [Application Data]
+表示携带的参数,*表示可选

客户端携带PSK发送给服务端,服务端如果接受就返回ServerHello,以及加密的EncryptedExtensions,Finished消息。之后两者就能进行应用数据传输,整个流程需要一个1-RTT。

记录层

流程为数据分段,填充(为了隐藏流量),加密和完整性保护(AEAD),添加消息头。

与1.2最主要的不同就是抛弃的块模式和流模式,固定为AEAD,并对AEAD做了一些调整,这里就不展开说了。

变化分析

HKDF密钥派生函数

1.3重新设计了密钥派生函数,对于HKDF函数分为Extract和Expand过程,Extract过程增加密钥材料的随机性,Expand进行拓展,在1.2中PRF函数只有Expand的过程,它默认密钥材料的随机性是足够的,然而这是不一定的可能ECC协商出来的随机分布是不够均匀的。

Extract过程:

1
2
3
4
5
6
HKDF-Extract(salt, IKM) -> PRK
salt盐可选,没有时用0填充HashLen长度
IKM:Input Keying Material
PRK: 生成的伪随机key

PRK = HMAC-Hash(salt, IKM)

Expand过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
HKDF-Expand(PRK, info, L) -> OKM
PRK伪随机key
info可选一般标志上下文信息
L期望输出的字节数

OKM生成方式:
N = ceil(L/HashLen)
T = T(1) | T(2) | T(3) | ... | T(N)
OKM = first L octets of T

where:
T(0) = empty string (zero length)
T(1) = HMAC-Hash(PRK, T(0) | info | 0x01)
T(2) = HMAC-Hash(PRK, T(1) | info | 0x02)
T(3) = HMAC-Hash(PRK, T(2) | info | 0x03)
...

以及最终使用的Derive-Secret函数与Expand所对应的关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
HKDF-Expand-Label(Secret, Label, Context, Length) =
HKDF-Expand(Secret, HkdfLabel, Length)

Where HkdfLabel is specified as:

struct {
uint16 length = Length;
opaque label<7..255> = "tls13 " + Label;
opaque context<0..255> = Context;
} HkdfLabel;

Derive-Secret(Secret, Label, Messages) =
HKDF-Expand-Label(Secret, Label,
Transcript-Hash(Messages), Hash.length)

Transcript-Hash级联握手消息做Hash运算

密钥变化

1.3对于密钥做了更加详细的分类,在每一个使用部分的密钥实际上都是不同的,都会通过HKDF函数做变化,这也是密码学上的安全要求,一个密钥只使用在一种功能上。

一次握手的密钥变化如下,很复杂,可以只大概了解下有什么密钥:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
          0
|
v
PSK -> HKDF-Extract = Early Secret
|
+-----> Derive-Secret(., "ext binder" | "res binder", "")
| = binder_key
|
+-----> Derive-Secret(., "c e traffic", ClientHello)
| = client_early_traffic_secret
|
+-----> Derive-Secret(., "e exp master", ClientHello)
| = early_exporter_master_secret
v
Derive-Secret(., "derived", "")
|
v
(EC)DHE -> HKDF-Extract = Handshake Secret
|
+-----> Derive-Secret(., "c hs traffic",
| ClientHello...ServerHello)
| = client_handshake_traffic_secret
|
+-----> Derive-Secret(., "s hs traffic",
| ClientHello...ServerHello)
| = server_handshake_traffic_secret
v
Derive-Secret(., "derived", "")
|
v
0 -> HKDF-Extract = Master Secret
|
+-----> Derive-Secret(., "c ap traffic",
| ClientHello...server Finished)
| = client_application_traffic_secret_0
|
+-----> Derive-Secret(., "s ap traffic",
| ClientHello...server Finished)
| = server_application_traffic_secret_0
|
+-----> Derive-Secret(., "exp master",
| ClientHello...server Finished)
| = exporter_master_secret
|
+-----> Derive-Secret(., "res master",
ClientHello...client Finished)
= resumption_master_secret

  1. HKDF-Extract 画在图上,它为从顶部获取 Salt 参数,从左侧获取 IKM 参数,它的输出是底部,和右侧输出的名称。
  2. Derive-Secret 的 Secret 参数由传入的箭头指示。例如,Early Secret 是生成 client_early_traffic_secret 的 Secret。
  3. “0” 表示将 Hash.length 字节的字符串设置为零。

其中有三个Secret,是从Extract中得来:

1
2
3
4
5
Early Secret = HKDF-Extract(salt, IKM) = HKDF-Extract(0, PSK) (有PSK的情况)= HKDF-Extract(0,0) (没有PSK的情况)

Handshake Secret = HKDF-Extract(salt, IKM) = HKDF-Extract(Derive-Secret(Early Secret, "derived", ""), (EC)DHE)

Master Secret = HKDF-Extract(salt, IKM) = HKDF-Extract(Derive-Secret(Handshake Secret, "derived", ""), 0)

八个可能用到的密钥:

1
2
3
4
5
6
7
8
9
10
11
client_early_traffic_secret = Derive-Secret(Early Secret, "c e traffic", ClientHello)
early_exporter_master_secret = Derive-Secret(Early Secret, "e exp master", ClientHello)

client_handshake_traffic_secret = Derive-Secret(Handshake Secret, "c hs traffic", ClientHello...ServerHello)
server_handshake_traffic_secret = Derive-Secret(Handshake Secret, "s hs traffic", ClientHello...ServerHello)

client_application_traffic_secret_0 = Derive-Secret(Master Secret, "c ap traffic", ClientHello...server Finished)
server_application_traffic_secret_0 = Derive-Secret(Master Secret, "s ap traffic", ClientHello...server Finished)

exporter_master_secret = Derive-Secret(Master Secret, "exp master", ClientHello...server Finished)
resumption_master_secret = Derive-Secret(Master Secret, "res master", ClientHello...client Finished)
  1. early_exporter_master_secret和exporter_master_secre导出密钥,用户可以自定义使用方式。
  2. resumption_master_secret用于会话恢复。
  3. client_early_traffic_secret用于加密early_data,在0-RTT模式中使用。
  4. client_handshake_traffic_secret和server_handshake_traffic_secret用于握手时的加密。
  5. client_application_traffic_secret_0和server_application_traffic_secret_0用于加密应用流量数据。

3,4,5的密钥想要加密数据还需要一次Expand过程生成write_key和iv来作为真正加密数据的密钥,生成方式如下

1
2
[sender]_write_key = HKDF-Expand-Label(Secret, "key", "", key_length)
[sender]_write_iv = HKDF-Expand-Label(Secret, "iv", "", iv_length)

PSK会话恢复机制

1.3中使用PSK机制代替了Session机制,那么PSK是如何生成并使用的?

先看下Server发送的NewSessionTicket

1
2
3
4
5
6
7
struct {
uint32 ticket_lifetime;
uint32 ticket_age_add;
opaque ticket_nonce<0..255>;
opaque ticket<1..2^16-1>;
Extension extensions<0..2^16-2>;
} NewSessionTicket;

其中ticket的值是从上一节提到的resumptition_master_secret中计算出来的

1
2
PskIdentity.identity = ticket 
= HKDF-Expand-Label(resumption_master_secret, "resumption", ticket_nonce, Hash.length)

客户端接受到NewSessionTicket后会计算一个binder值,Truncate会取出ClientHello1中的binder_list,避免陷入无限循环。利用这个值来绑定上下文。

1
2
3
4
PskBinderEntry = HMAC(binder_key, Transcript-Hash(Truncate(ClientHello1)))
= HMAC(Derive-Secret(HKDF-Extract(0, PSK), "ext binder" | "res binder", ""), Transcript-Hash(Truncate(ClientHello1)))

其中binder_key = Derive-Secret(HKDF-Extract(0, PSK), "ext binder" | "res binder", "")

然后Client在会话恢复时将identity和binder包含在ClientHello的拓展中发送给Server,Server再验证binder的完整性,比对identity,选择是否接受PSK,从而生成新的handshake_traffic和application密钥进行会话恢复。

可以看到如果仅仅是这样的会话恢复,相对于1.2来说只是更安全(因为不再使用master_secret做传递,而是利用HKDF派生),性能上没有大的提升,所以就会涉及到下面的0-RTT。

0-RTT

对于TLS1.3还有一个很大的变化,那就是0-RTT模式。什么是0-RTT呢,就是基于PSK会话恢复时,PSK会作为Early Secret的IKM,然后从Early Secret生成client_early_traffic_secret,再由client_early_traffic_secret生成key和iv用来加密0-RTT发送的early_data。Client在ClientHello中携带early_data拓展选项,并发送由client_early_traffic_secret加密的数据,如果服务端接受early_data,那么就成功完成了0-RTT传输。

但是0-RTT为了效率降低了安全性,存在重放问题,因为在客户端发送early_data时,只有客户端参数决定,服务端没有参与到其中,那么中间人就可以截获这个ClientHello包以及加密数据,而服务端每次都能成功解析这个包,中间人就可以进行重放ddos或者重复敏感操作。除了这种最简单的重放,还有针对分布式系统的重放攻击,这里就不展开说了。

实例分析

通过上面的分析,我们可以看出TLS具有极强的通用性与可拓展性,但是针对一些具体的使用场景能否进行特殊化的改造,这里我们以微信mmtls作为例子来分析下。我们回顾TLS的过程,身份认证的过程是采用建立PKI体系,做到全球的通用性,但是针对完全可控的微信程序,可以直接将签名的公钥预埋在客户端中,这样就不用建立或者申请证书,通过强制更新客户端,来做公钥的维护,这样就很大程度简略的流程并提升了效率。

还有针对0-RTT的问题,对于TLS1.3本身协议上来说是无解的,更多的只能从应用层来做缓解,例如cloudflare针对0-RTT将禁止非幂等的操作像POST,PUT,只允许不带参数的GET请求。mmtls也是根据这些应用行为,有选择性的使用0-RTT模式,例如只在端连接使用。chrome甚至选择不解决0-RTT重放,因为大部分浏览器的操作都是GET先,安全性交由其他方面来做。

总结

整个TLS完成了密码学上的安全,并结合了软件工程的理念,做到了分层抽象,模块化等等优秀的设计,在通用性上也确保了更多的场景能够被覆盖,提升了互联网的安全性,基本完成了它的设计目标。

当然作为使用者,当我们自己在设计(魔改)安全协议的时候,依然需要小心谨慎,密码学的快速发展已经让大部分程序员很难理解其原理,甚至正确使用密码学黑盒工具也是困难的,所以我们更需要的是怀着严谨的态度去使用密码学工具,紧跟主流,避免陷入闭门造车的困境。还有根据本身的应用场景我们是否能够吸取TLS的精华,结合自己的业务需求去做特质化,省略一些臃肿的包袱,做到定制优化都是可以考虑的。

TLS中的奥义远远不止我所阐述的,短短两篇文章也不能尽善尽美地描绘,希望能够对每一个读者有所帮助,更加有条理地理解其奥义。

参考