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

新世界杂货铺 · · 877 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

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

前言

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

image

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

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

结论先行

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

HTTPS单向认证

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

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

image

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

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

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

image

HTTPS双向认证

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

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

// sconf.ClientAuth = tls.RequireAndVerifyClientCert

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

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

image

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

image

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

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

image

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

image

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

image

上图中红框部分为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结果:

image

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

image

由上图知,签名类型为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后的输出截图:

image

图中红色箭头部分为在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-...

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

本文来自:Segmentfault

感谢作者:新世界杂货铺

查看原文:码了2000多行代码就是为了讲清楚TLS握手流程

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

877 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传