写点什么

码了 2000 多行代码就是为了讲清楚 TLS 握手流程

用户头像
Gopher指北
关注
发布于: 2020 年 11 月 28 日
码了2000多行代码就是为了讲清楚TLS握手流程

来自公众号:新世界杂货铺

前言

呼,这篇文章的准备周期可谓是相当的长了!原本是想直接通过源码进行分析的,但是发现 TLS 握手流程调试起来非常不方便,笔者怒了,于是实现了一个极简的net.Conn接口以方便调试。码着码着,笔者哭了,因为现在这个调试 Demo 已经达到 2000 多行代码了!



虽然码了两千多行代码,但是目前只能够解析 TLS1.3 握手流程中发送的消息,因此本篇主要分析 TLS1.3 的握手流程。


特别提醒:有想在本地调试一番的小伙伴请至文末获取本篇源码。

结论先行

鉴于本文篇幅较长,笔者决定结论先行,以助各位读者理解后文详细的分析内容。

HTTPS 单向认证

单向认证客户端不需要证书,客户端只要验证服务端证书合法即可访问。

下面是笔者运行 Demo 打印的调试信息:


根据调试信息知,在 TLS1.3 单向认证中,总共收发数据三次,Client 和 Server 从这三次数据中分别读取不同的信息以达到握手的目的。


注意:TLS1.3 不处理ChangeCipherSpec类型的数据,而该数据在 TLS1.2 中是需要处理的。因本篇主要分析 TLS1.3 握手流程,故后续不会再提及ChangeCipherSpec,同时时序图中也会忽略此消息

笔者将调试信息转换为下述时序图,以方便各位读者理解。



HTTPS 双向认证

双向认证不仅服务端要有证书,客户端也需要证书,只有客户端和服务端证书均合法才可继续访问。

笔者在这里特别提醒,开启双向认证很简单,在笔者的 Demo 中取消下面代码的注释即可。

// sconf.ClientAuth = tls.RequireAndVerifyClientCert
复制代码

另外,笔者在main.go同目录下留有测试用的根证书、服务端证书和客户端证书,为了保证双向认证的顺利运行请将根证书安装为受用户信任的证书。

下面是笔者运行 Demo 打印的调试信息:


同单向认证一样,笔者将调试信息转换为下述时序图。



双向认证和单向认证相比,Server 发消息给 Client 时会额外发送一个certificateRequestMsgTLS13消息,Client 收到此消息后会将证书信息(certificateMsgTLS13)和签名信息(certificateVerifyMsg)发送给 Server。

双向认证中,Client 和 Server 发送消息变多了,但是总的数据收发仍然只有三次

总结


1、TLS1.3 和 TLS1.2 握手流程是有区别的,这一点需要注意。


2、单向认证和双向认证中,总的数据收发仅三次,单次发送的数据中包含一个或者多个消息。


3、clientHelloMsgserverHelloMsg未经过加密,之后发送的消息均做了加密处理。


4、Client 和 Server 会各自计算两次密钥,计算时机分别是读取到对方的HelloMsgfinishedMsg之后。


:上述第 3 点和第 4 点分析过程详见后文。

Client 发送 HelloMsg

在 TLS 握手过程中的第一步是 Client 发送 HelloMsg,所以针对 TLS 握手流程的分析也从这一步开始。

Server 对于 Client 的基本信息了解完全依赖于 Client 主动告知 Server,而其中比较关键的信息分别是客户端支持的TLS版本客户端支持的加密套件(cipherSuites)客户端支持的签名算法客户端支持的密钥交换协议以及其对应的公钥

客户端支持的 TLS 版本:

客户端支持的 TLS 版本主要通过 tls 包中(*Config).supportedVersions方法计算。对 TLS1.3 来说默认支持的 TLS 版本如下:


var supportedVersions = []uint16{	VersionTLS13,	VersionTLS12,	VersionTLS11,	VersionTLS10,}
复制代码

在发起请求时如果用户手动设置了tls.Config中的MaxVersion或者MinVersion,则客户端支持的 TLS 版本会发生变化。

例如发起请求时,设置了conf.MaxVersion = tls.VersionTLS12,此时(*Config).supportedVersions返回的版本为:

[]uint16{	VersionTLS12,	VersionTLS11,	VersionTLS10,}
复制代码

ps: 如果有兴趣的小伙伴可以在克隆笔者的 demo 后手动设置 Config.MaxVersion,设置后可以调试 TLS1.2 的握手流程。


客户端支持的加密套件(cipherSuites):

说实话,加密套件已经进入笔者的知识盲区了,其作用笔者会在下一小节讲明白,故本小节笔者直接贴出计算后的结果。


