5

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

 3 years ago
source link: https://studygolang.com/articles/31839
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

前言

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

meymyqa.jpg!mobile

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

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

结论先行

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

HTTPS单向认证

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

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

M3UfIn3.jpg!mobile

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

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

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

aMJr6b.jpg!mobile

HTTPS双向认证

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

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

// sconf.ClientAuth = tls.RequireAndVerifyClientCert

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

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

i2aAZ3B.jpg!mobile

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

ZZnQbee.jpg!mobile

双向认证和单向认证相比,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):

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

vAzMray.jpg!mobile

图中篮框部分为当前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.TLS_AES_128_GCM_SHA256 ),详情见下图:

U7RzUjb.jpg!mobile

上图中的 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.id
hs.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结果:

AZVrua.jpg!mobile

上图中红框部分为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 在前面已经提到过是 SHA256 Hash算法的一种实现。下面我们逐步分析源码中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结果:

aYRNBjI.jpg!mobile

上图中 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结果:

MZJRbir.jpg!mobile

由上图知,签名类型为 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 = sig
hs.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 = selectedSuite
hs.transcript = hs.suite.hash.New()
hs.transcript.Write(hs.hello.marshal()) // hs.hello为clientHelloMsg
hs.transcript.Write(hs.serverHello.marshal())

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

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

sharedKey := hs.ecdheParams.SharedKey(hs.serverHello.serverShare.data)
earlySecret := hs.earlySecret
if !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派生出clientSecret
c.out.setTrafficSecret(hs.suite, clientSecret)
serverSecret := hs.suite.deriveSecret(handshakeSecret,
    serverHandshakeTrafficLabel, hs.transcript) // 通过handshakeSecret派生出serverSecret
c.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后的输出截图:

vaIBziR.jpg!mobile

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

一点感慨

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

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

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

挖坑

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

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

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

注:

  1. 写本文时, 笔者所用go版本为: go1.15.2
  2. 文章中所用完整例子: https://github.com/Isites/go-...

有疑问加站长微信联系(非本文作者)

eUjI7rn.png!mobile

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK