聊聊 Go 與 TLS 1-3

除了一些綜述類文章和譯文,我的文章選題多來源於實際工作和學習中遇到的問題。這次我們來聊聊近期遇到一個問題:如何加快基於 TLS 安全通信的海量連接的建連速度?

TLS(Transport Layer Security) 傳輸安全層的下面是 TCP 層,我們首先可能會想到的是優化內核有關 TCP 握手的相關參數來快速建立 TCP 連接,比如:

關於 Linux 內核參數調優,大家可以參考一下極客時間專欄《系統性能調優必知必會》[1]

此外爲了加速海量連接的建連速度,提高應用從內核 accept 連接隊列獲取連接的速度,我們還可以採用多線程 / 多 goroutine 併發 Listen 同一個端口並併發 Accept 的機制,如果你使用的是 Go 語言,可以看看 go-reuseport[2] 這個包。

說完 TCP 層,那麼 TLS 層是否有可優化的、對建連速度有影響的地方呢?有的,那就是使用 TLS 1.3 版本來加速握手過程,從而加快建連速度。TLS 1.3 是 2018 年發佈的新 TLS 標準,近 2-3 年纔開始得到主流語言、瀏覽器和 web 服務器的支持。那麼它與之前的 TLS 1.2 有何不同呢?Go 對 TLS 1.3 版本的支持程度如何?如何用 Go 編寫使用 TLS 1.3 的安全通信代碼?TLS 1.3 建連速度究竟比 TLS 1.2 快多少呢?

帶着這些問題,我們進入本篇正文部分!我們先來簡要看看 TLS 1.3 與 TLS 1.2 版本的差異。

1. TLS 1.3 與 TLS 1.2 的差異

TLS 是由互聯網工程任務組 (Internet Engineering Task Force, IETF[3]) 制定和發佈的、用於替代 SSL 的、基於加解密和簽名算法的安全連接協議標準,其演進過程如下圖:

其中 TLS 1.0 和 1.1 版本因不再安全,於 2020 年被作廢 [4],目前主流的版本,也是應用最爲廣泛的是 2008 年發佈的 TLS 1.2 版本 (使用佔比如下圖統計),而最新版本則是 2018 年正式發佈的 TLS 1.3[5],而 TLS 1.3 版本的發佈 [6] 也意味着 TLS 1.2 版本進入 “作廢期”,雖然實際中 TLS 1.2 的“下線” 還需要很長時間:

TLS 1.3 與 TLS 1.2 並不不兼容,在 TLS 1.3[7] 協議規範中,我們能看到列出的 TLS 1.3 相對於 TLS 1.2 的一些主要改動:

注:常見的 AEAD 算法包括:AES-128-GCM、AES-256-GCM、ChaCha20-IETF-Poly1305 等。在具備 AES 加速的 CPU(桌面,服務器)上,建議使用 AES-XXX-GCM 系列,移動設備建議使用 ChaCha20-IETF-Poly1305 系列。

注:前向安全 (Forward Secrecy) 是指的是長期使用的主密鑰泄漏不會導致過去的會話密鑰泄漏。前向安全能夠保護過去進行的通訊不受密碼或密鑰在未來暴露的威脅。如果系統具有前向安全性,就可以保證在主密鑰泄露時歷史通訊的安全,即使系統遭到主動攻擊也是如此。

注:當客戶端(例如瀏覽器)首次成功完成與服務器的 TLS 1.3 握手後,客戶端和服務器都可在本地存儲預共享的加密密鑰,這稱爲恢復主密鑰。如果客戶端稍後再次與服務器建立連接,則可以使用此恢復密鑰將其第一條消息中的加密應用程序數據發送到服務器,而無需第二次執行握手。0-RTT 模式有一個安全弱點。通過恢復模式發送數據不需要服務器的任何交互,這意味着攻擊者(一般是中間人 (middle-man))可以捕獲加密的 0-RTT 數據,然後將其重新發送到服務器,或重放(Replay)它們。解決此問題的方法是確保所有 0-RTT 請求都是冪等的。

在這些主要變化中,與初次建連速度有關的顯然是 TLS 1.3 握手機制的變化:從 2-RTT 縮短到 1-RTT(如上圖所示)。下面我們就用 Go 作爲示例來看看 TLS 1.3 相對於 TLS 1.2 在建連速度方面究竟有怎樣的提升。

2. Go 對 TLS 1.3 的支持

瞭解了這些後,我們來看一個簡單的使用 Go 和 TLS 1.3 版本的客戶端與服務端示例。

3. Go TLS 1.3 客戶端與服務端通信示例

這次我們不去參考 Go 標準庫 crypto/tls 包的樣例,我們玩把時髦兒的:通過 AI 輔助生成一套基於 TLS 的 client 與 server 端的通信代碼示例。ChatGPT[13] 不對大陸開放,我這裏用的是 AI 編程助手 (AICodeHelper)[14],下面是生成過程的截圖:

AICodeHelper 爲我們生成了大部分代碼,但是 server 端代碼有兩個問題:只能處理一個 client 端連接和沒有生成傳入 server 證書和私鑰的代碼段,我們基於上面的框架代碼做一下修改,得到我們的 server 和 client 端代碼:

server端代碼:

// https://github.com/bigwhite/experiments/blob/master/go-and-tls13/server.go
package main

import (
 "bufio"
 "crypto/tls"
 "fmt"
 "net"
)

func main() {
 cer, err := tls.LoadX509KeyPair("server.crt""server.key")
 if err != nil {
  fmt.Println(err)
  return
 }

 config := &tls.Config{Certificates: []tls.Certificate{cer}}
 ln, err := tls.Listen("tcp""localhost:8443", config)
 if err != nil {
  fmt.Println(err)
  return
 }
 defer ln.Close()

 for {
  conn, err := ln.Accept()
  if err != nil {
   fmt.Println(err)
   continue
  }
  go handleConnection(conn)
 }
}

func handleConnection(conn net.Conn) {
 defer conn.Close()
 r := bufio.NewReader(conn)
 for {
  msg, err := r.ReadString('\n')
  if err != nil {
   fmt.Println(err)
   return
  }

  println(msg)

  n, err := conn.Write([]byte("hello, world from server\n"))
  if err != nil {
   fmt.Println(n, err)
   return
  }
 }
}
// https://github.com/bigwhite/experiments/blob/master/go-and-tls13/client.go

package main

import (
 "crypto/tls"
 "log"
)

func main() {
 conf := &tls.Config{
  InsecureSkipVerify: true,
 }

 conn, err := tls.Dial("tcp""localhost:8443", conf)
 if err != nil {
  log.Println(err)
  return
 }
 defer conn.Close()

 n, err := conn.Write([]byte("hello, world from client\n"))
 if err != nil {
  log.Println(n, err)
  return
 }

 buf := make([]byte, 100)
 n, err = conn.Read(buf)
 if err != nil {
  log.Println(n, err)
  return
 }

 println(string(buf[:n]))
}

爲了方便期間,這裏使用自簽名證書,並且客戶端不對服務端的公鑰數字證書進行驗籤 (我們無需生成創建 CA 的相關 key 和證書),我們只需要使用下面命令生成一對 server.key 和 server.crt:

$openssl genrsa -out server.key 2048
Generating RSA private key, 2048 bit long modulus
..........................+++
................................+++
e is 65537 (0x10001)

$openssl req -new -x509 -sha256 -key server.key -out server.crt -days 3650
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) []:
State or Province Name (full name) []:
Locality Name (eg, city) []:
Organization Name (eg, company) []:
Organizational Unit Name (eg, section) []:
Common Name (eg, fully qualified host name) []:localhost
Email Address []:

關於非對稱加密和數字證書方面的詳細內容,可以參見我的《Go 語言精進之路》一書的第 51 條 “使用 net/http 包實現安全通信”[15]。

運行 Server 和 Client,這裏我使用的是 Go 1.19 版本 [16] 編譯器:

$go run server.go
hello, world from client

EOF

$go run client.go
hello, world from server

我們的示例已經可以正常運行了!那麼如何證明示例中的 client 與 server 間使用的是 1.3 版本的 TLS 連接呢?或者如何查看 client 與 server 間使用的是哪個 TLS 版本呢?

有小夥伴可能會說:用 wireshark 抓包看,這個可行,但是用 wireshark 抓 tls 包,尤其是 1.3 建連包比較費勁。我們有更簡單的方式,我們在開發環境可以通過修改標準庫來實現。我們繼續往下看。

4. server 和 client 端的 TLS 版本的選擇

TLS 握手過程由 client 端發起,從 client 的視角,當 client 收到 serverHello 的響應後便可得到決策後要使用的 TLS 版本。因此這裏我們改造一下 crypto/tls/handshake_client.go 的 clientHandshake 方法,在其實現中利用 fmt.Printf 輸出 TLS 連接相關的信息即可 (見下面代碼中 "====" 開頭的輸出內容):

// $GOROOT/src/crypto/tls/handshake_client.go

func (c *Conn) clientHandshake(ctx context.Context) (err error) {
    ... ...
    hello, ecdheParams, err := c.makeClientHello()
    if err != nil {
        return err
    }
    c.serverName = hello.serverName

    fmt.Printf("====client: supportedVersions: %x, cipherSuites: %x\n", hello.supportedVersions, hello.cipherSuites)
    
    ... ...

    msg, err := c.readHandshake()
    if err != nil {
        return err
    }

    serverHello, ok := msg.(*serverHelloMsg)
    if !ok {
        c.sendAlert(alertUnexpectedMessage)
        return unexpectedMessageError(serverHello, msg)
    }

    if err := c.pickTLSVersion(serverHello); err != nil {
        return err
    }

    ... ...

    if c.vers == VersionTLS13 {
        fmt.Printf("====client: choose tls 1.3, server use ciphersuite: [0x%x]\n", serverHello.cipherSuite)
        ... ...
        // In TLS 1.3, session tickets are delivered after the handshake.
        return hs.handshake()
    }
    fmt.Printf("====client: choose tls 1.2, server use ciphersuite: [0x%x]\n", serverHello.cipherSuite)

    hs := &clientHandshakeState{
        ... ...
    }
    
    if err := hs.handshake(); err != nil {
        return err
    }
    ... ...
}

修改完標準庫後,我們再來重新運行一下上面的 client.go:

$go run client.go
====client: supportedVersions: [304 303], cipherSuites: [c02b c02f c02c c030 cca9 cca8 c009 c013 c00a c014 9c 9d 2f 35 c012 a 1301 1302 1303]
====client: choose tls 1.3, server use ciphersuite: [0x1301]
hello, world from server

這裏我們看一下第一行輸出的內容,這裏輸出的是 client 端構建 clientHello 握手包中的內容,展示的是 client 端支持的 TLS 版本以及密碼套件 (cipher suites),我們看到客戶端支持 0x304、0x303 兩個 TLS 版本,這兩個數字與下面代碼中的常量分別對應:

// $GOROOT/src/crypto/tls/common.go
const (
    VersionTLS10 = 0x0301
    VersionTLS11 = 0x0302
    VersionTLS12 = 0x0303
    VersionTLS13 = 0x0304

    // Deprecated: SSLv3 is cryptographically broken, and is no longer
    // supported by this package. See golang.org/issue/32716.
    VersionSSL30 = 0x0300
)

而輸出的 cipherSuites 中包含的那些十六進制數則來自下面常量:

// $GOROOT/src/crypto/tls/cipher_suites.go
const (
    // TLS 1.0 - 1.2 cipher suites.
    TLS_RSA_WITH_RC4_128_SHA                      uint16 = 0x0005
    TLS_RSA_WITH_3DES_EDE_CBC_SHA                 uint16 = 0x000a
    TLS_RSA_WITH_AES_128_CBC_SHA                  uint16 = 0x002f
    TLS_RSA_WITH_AES_256_CBC_SHA                  uint16 = 0x0035
    TLS_RSA_WITH_AES_128_CBC_SHA256               uint16 = 0x003c
    TLS_RSA_WITH_AES_128_GCM_SHA256               uint16 = 0x009c
    TLS_RSA_WITH_AES_256_GCM_SHA384               uint16 = 0x009d
    TLS_ECDHE_ECDSA_WITH_RC4_128_SHA              uint16 = 0xc007
    TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA          uint16 = 0xc009
    TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA          uint16 = 0xc00a
    TLS_ECDHE_RSA_WITH_RC4_128_SHA                uint16 = 0xc011
    TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA           uint16 = 0xc012
    TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA            uint16 = 0xc013
    TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA            uint16 = 0xc014
    TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256       uint16 = 0xc023
    TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256         uint16 = 0xc027
    TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256         uint16 = 0xc02f
    TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256       uint16 = 0xc02b
    TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384         uint16 = 0xc030
    TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384       uint16 = 0xc02c
    TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256   uint16 = 0xcca8
    TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 uint16 = 0xcca9

    // TLS 1.3 cipher suites.
    TLS_AES_128_GCM_SHA256       uint16 = 0x1301
    TLS_AES_256_GCM_SHA384       uint16 = 0x1302
    TLS_CHACHA20_POLY1305_SHA256 uint16 = 0x1303
    ... ...
}

而從 client.go 運行結果中的第二行輸出可以看出:這次建連,雙方最終選擇了 TLS 1.3 版本和 TLS_AES_128_GCM_SHA256 這個 cipher suite。這與前面我們在回顧 Go 語言對 TLS 1.3 的支持歷史中的描述一致,TLS 1.3 是建連版本的默認選擇。

那麼我們是否可以選擇建連時使用的版本呢?當然可以,我們既可以在 server 端配置,也可以在客戶端配置。我們先來看看在 Server 端如何配置:

// https://github.com/bigwhite/experiments/blob/master/go-and-tls13/server_tls12.go

func main() {
 cer, err := tls.LoadX509KeyPair("server.crt""server.key")
 if err != nil {
  fmt.Println(err)
  return
 }

 config := &tls.Config{
  Certificates: []tls.Certificate{cer},
  MaxVersion:   tls.VersionTLS12,
 }
    ... ...
}

我們基於 server.go 創建了 server_tls12.go,在這個新源文件中,我們在 tls.Config 中增加一個配置 MaxVersion,並將其值設置爲 tls.VersionTLS12,其含義是其最高支持的 TLS 版本爲 TLS 1.2。這樣當我們使用 client.go 與基於 server_tls12.go 運行的服務端程序建連時,我們將得到下面輸出:

$go run client.go
====client: supportedVersions: [304 303], cipherSuites: [c02b c02f c02c c030 cca9 cca8 c009 c013 c00a c014 9c 9d 2f 35 c012 a 1301 1302 1303]
====client: choose tls 1.2, server use ciphersuite: [0xc02f]
hello, world from server

我們看到,交互的雙方最後選擇了 TLS 1.2 版本,使用的密碼套件爲 0xc02f,即 TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256。

同理,如果你想在 client 端配置最高支持 TLS 1.2 版本的話,也可以採用同樣的方式,大家可以看看本文對應代碼庫中的 client_tls12.go 這個源文件,這裏就不贅述了。

到這裏,一些小夥伴可能有了一個疑問:我們可以配置使用的 TLS 的版本,那麼對於 TLS 1.3 而言,我們是否可以配置要使用的密碼套件呢?答案是目前不可以,理由來自於 Config.CipherSuites 字段的註釋:“Note that TLS 1.3 ciphersuites are not configurable”:

// $GOROOT/src/crypto/tls/common.go

type Config struct {
    ... ...
    // CipherSuites is a list of enabled TLS 1.0–1.2 cipher suites. The order of
    // the list is ignored. Note that TLS 1.3 ciphersuites are not configurable.
    //
    // If CipherSuites is nil, a safe default list is used. The default cipher
    // suites might change over time.
    CipherSuites []uint16
    ... ...
}

tls 包會根據系統是否支持 AES 加速來選擇密碼套件,如果支持 AES 加速,就使用下面的 defaultCipherSuitesTLS13,這樣 AES 相關套件會被優先選擇,否則 defaultCipherSuitesTLS13NoAES 會被使用,TLS_CHACHA20_POLY1305_SHA256 會被優先選擇:

// $GOROOT/src/crypto/tls/cipher_suites.go

// defaultCipherSuitesTLS13 is also the preference order, since there are no
// disabled by default TLS 1.3 cipher suites. The same AES vs ChaCha20 logic as
// cipherSuitesPreferenceOrder applies.
var defaultCipherSuitesTLS13 = []uint16{
    TLS_AES_128_GCM_SHA256,
    TLS_AES_256_GCM_SHA384,
    TLS_CHACHA20_POLY1305_SHA256,
}   
    
var defaultCipherSuitesTLS13NoAES = []uint16{
    TLS_CHACHA20_POLY1305_SHA256,
    TLS_AES_128_GCM_SHA256,
    TLS_AES_256_GCM_SHA384,
}

注:joe shaw 曾寫過一篇文章 “Abusing go:linkname to customize TLS 1.3 cipher suites”[17],文中描述了一種通過 go:linkname 定製 TLS 1.3 密碼套件的方法,有興趣的小夥伴們可以去閱讀一下。

5. 建連速度 benchmark

最後我們再來看看相較於 TLS 1.2,TLS 1.3 的建連速度究竟快了多少。考慮到兩個版本在 RTT 數量上的差異,即網絡延遲對建連速度影響較大,我特意選擇了一個 ping 在 20-30ms 的網絡。我們爲 TLS 1.2 和 TLS 1.3 分別建立 Benchmark Test:

// https://github.com/bigwhite/experiments/blob/master/go-and-tls13/benchmark/benchmark_test.go

package main

import (
 "crypto/tls"
 "testing"
)

func tls12_dial() error {
 conf := &tls.Config{
  InsecureSkipVerify: true,
  MaxVersion:         tls.VersionTLS12,
 }

 conn, err := tls.Dial("tcp""192.168.11.10:8443", conf)
 if err != nil {
  return err
 }
 conn.Close()
 return nil
}

func tls13_dial() error {
 conf := &tls.Config{
  InsecureSkipVerify: true,
 }

 conn, err := tls.Dial("tcp""192.168.11.10:8443", conf)
 if err != nil {
  return err
 }
 conn.Close()
 return nil
}

func BenchmarkTls13(b *testing.B) {
 b.ReportAllocs()

 for i := 0; i < b.N; i++ {
  err := tls13_dial()
  if err != nil {
   panic(err)
  }
 }
}

func BenchmarkTls12(b *testing.B) {
 b.ReportAllocs()

 for i := 0; i < b.N; i++ {
  err := tls12_dial()
  if err != nil {
   panic(err)
  }
 }
}

server 部署在 192.168.11.10 上,針對每個 benchmark test,我們給予 10s 鐘的測試時間,下面是運行結果:

$go test -benchtime 10s -bench .
goos: linux
goarch: amd64
pkg: demo
cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz
BenchmarkTls13-8        216   56036809 ns/op   47966 B/op     608 allocs/op
BenchmarkTls12-8        145   82395933 ns/op   26655 B/op     283 allocs/op
PASS
ok  demo 37.959s

我們看到相對與 TLS 1.2,TLS 1.3 建連速度的確更快些。不過從內存分配的情況來看,Go TLS 1.3 的實現似乎更復雜一些。

6. 參考資料

本文涉及的源碼可以在這裏 [18] 下載。


Gopher Daily(Gopher 每日新聞) 歸檔倉庫 - https://github.com/bigwhite/gopherdaily

我的聯繫方式:

商務合作方式:撰稿、出書、培訓、在線課程、合夥創業、諮詢、廣告合作。

參考資料

[1] 

《系統性能調優必知必會》: https://time.geekbang.org/column/intro/100051201?code=SmhvMrwy3mAvQJMyZSNlldumxpWLW4D7BnWjawY8M%2FQ%3D&source=app_share

[2] 

go-reuseport: https://github.com/libp2p/go-reuseport

[3] 

IETF: https://www.ietf.org/

[4] 

2020 年被作廢: https://www.ietf.org/rfc/rfc8996.html

[5] 

2018 年正式發佈的 TLS 1.3: https://www.ietf.org/blog/tls13/

[6] 

TLS 1.3 版本的發佈: https://datatracker.ietf.org/doc/rfc8446/

[7] 

TLS 1.3: https://datatracker.ietf.org/doc/rfc8446/

[8] 

Go 1.12 版本: https://tonybai.com/2019/03/02/some-changes-in-go-1-12

[9] 

Go 1.13 版本: https://tonybai.com/2019/10/27/some-changes-in-go-1-13/

[10] 

Go 1.14 版本: https://tonybai.com/2020/03/08/some-changes-in-go-1-14/

[11] 

Go 1.16 版本: https://tonybai.com/2021/02/25/some-changes-in-go-1-16

[12] 

Go 1.18 版本: https://tonybai.com/2022/04/20/some-changes-in-go-1-18

[13] 

ChatGPT: https://openai.com/blog/chatgpt/

[14] 

AI 編程助手 (AICodeHelper): https://www.aicodehelper.com/

[15] 

《Go 語言精進之路》一書的第 51 條 “使用 net/http 包實現安全通信”: https://book.douban.com/subject/35720729/

[16] 

Go 1.19 版本: https://tonybai.com/2022/08/22/some-changes-in-go-1-19

[17] 

“Abusing go:linkname to customize TLS 1.3 cipher suites”: https://www.joeshaw.org/abusing-go-linkname-to-customize-tls13-cipher-suites/

[18] 

這裏: https://github.com/bigwhite/experiments/blob/master/go-and-tls13

[19] 

“Gopher 部落” 知識星球: https://wx.zsxq.com/dweb2/index/group/51284458844544

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/NtBzM8l54HRVSpapKR_0sg