图中篮框部分为当前 Client 支持加密套件 Id,红框部分为计算逻辑。

客户端支持的签名算法:

客户端支持的签名算法,仅在客户端支持的最大 TLS 版本大于等于 TLS1.2 时生效。此时客户端支持的签名算法如下:


var supportedSignatureAlgorithms = []SignatureScheme{	PSSWithSHA256,	ECDSAWithP256AndSHA256,	Ed25519,	PSSWithSHA384,	PSSWithSHA512,	PKCS1WithSHA256,	PKCS1WithSHA384,	PKCS1WithSHA512,	ECDSAWithP384AndSHA384,	ECDSAWithP521AndSHA512,	PKCS1WithSHA1,	ECDSAWithSHA1,}
复制代码


客户端支持的密钥交换协议及其对应的公钥:

这一块儿逻辑仅在客户端支持的最大 TLS 版本是 TLS1.3 时生效。


if hello.supportedVersions[0] == VersionTLS13 {	hello.cipherSuites = append(hello.cipherSuites, defaultCipherSuitesTLS13()...)
curveID := config.curvePreferences()[0] if _, ok := curveForCurveID(curveID); curveID != X25519 && !ok { return nil, nil, errors.New("tls: CurvePreferences includes unsupported curve") } params, err = generateECDHEParameters(config.rand(), curveID) if err != nil { return nil, nil, err } hello.keyShares = []keyShare{{group: curveID, data: params.PublicKey()}}}
复制代码

上述代码中,方法config.curvePreferences的逻辑为:


var defaultCurvePreferences = []CurveID{X25519, CurveP256, CurveP384, CurveP521}func (c *Config) curvePreferences() []CurveID {	if c == nil || len(c.CurvePreferences) == 0 {		return defaultCurvePreferences	}	return c.CurvePreferences}
复制代码

在本篇中,笔者未手动设置优先可供选择的曲线,故curveID的值为X25519

上述代码中,generateECDHEParameters函数的作用是根据曲线 Id 生成一种椭圆曲线密钥交换协议的实现。

如果客户端支持的最大 TLS 版本是 TLS1.3 时,会为 Client 支持的加密套件增加 TLS1.3 默认的加密套件,同时还会选择 Curve25519 密钥交换协议生成keyShare

小结:本节介绍了在 TLS1.3 中 Client 需要告知 Server 客户端支持的 TLS 版本号、客户端支持的加密套件、客户端支持的签名算法和客户端支持的密钥交换协议。

Server 读 HelloMsg&发送消息

Server 读到clientHelloMsg之后会根据客户端支持的 TLS 版本和本地支持的 TLS 版本做对比,得到 Client 和 Server 均支持的 TLS 版本最大值,该值作为后续继续通信的标准。本篇中 Client 和 Server 都支持 TLS1.3,因此 Server 进入 TLS1.3 的握手流程。

处理 clientHelloMsg

Server 进入 TLS1.3 握手流程之后,还需要继续处理 clientHelloMsg,同时构建serverHelloMsg

Server 支持的 TLS 版本:

进入 TLS1.3 握手流程之前,Server 已经计算出两端均支持的 TLS 版本,但是 Client 还无法得知 Server 支持的 TLS 版本,因此开始继续处理 clientHelloMsg 时,Server 将已经计算得到的 TLS 版本赋值给supportedVersion以告知客户端。


// client读取到serverHelloMsg后,通过读取此字段计算两端均支持的TLS版本hs.hello.supportedVersion = c.vers
复制代码


Server 计算两端均支持的加密套件

clientHelloMsg中含有 Client 支持的加密套件信息,Server 读取该信息并和本地支持的加密套件做对比计算出两端均支持的加密套件。

这里需要注意的是,如果 Server 的tls.Config.PreferServerCipherSuitestrue则选择 Server 第一个在两端均支持的加密套件,否则选择 Client 第一个在两端均支持的加密套件。笔者通过 Debug 得到两端均支持的加密套件 id 为4865(其常量为tls.TLSAES128_GCM_SHA256),详情见下图:



上图中的mutualCipherSuiteTLS13函数会从cipherSuitesTLS13变量中选择匹配的加密套件。


var cipherSuitesTLS13 = []*cipherSuiteTLS13{	{TLS_AES_128_GCM_SHA256, 16, aeadAESGCMTLS13, crypto.SHA256},	{TLS_CHACHA20_POLY1305_SHA256, 32, aeadChaCha20Poly1305, crypto.SHA256},	{TLS_AES_256_GCM_SHA384, 32, aeadAESGCMTLS13, crypto.SHA384},}
复制代码


结合前面的 Debug 信息知,hs.suitecipherSuiteTLS13结构体的变量且其值为cipherSuitesTLS13切片的第一个。cipherSuiteTLS13结构体定义如下:


type cipherSuiteTLS13 struct {	id     uint16	keyLen int	aead   func(key, fixedNonce []byte) aead	hash   crypto.Hash}
复制代码


至此,Server 已经计算出双端均支持的加密套件,Server 通过设置cipherSuite将双端均支持的加密套件告知 Client:


hs.hello.cipherSuite = hs.suite.idhs.transcript = hs.suite.hash.New()
复制代码

在后续计算密钥时需要对 Client 和 Server 之间的所有消息计算 Hash 摘要。根据前面计算出的加密套件知,本篇中计算消息摘要的 Hash 算法为SHA256,此算法的实现赋值给hs.transcript变量,后续计算消息摘要时均通过该变量实现。


Server 计算双端均支持的密钥交换协议以及对应的公钥


clientHelloMsg.keyShares变量记录着 Client 支持的曲线 Id 以及对应的公钥。Server 通过对比本地支持的曲线 Id 计算出双端均支持的密钥交换协议。根据前面 Client 发送 HelloMsg 这一小节的内容以及笔者实际调试的结果,双端均支持的曲线为Curve25519

Server 计算出双端均支持的曲线后,调用generateECDHEParameters方法得到对应密钥交换协议的实现,即 Curve25519 密钥交换协议。

Curve25519是椭圆曲线迪菲-赫尔曼(Elliptic-curve Diffie–Hellman ,缩写为 ECDH)密钥交换方案之一,同时也是最快的 ECC(Elliptic-curve cryptography)曲线之一。

ECDH可以为 Client 和 Server 在不安全的通道上为双方建立共享密钥,并且 Client 和 Server 需要各自持有一组椭圆曲线公私密钥对。当 Client 和 Server 需要建立共享密钥时仅需要公布各自的公钥,Client 和 Server 通过对方的公钥以及自己的私钥即可计算出相等的密钥。如果公钥被第三方截获也无关紧要,因为第三方没有私钥无法计算出共享密钥除非第三方能够解决椭圆曲线 Diffie–Hellman 问题。ECDHEECDH的一个变种,其区别仅仅是私钥和公钥在每次建立共享密钥时均需重新生成(以上为笔者对维基百科中 ECDH 的理解)。

ECDHE有了一定的理解后,我们现在看一下generateECDHEParameters函数中的部分源码:


func generateECDHEParameters(rand io.Reader, curveID CurveID) (ecdheParameters, error) {	if curveID == X25519 {		privateKey := make([]byte, curve25519.ScalarSize)		if _, err := io.ReadFull(rand, privateKey); err != nil {			return nil, err		}		publicKey, err := curve25519.X25519(privateKey, curve25519.Basepoint)		if err != nil {			return nil, err		}		return &x25519Parameters{privateKey: privateKey, publicKey: publicKey}, nil	}  // 此处省略代码}
复制代码


每次调用generateECDHEParameters函数时均会生成一组新的椭圆曲线公私密钥对。clientHelloMsg.keyShares变量存有 Client 的公钥,因此 Server 已经可以计算共享密钥:


params, err := generateECDHEParameters(c.config.rand(), selectedGroup)if err != nil {  c.sendAlert(alertInternalError)  return err}hs.hello.serverShare = keyShare{group: selectedGroup, data: params.PublicKey()}hs.sharedKey = params.SharedKey(clientKeyShare.data) // 共享密钥
复制代码


上述代码中 Server 已经计算出共享密钥,之后可以通过此密钥派生出其他密钥为数据加密。Client 因为无 Server 的公钥还无法计算出共享密钥,所以 Server 通过设置serverShare变量告知 Client 服务端的公钥。


至此,Server 对 Client 发来的 helloMsg 已经处理完毕。笔者在这里额外提醒一句,clientHelloMsgserverHelloMsg中仍然有 Client 和 Server 生成的随机数,但是在 TLS1.3 中这两个随机数已经和密钥交换无关了。


小结:本节介绍了 Server 读取clientHelloMsg后会计算双端支持的 TLS 版本以及双端支持的加密套件和密钥交换协议,同时还介绍了共享密钥的生成以及 ECDH 的概念。

选择合适的证书以及签名算法

在 Server 选择和当前 Client 匹配的证书前其实还有关于预共享密钥模式的处理,该模式需要实现ClientSessionCache接口,鉴于其不影响握手流程的分析,故本篇不讨论预共享密钥模式。


一个 Server 可能给多个 Host 提供服务,因此 Server 可能持有多个证书,那么选择一个和当前 Client 匹配的证书是十分必要的,其实现逻辑参见(*Config).getCertificate方法。本篇中的 Demo 只有一个证书,故该方法会直接返回此证书。


证书中是包含公钥的,不同的公钥支持的签名算法是不同的,在本例中 Server 支持的签名算法和最终双端均支持的签名算法见下面的 Debug 结果:



上图中红框部分为 Server 支持的签名算法,蓝框为选定的双端均支持的签名算法。


小结:本节主要介绍了 Server 选择匹配当前 Client 的证书和签名算法。


计算握手阶段的密钥以及发送 Server 的参数

在这个阶段 Server 会将serverHelloMsg写入缓冲区,写完之后再写入一个ChangeCipherSpec(TLS1.3 不会处理此消息)消息,需要注意的是serverHelloMsg未进行加密发送。


计算握手阶段的密钥


前面提到过计算密钥需要计算消息摘要:


hs.transcript.Write(hs.clientHello.marshal())hs.transcript.Write(hs.hello.marshal()) // hs.hello为serverHelloMsg
复制代码


上述代码中hs.transcript在前面已经提到过是SHA256Hash 算法的一种实现。下面我们逐步分析源码中 Server 第一次计算密钥的过程。


首先,派生出handshakeSecret


earlySecret := hs.earlySecret if earlySecret == nil {  earlySecret = hs.suite.extract(nil, nil)}hs.handshakeSecret = hs.suite.extract(hs.sharedKey, hs.suite.deriveSecret(earlySecret, "derived", nil))
复制代码


earlySecret和预共享密钥有关,因本篇不涉及预共享密钥,故earlySecretnil。此时,earlySecret会通过加密套件派生出一个密钥。


// extract implements HKDF-Extract with the cipher suite hash.func (c *cipherSuiteTLS13) extract(newSecret, currentSecret []byte) []byte {	if newSecret == nil {		newSecret = make([]byte, c.hash.Size())	}	return hkdf.Extract(c.hash.New, newSecret, currentSecret)}
复制代码


上述代码中HDKF是一种基于哈希消息身份验证的密钥派生算法,其两个主要用途分别为:一、从较大的随机源中提取更加均匀和随机的密钥;二、将已经合理的随机输入(例如共享密钥)扩展为更大的密码独立输出,从而将共享密钥派生出多个密钥(以上为笔者对维基百科中 HKDF 的理解)。


上述代码中hs.suite.deriveSecret方法笔者就不列出其源码了,该方法最终会调用hkdf.Expand方法进行密钥派生。


此时再次回顾hs.handshakeSecret的生成正是HKDF算法基于sharedKeyearlySecret计算的结果。


然后,通过handshakeSecret和消息摘要派生出一组密钥。


clientSecret := hs.suite.deriveSecret(hs.handshakeSecret,	clientHandshakeTrafficLabel, hs.transcript)c.in.setTrafficSecret(hs.suite, clientSecret)serverSecret := hs.suite.deriveSecret(hs.handshakeSecret,	serverHandshakeTrafficLabel, hs.transcript)c.out.setTrafficSecret(hs.suite, serverSecret)
复制代码


上述代码中clientHandshakeTrafficLabelserverHandshakeTrafficLabel为常量,其值分别为c hs traffics hs traffichs.suite.deriveSecret方法会在内部调用hs.transcript.Sum(nil)计算出消息的摘要信息,所以clientSecretserverSecretHKDF算法基于handshakeSecret和两个常量以及 Server 和 Client 已经发送的消息的摘要派生出的密钥。


clientSecret在服务端用于对收到的数据进行解密,serverSecret在服务端对要发送的数据进行加密。c.inc.out同其语义一样,分别用于处理收到的数据和要发送的数据。


下面看看笔者对setTrafficSecret方法的 Debug 结果:



上图中trafficKey方法使用HKDF算法对密钥进行了再次派生,笔者就不再对其展开。这里需要关注的是红框部分,aes-gcm是一种AEAD加密。


单纯的对称加密算法,其解密步骤是无法确认密钥是否正确的。也就是说,加密后的数据可以用任何密钥执行解密运算,得到一组疑似原始数据,然而并不知道密钥是否是正确,也不知道解密出来的原始数据是否正确。因此,需要在单纯的加密算法之上,加上一层验证手段,来确认解密步骤是否正确,这就是AEAD


至此,Server 在握手阶段的密钥生成结束,此阶段之后发送的消息(即serverHelloMsgChangeCipherSpec之后的消息),均通过aes-gcm算法加密。


最后回顾一下加密套件的作用:


1、提供消息摘要的 Hash 算法。


2、提供加解密的AEAD算法。


最后再顺便提一嘴,笔者 Demo 中 parse.go 文件的processMsg方法在处理serverHelloMsg时有计算握手阶段密钥的极简实现。


支持的 HTTP 协议


Client 通过clientHelloMsg.alpnProtocols告知 Server 客户端支持的 HTTP 协议,Server 通过对比本地支持的 HTTP 协议,最终选择双端均支持的协议并构建encryptedExtensionsMsg消息告知 Client


encryptedExtensions := new(encryptedExtensionsMsg)if len(hs.clientHello.alpnProtocols) > 0 {  if selectedProto, fallback := mutualProtocol(hs.clientHello.alpnProtocols, c.config.NextProtos); !fallback {    encryptedExtensions.alpnProtocol = selectedProto    c.clientProtocol = selectedProto  }}hs.transcript.Write(encryptedExtensions.marshal())
复制代码


hs.clientHello.alpnProtocols的数据来源为客户端的tls.Config.NextProtos。在笔者的 Demo 中,Client 和 Server 均支持h2http1.1这两种协议。


这里顺便强调一下,Client 或者 Server 在获取到对方的 helloMsg 之后接受/发送的消息均会调用hs.transcript.Write方法,以便计算密钥时可以快速计算消息摘要。


小结


1、本节讨论了握手阶段的密钥生成流程:对消息摘要,然后用 HKDF 算法对共享密钥和消息摘要派生密钥,最后通过加密套件返回 AEAD 算法的实现。


2、确认了加密套件的作用。


3、计算两端均支持的 HTTP 协议。


发送 Server 证书以及签名

此阶段主要涉及三个消息,分别是certificateRequestMsgTLS13certificateMsgTLS13certificateVerifyMsg


其中certificateRequestMsgTLS13仅在双向认证时才发送给 Client,单向认证时 Server 不发送此消息。这里也再次印证了前面单向认证和双向认证时序图中 Server 发送的消息数量不一致的原因。


certificateMsgTLS13消息的主体是 Server 的证书这个没什么好说的,下面着重分析一下certificateVerifyMsg


私钥签名


首先,构建certificateVerifyMsg并设置其签名算法。


certVerifyMsg := new(certificateVerifyMsg)certVerifyMsg.hasSignatureAlgorithm = true // 没有签名算法无法签名,所以直接写true没毛病certVerifyMsg.signatureAlgorithm = hs.sigAlg
复制代码


上述代码中hs.sigAlg选择合适的证书以及签名算法小节选择的签名算法。


然后,通过签名算法计算签名类型以及签名 hash,并构建签名选项。以下为笔者 Debug 结果:



由上图知,签名类型为signatureRSAPSS,签名哈希算法为SHA256signedMessage的作用是将消息的摘要和serverSignatureContext(值为TLS 1.3, server CertificateVerify\x00)常量按照固定格式构建为待签名数据。


最后,计算签名并发送消息。


sig, err := hs.cert.PrivateKey.(crypto.Signer).Sign(c.config.rand(), signed, signOpts)if err != nil {  // 省略代码  return errors.New("tls: failed to sign handshake: " + err.Error())}certVerifyMsg.signature = sighs.transcript.Write(certVerifyMsg.marshal())
复制代码


特别提醒,私钥加密公钥解密称之为签名。


小结:本节主要介绍了此阶段会发送的三种消息,以及 Server 签名的过程。


发送 finishedMsg 并再次计算密钥

发送 finishedMsg


finishedMsg的内容非常简单,仅一个字段:


finished := &finishedMsg{  verifyData: hs.suite.finishedHash(c.out.trafficSecret, hs.transcript),}
复制代码


verifyData通过加密套件的finishedHash计算得出,下面我们看看finishedHash的内容:


func (c *cipherSuiteTLS13) finishedHash(baseKey []byte, transcript hash.Hash) []byte {	finishedKey := c.expandLabel(baseKey, "finished", nil, c.hash.Size())	verifyData := hmac.New(c.hash.New, finishedKey)	verifyData.Write(transcript.Sum(nil))	return verifyData.Sum(nil)}
复制代码


HMAC是一种利用密码学中的散列函数来进行消息认证的一种机制,所能提供的消息认证包括两方面内容(此内容摘自百度百科):


消息完整性认证:能够证明消息内容在传送过程没有被修改。


信源身份认证:因为通信双方共享了认证的密钥,接收方能够认证发送该数据的信源与所宣称的一致,即能够可靠地确认接收的消息与发送的一致。


上述代码中,c.expandLabel最种会调用hkdf.Expand派生出新的密钥。最后用新的密钥以及消息摘要通过HMAC算法计算出verifyData


收到finishedMsg一方通过同样的方式在本地计算出verifyData',如果verifyData'verifyData相等,则证明此消息未被修改且来源可信。


再次计算密钥

本次计算密钥的过程和前面计算密钥的流程相似,所以直接上代码:


hs.masterSecret = hs.suite.extract(nil,	hs.suite.deriveSecret(hs.handshakeSecret, "derived", nil))
hs.trafficSecret = hs.suite.deriveSecret(hs.masterSecret, clientApplicationTrafficLabel, hs.transcript)serverSecret := hs.suite.deriveSecret(hs.masterSecret, serverApplicationTrafficLabel, hs.transcript)c.out.setTrafficSecret(hs.suite, serverSecret)
复制代码


首先,利用前文已经生成的handshakeSecret 再次派生出masterSecret,然后再从masterSecret派生出trafficSecretserverSecret,最后调用c.out.setTrafficSecret(hs.suite, serverSecret)计算出 Server 发送数据时的AEAD加密算法。


需要注意的是,此时利用serverSecret生成的AEAD加密算法会用于握手结束后对要发送的业务数据进行加密。


此阶段结束后,Server 会调用c.flush()方法,将前面提到的消息一次性发送给 Client。


小结


1、本节介绍了finishedMsg的生成过程,其中finishedMsg.verifyData通过HMAC算法计算得出。


2、finishedMsg的作用是确保握手过程中发送的消息未被篡改,且数据来源可信。


3、计算 Server 发送业务数据时的加密密钥。


Client 读消息 &发送消息

Client 读到serverHelloMsg之后会读取服务端支持的 TLS 版本并和本地支持的版本做对比,前文已经提到过服务端支持的 TLS 版本是 TLS1.3,因此 Client 也进入 TLS1.3 握手流程。

读取 serverHelloMsg 并计算密钥

Client 进入 TLS1.3 握手流程后,有一系列的检查逻辑,这些逻辑比较长而且笔者也不需要考虑这些异常,因此笔者化繁为简,在下面列出关键逻辑:


selectedSuite := mutualCipherSuiteTLS13(hs.hello.cipherSuites,	hs.serverHello.cipherSuite) // 结合Server支持的加密套件选择双端均支持的加密套件hs.suite = selectedSuitehs.transcript = hs.suite.hash.New()hs.transcript.Write(hs.hello.marshal()) // hs.hello为clientHelloMsghs.transcript.Write(hs.serverHello.marshal())
复制代码


上面这一段代码逻辑和 Server 处理加密套件以及通过加密套件构建消息摘要算法的实现逻辑相对应,因此笔者不再过多赘述。


下面我们看一下计算握手阶段的密钥以及masterSecret的生成:


sharedKey := hs.ecdheParams.SharedKey(hs.serverHello.serverShare.data)earlySecret := hs.earlySecretif !hs.usingPSK {  earlySecret = hs.suite.extract(nil, nil)}handshakeSecret := hs.suite.extract(sharedKey,	hs.suite.deriveSecret(earlySecret, "derived", nil)) // 通过共享密钥派生出handshakeSecret
clientSecret := hs.suite.deriveSecret(handshakeSecret, clientHandshakeTrafficLabel, hs.transcript) // 通过handshakeSecret派生出clientSecretc.out.setTrafficSecret(hs.suite, clientSecret)serverSecret := hs.suite.deriveSecret(handshakeSecret, serverHandshakeTrafficLabel, hs.transcript) // 通过handshakeSecret派生出serverSecretc.in.setTrafficSecret(hs.suite, serverSecret)hs.masterSecret = hs.suite.extract(nil, hs.suite.deriveSecret(handshakeSecret, "derived", nil)) // 通过handshakeSecret派生出masterSecret
复制代码


这里需要提一嘴的是hs.ecdheParams,该值为 Client 发送 HelloMsg 这一小节调用generateECDHEParameters函数生成的params。其他逻辑和 Server 生成握手阶段的密钥保持一致,硬要说不同的话也就只有masterSecret生成的阶段不同。


最后,clientSecret在客户端用于对要发送的数据进行加密,serverSecret在客户端对收到的数据进行解密。


小结:本节梳理了客户端处理serverHelloMsg的逻辑和生成握手阶段密钥的逻辑。

处理 Server 发送的参数

在客户端需要处理的 Server 参数只有一个encryptedExtensionsMsg消息。而且处理逻辑也十分简单:


msg, err := c.readHandshake()encryptedExtensions, ok := msg.(*encryptedExtensionsMsg)hs.transcript.Write(encryptedExtensions.marshal())c.clientProtocol = encryptedExtensions.alpnProtocol
复制代码


如果客户端读取到encryptedExtensionsMsg消息,则直接将 Server 支持的 HTTP 协议赋值给c.clientProtocol。在之后的 HTTP 请求中会根据 TLS 握手状态以及服务端是否支持h2决定是否将本次请求升级为http2

验证证书和签名

本小节仍然继续处理 Server 发送的消息,主要包含certificateRequestMsgTLS13certificateMsgTLS13certificateVerifyMsg,这三个消息均和证书相关。


首先,处理certificateRequestMsgTLS13消息,仅在双向认证时,服务端才发送此消息。在本阶段的处理逻辑也很简单,读取该消息并记录。


msg, err := c.readHandshake()certReq, ok := msg.(*certificateRequestMsgTLS13)if ok {  hs.transcript.Write(certReq.marshal())  hs.certReq = certReq  msg, err = c.readHandshake()}
复制代码


其次,处理certificateMsgTLS13消息,该消息中主要包含证书信息,Client 在获取到证书信息后要校验证书是否过期以及是否可信任。


if err := c.verifyServerCertificate(certMsg.certificate.Certificate); err != nil {  return err}
复制代码


c.verifyServerCertificate的内部逻辑如果各位读者有兴趣可以下载 Demo 调试一番,笔者在这里就不对该方法做深入的展开和分析了。


最后,处理certificateVerifyMsg消息。前面在处理certificateMsgTLS13时已经验证了证书可信任或者 Client 可以忽略不受信任的证书,但是 Client 仍无法确信提供这个证书的服务器是否持有该证书,而验证签名的意义就在于确保该服务确实持有该证书。


在 Server 发送certificateVerifyMsg消息时已经使用了证书对应的私钥对需要签名的数据进行签名,客户端利用证书的公钥解密该签名并和本地的待签名数据做对比以确保服务端确实持有该证书。


// 根据签名算法返回对应的算法类型和hash算法sigType, sigHash, err := typeAndHashFromSignatureScheme(certVerify.signatureAlgorithm)signed := signedMessage(sigHash, serverSignatureContext, hs.transcript)if err := verifyHandshakeSignature(sigType, c.peerCertificates[0].PublicKey,	sigHash, signed, certVerify.signature); err != nil {  c.sendAlert(alertDecryptError)  return errors.New("tls: invalid signature by the server certificate: " + err.Error())}
复制代码


typeAndHashFromSignatureScheme函数和signedMessage函数在前文已经提到过,因此不再做重复叙述。

verifyHandshakeSignature函数的内部实现涉及到非对称加密算法的加解密,因笔者的知识有限,确实无法做更进一步的分析,在这里给各位读者道个歉~


小结:在这一小节简单介绍了客户端证书的验证以及签名的验证。

处理 finishedMsg 并再次计算密钥

客户端对证书签名验证通过后,接下来还需要验证消息的完整性。


finished, ok := msg.(*finishedMsg)expectedMAC := hs.suite.finishedHash(c.in.trafficSecret, hs.transcript)if !hmac.Equal(expectedMAC, finished.verifyData) {  c.sendAlert(alertDecryptError)  return errors.New("tls: invalid server finished hash")}
复制代码


finishedHash方法说明请参考发送 finishedMsg 并再次计算密钥这一小节。


只有当客户端计算的expectedMACfinishedMsg.verifyData一致时才可继续后续操作,即客户端二次计算密钥。


hs.trafficSecret = hs.suite.deriveSecret(hs.masterSecret,	clientApplicationTrafficLabel, hs.transcript)serverSecret := hs.suite.deriveSecret(hs.masterSecret,	serverApplicationTrafficLabel, hs.transcript)c.in.setTrafficSecret(hs.suite, serverSecret)
复制代码


二次计算密钥时分别派生出trafficSecretserverSecret两个密钥。


需要注意的是,此时利用serverSecret生成的AEAD加密算法会用于握手结束后对收到的业务数据进行解密。


至此,Server 发送给客户端的消息已经全部处理完毕。


小结:本节主要介绍了客户端通过HMAC算法确保收到的消息未被篡改以及二次计算密钥。

Client 发送最后的消息

客户端已经验证了服务端消息的完整性,但是服务端还未验证客户端消息的完整性,因此客户端还需要发送最后一次数据给服务端。


首先判断是否需要发送证书给 Server:


if hs.certReq == nil {  return nil}certMsg := new(certificateMsgTLS13)// 此处省略代码certVerifyMsg := new(certificateVerifyMsg)certVerifyMsg.hasSignatureAlgorithm = true// 此处省略代码
复制代码


根据验证证书和签名这一小节的描述,如果服务端要求客户端发送证书则hs.certReq不为 nil。


certificateMsgTLS13的主体也是证书,该证书的来源为客户端tls.Config配置的证书,在本例中客户端配置证书逻辑如下:


tlsConf.NextProtos = append(tlsConf.NextProtos, "h2", "http/1.1")tlsConf.Certificates = make([]tls.Certificate, 1)if len(certFile) > 0 && len(keyFile) > 0 {  var err error  tlsConf.Certificates[0], err = tls.LoadX509KeyPair(certFile, keyFile)  if err != nil {    return nil, err  }}
复制代码


既然要发送证书给服务端,那么同服务端逻辑一样也需要发送certificateVerifyMsg提供消息签名的信息。客户端签名逻辑和服务端签名逻辑一致,因此笔者不再赘述。


最后,客户端需要发送finishedMsg给服务端:


finished := &finishedMsg{  verifyData: hs.suite.finishedHash(c.out.trafficSecret, hs.transcript),}hs.transcript.Write(finished.marshal())c.out.setTrafficSecret(hs.suite, hs.trafficSecret)
复制代码


需要注意的是hs.trafficSecret在第二次计算密钥时就已经被赋值,当finishedMsg发送后,利用hs.trafficSecret生成的AEAD加密算法会对客户端要发送的业务数据进行加密。


至此,客户端的握手流程全部完成。


小结


1、如果服务端要求客户端发送证书,则客户端会发送certificateMsgTLS13certificateVerifyMsg消息


2、发送finishedMsg消息并设置发送业务数据时的密钥信息。

Server 读 Client 最后的消息

首先,服务端在 TLS 握手的最后阶段,会先判断是否要求客户端发送证书,如果要求客户端发送证书则处理客户端发送的certificateMsgTLS13certificateVerifyMsg消息。服务端处理certificateMsgTLS13certificateVerifyMsg消息的逻辑和客户端处理这两个消息的逻辑类似。


其次,读取客户端发送的finishedMsg, 并验证消息的完整性,验证逻辑和客户端验证finishedMsg逻辑一致。


最后,设置服务端读取业务数据时的加密信息:


c.in.setTrafficSecret(hs.suite, hs.trafficSecret)
复制代码


hs.trafficSecret在服务端第二次计算加密信息时就已经赋值,当读完客户端发送的finishedMsg之后再执行此步骤是为了避免无法解密客户端发送的握手信息。


至此,服务端的握手流程全部完成。

握手完成之后

完成上述流程后,笔者还想试试看能不能从握手过程获取的密钥信息对业务数据进行解密。说干就干,下面是笔者在 TLS 握手完成之后用 Client 连接发送了一条消息的代码。


// main.go 握手完成之后,client发送了一条数据client.Write([]byte("点赞关注:新世界杂货铺"))
复制代码


下面是运行 Demo 后的输出截图:



图中红色箭头部分为在 Internet 中真实传输的数据,蓝色箭头部分为其解密结果。

一点感慨

关于 TLS 握手流程的文章笔者想写很久了,现在总算得偿所愿。笔者不敢保证把 TLS 握手过程的每一个细节都描述清楚,所以如果中间有什么问题还请各位读者及时指出,大家相互学习。


写到这里时笔者的内心也略有忐忑,毕竟这中间涉及了很多密码学相关的知识,而在笔者各种疯狂查资料期间发现国内具有权威性的文章还是太少。像ECDH之类的关键词在百度百科都没有收录,果然维基百科才是爸爸呀。


最后一点感概是关于 Go 中io.Reader io.Writer这两个接口的,不得不说这两个接口的设计真的很简单但是真的非常通用。笔者的 Demo 正是基于这两个接口实现,否则笔者的心愿很难完成。

挖坑

在上一篇文章中,笔者给了一条彩蛋——“下一期 TLS/SSL 握手流程敬请期待”。哇,这可真的是自己坑自己了,本篇文章未完成之前,笔者愣是断更了也没敢发别的文章。果然自己作的死,哭着也要作完。


有了前车之鉴,笔者决定以后不再放彩蛋,而是挖坑(填坑时间待定😊):本篇中主要介绍了 TLS1.3 的握手流程,那么 TLS1.2 也快了~


最后,衷心希望本文能够对各位读者有一定的帮助。


1. 写本文时, 笔者所用 go 版本为: go1.15.2

2. 文章中所用完整例子:https://github.com/Isites/go-coder/blob/master/http2/tls/main.go


发布于: 2020 年 11 月 28 日阅读数: 418
用户头像

Gopher指北

关注

还未添加个人签名 2020.09.15 加入

欢迎关注公众号:Gopher指北

评论

发布
暂无评论
码了2000多行代码就是为了讲清楚TLS握手流程