Skip to content

REALITY 客户端握手:源码逐步走读

简介

本文以 UClient 函数为核心,逐步拆解 REALITY 客户端 TLS 握手的每一行关键代码。读完本文后应能完全理解客户端从"建立 TCP 连接"到"确认对方是 REALITY 服务端"期间发生了什么。

本文回答

  • UClient 函数的完整执行流程
  • 每一步操作的对象、输入和输出
  • uTLS 如何被集成到 REALITY 流程中
  • AuthKey 的派生过程逐行分析
  • Session ID 加密嵌入的字节操作
  • VerifyPeerCertificate 的三层验证如何被触发

本文在项目中的位置

本文是 REALITY 协议详解 的源码级补充,聚焦于客户端实现的逐行分析。

适合读者

需要修改 REALITY 客户端代码、调试握手问题、或深入理解实现细节的开发者。

前置知识

文章理解难度

较难。需要同时理解 Go 并发、TLS 二进制协议和密码学 API。

重点

  • 阶段 3:AuthKey 派生(ECDH + HKDF)
  • 阶段 4:Session ID 加密嵌入(AEAD)
  • 阶段 5:VerifyPeerCertificate 的 HMAC 验证

上级文档和相关文档

主要证据

xray-core/transport/internet/reality/reality.go 行 117-277(UClient 函数完整源码)。


源码总览

UClient 函数约 160 行,按逻辑可分为七个阶段:

阶段行范围做什么
1119-121初始化 UConn 结构体
2122-137配置 uTLS 参数,创建 uTLS 连接
3138-169构造 ClientHello,执行 ECDH + HKDF 派生 AuthKey
4170-176AEAD 加密 Session ID,写回 Raw 字节
5177-179发起 TLS 握手
676-115VerifyPeerCertificate 回调被触发
7183-276根据验证结果决定连接行为

阶段 1:初始化 UConn 结构体

源码位置:reality.go 行 117-121

go
func UClient(c net.Conn, config *Config, ctx context.Context, dest net.Destination) (net.Conn, error) {
    localAddr := c.LocalAddr().String()
    uConn := &UConn{
        Config: config,
    }

输入

  • c:底层 TCP 连接(net.Conn),已通过三次握手建立
  • config:REALITY 客户端配置(transport/internet/reality/config.go 解析自 JSON)
  • dest:目标地址(VLESS 入站解析出的目标 IP/域名和端口)

输出:已初始化但尚未握手的 UConn 指针。

UConn 结构体(行 57-63):

go
type UConn struct {
    *utls.UConn          // 嵌入 uTLS 的 TLS 连接
    Config     *Config   // REALITY 客户端配置
    ServerName string    // 目标网站 SNI
    AuthKey    []byte    // 派生出的认证密钥(32 字节)
    Verified   bool      // 握手后是否通过 REALITY 验证
}

阶段 2:配置 uTLS 参数

源码位置:reality.go 行 122-137

go
    utlsConfig := &utls.Config{
        VerifyPeerCertificate:  uConn.VerifyPeerCertificate,   // ← 注册证书验证回调
        ServerName:             config.ServerName,               // ← SNI
        InsecureSkipVerify:     true,                            // ← 跳过标准 CA 链验证
        SessionTicketsDisabled: true,                            // ← 禁用会话票据(避免指纹)
        KeyLogWriter:           KeyLogWriterFromConfig(config),  // ← 可选:导出密钥日志
    }

关键配置项解释

  1. VerifyPeerCertificate:注册 UConn 的方法作为证书回调。TLS 握手完成后,uTLS 会用收到的证书调用这个函数。这是 REALITY 认证的核心钩子。

  2. InsecureSkipVerify: true:因为 REALITY 不使用标准 CA 证书链,必须跳过标准验证。安全性由 VerifyPeerCertificate 回调保证。

  3. SessionTicketsDisabled: true:禁用 TLS 会话票据。会话票据可能引入额外的指纹特征。

  4. ServerName:如果配置中为空的,使用目标地址的域名:

go
    if utlsConfig.ServerName == "" {
        utlsConfig.ServerName = dest.Address.String()
    }
    uConn.ServerName = utlsConfig.ServerName

获取 uTLS 指纹

源码位置:reality.go 行 133-137

go
    fingerprint := tls.GetFingerprint(config.Fingerprint)
    if fingerprint == nil {
        return nil, errors.New("REALITY: failed to get fingerprint").AtError()
    }
    uConn.UConn = utls.UClient(c, utlsConfig, *fingerprint)

tls.GetFingerprint 定义在 xray-core/transport/internet/tls/tls.go,它将配置中的字符串(如 "chrome")映射为 uTLS 的 ClientHelloID

  • "chrome"utls.HelloChrome_Auto
  • "firefox"utls.HelloFirefox_Auto
  • "safari"utls.HelloSafari_Auto
  • "ios"utls.HelloIOS_Auto
  • "randomized"utls.HelloRandomized

utls.UClient(c, utlsConfig, *fingerprint) 创建一个 uTLS 连接。此时尚未建构 ClientHello——uTLS 会在 Handshake() 被调用时才按照指纹模板生成 ClientHello。


阶段 3:构建 ClientHello 并派生 AuthKey

这是整个 REALITY 客户端实现中最关键的代码段

源码位置:reality.go 行 138-169

3.1 预构建握手状态

go
    {
        uConn.BuildHandshakeState()
        hello := uConn.HandshakeState.Hello

BuildHandshakeState() 按 uTLS 指纹模板预生成 ClientHello。此时 ClientHello 尚未发送——但它的所有字段(密码套件、扩展、随机数等)都已经按照 Chrome/Firefox 的格式生成完毕。

hello 的类型是 uTLS 库内部表示 ClientHello 的结构体。它的 Raw 字段是序列化后的完整 ClientHello 字节切片。

3.2 写入 Session ID 认证信息

go
        hello.SessionId = make([]byte, 32)
        copy(hello.Raw[39:], hello.SessionId) // Session ID 在 Raw 字节中的偏移

第一行:创建全新的 32 字节切片。这会断开 hello.SessionIdhello.Raw 内部子切片的关联。

第二行:将空白的 Session ID 复制到 Raw 字节的偏移 39 处——相当于在完整的 ClientHello 字节序列中预留 32 字节的认证空间。

为什么是偏移 39?回顾 ClientHello 序列化格式:

  • 0:握手类型(1 byte)
  • 1-3:消息长度(3 bytes)
  • 4-5:客户端版本(2 bytes)
  • 6-37:随机数(32 bytes)
  • 38:Session ID 长度(1 byte,值为 32)
  • 39-70:Session ID 数据(32 bytes) ← 这里
go
        hello.SessionId[0] = core.Version_x       // Xray 主版本号
        hello.SessionId[1] = core.Version_y       // Xray 次版本号
        hello.SessionId[2] = core.Version_z       // Xray 修订版本号
        hello.SessionId[3] = 0                     // 保留字节
        binary.BigEndian.PutUint32(hello.SessionId[4:], uint32(time.Now().Unix()))
        copy(hello.SessionId[8:], config.ShortId)

此时 Session ID 的前 16 字节结构:

SessionId[0:16] 的明文内容:
[0]    = 主版本号
[1]    = 次版本号
[2]    = 修订号
[3]    = 保留(0)
[4-7]  = Unix 时间戳(BigEndian uint32)
[8-15] = ShortId(从配置读取)

3.3 ECDH 密钥交换

go
        publicKey, err := ecdh.X25519().NewPublicKey(config.PublicKey)
        if err != nil {
            return nil, errors.New("REALITY: publicKey == nil")
        }

config.PublicKey 是 32 字节的服务端 X25519 公钥。ecdh.X25519().NewPublicKey() 将其解析为 Go 标准库的 *ecdh.PublicKey 类型。

go
        ecdhe := uConn.HandshakeState.State13.KeyShareKeys.Ecdhe
        if ecdhe == nil {
            ecdhe = uConn.HandshakeState.State13.KeyShareKeys.MlkemEcdhe
        }

从 uTLS 的握手状态中获取客户端临时 ECDH 私钥。优先级:

  1. X25519(标准 ECDH)
  2. X25519MLKEM768(混合后量子密钥交换)

ecdhe 的类型是 *ecdh.PrivateKey——客户端的临时私钥。

go
        if ecdhe == nil {
            return nil, errors.New("Current fingerprint ", uConn.ClientHelloID.Client,
                uConn.ClientHelloID.Version, " does not support TLS 1.3, ...")
        }

如果两个私钥都为 nil,说明 uTLS 指纹不支持 TLS 1.3——REALITY 必须依赖 TLS 1.3。

go
        uConn.AuthKey, _ = ecdhe.ECDH(publicKey)
        if uConn.AuthKey == nil {
            return nil, errors.New("REALITY: SharedKey == nil")
        }

这行代码是第一个关键密码学操作:用客户端临时私钥和服务端长期公钥做 ECDH,算出 32 字节的 SharedSecret

3.4 HKDF 密钥派生

go
        if _, err := hkdf.New(sha256.New, uConn.AuthKey, hello.Random[:20],
            []byte("REALITY")).Read(uConn.AuthKey); err != nil {
            return nil, err
        }

参数分析:

  • Hash = SHA256
  • IKM = uConn.AuthKey(ECDH 的 SharedSecret,32 字节)
  • Salt = hello.Random[:20](ClientHello 中的客户端随机数前 20 字节)
  • Info = "REALITY"(域分离标签)
  • 输出 = 32 字节,直接写回 uConn.AuthKey

经过 HKDF 后,uConn.AuthKey 从"ECDH 原始输出"变成了"安全派生密钥"。这个密钥是后续所有认证操作的基础:

  • 用它对 Session ID 做 AEAD 加密(阶段 4)
  • 用它的 HMAC 验证临时证书(阶段 6)

阶段 4:AEAD 加密 Session ID

源码位置:reality.go 行 170-176

go
        aead := crypto.NewAesGcm(uConn.AuthKey)
        if config.Show {
            fmt.Printf("REALITY localAddr: %v\tuConn.AuthKey[:16]: %v\tAEAD: %T\n",
                localAddr, uConn.AuthKey[:16], aead)
        }
        aead.Seal(hello.SessionId[:0], hello.Random[20:], hello.SessionId[:16], hello.Raw)
        copy(hello.Raw[39:], hello.SessionId)

逐行分析:

第一行:用 AuthKey 创建 AES-GCM 加密器。crypto.NewAesGcm 判断 AuthKey 的长度自动选择 AES-128-GCM 或 AES-256-GCM(AuthKey 是 32 字节 → AES-256)。

第四行 aead.Seal:这是最关键的一行。参数:

  • dst = hello.SessionId[:0]:输出目标,长度 0 表示"复用底层数组但从起始位置写入"
  • nonce = hello.Random[20:]:ClientHello 随机数的第 20-31 字节(12 字节,正好是 GCM 标准 Nonce 长度)
  • plaintext = hello.SessionId[:16]:要加密的明文(版本号 + 时间戳 + ShortId)
  • additionalData = hello.Raw:整个 ClientHello 的原始字节作为关联数据

Seal 的输出 = 明文被加密后的密文 + 16 字节的 GCM 认证标签。结果写到 hello.SessionId 的前 32 字节中(密文 16 字节 + 认证标签 16 字节 = 恰好 32 字节)。

为什么 Nonce 选自 ClientHello.Random

ClientHello.Random 是 TLS 协议在每次连接时随机生成的 32 字节。它的前 20 字节用作 HKDF 的 Salt,后 12 字节用作 AEAD 的 Nonce。这样确保了:

  1. 每次连接的 Salt 和 Nonce 都不同(由 TLS 随机数保证)
  2. Salt 和 Nonce 不需要额外传输(它们已经在 ClientHello 中,服务端收到 ClientHello 后也能读取)

为什么关联数据是整个 ClientHello.Raw

这确保了加密的 Session ID 与这个具体的 ClientHello 绑定。如果审查方试图将 Session ID 复制到另一个连接中重放,关联数据不同会导致 AEAD 认证失败。

第五行:加密后的 32 字节 Session ID 写回 hello.Raw 偏移 39 处。现在 hello.Raw 就是最终的、包含 REALITY 认证信息的 ClientHello 字节序列。


阶段 5:发起 TLS 握手

源码位置:reality.go 行 177-179

go
    if err := uConn.HandshakeContext(ctx); err != nil {
        return nil, err
    }

这一行触发 uTLS 执行完整的 TLS 1.3 客户端握手:

  1. uTLS 将修改后的 ClientHello(包含加密的 Session ID)发送到服务端
  2. 服务端处理 ClientHello,返回 ServerHello + 后续加密消息
  3. uTLS 接收并处理服务端响应,包括证书
  4. uTLS 在收到 Certificate 消息后,调用注册的 VerifyPeerCertificate 回调

阶段 6:VerifyPeerCertificate 回调

源码位置:reality.go 行 76-115

TLS 握手过程中,uTLS 在收到服务端证书后自动调用 VerifyPeerCertificate

6.1 读取对端证书

go
func (c *UConn) VerifyPeerCertificate(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
    // 使用 unsafe 指针读取 uTLS 内部已解析的证书对象
    p, _ := reflect.TypeOf(c.Conn).Elem().FieldByName("peerCertificates")
    certs := *(*([]*x509.Certificate))(unsafe.Pointer(uintptr(unsafe.Pointer(c.Conn)) + p.Offset))

rawCerts 是 DER 编码的原始证书字节。certs 是 uTLS 内部已经解析好的 *x509.Certificate 对象数组。之所以用 unsafe 读取内部字段而不是自己解析 rawCerts,是因为 uTLS 内部已经做了解析,直接复用避免重复工作。

6.2 第一层:Ed25519 HMAC 签名验证

go
    if pub, ok := certs[0].PublicKey.(ed25519.PublicKey); ok {
        h := hmac.New(sha512, c.AuthKey)
        h.Write(pub)
        if bytes.Equal(h.Sum(nil), certs[0].Signature) {

检查证书的公钥是否为 Ed25519 类型:

  • :用 AuthKey 计算 HMAC-SHA512(AuthKey, 公钥),与证书的 Signature 字段比较
    • 匹配:这是 REALITY 服务端签发的临时证书 → 继续验证
    • 不匹配:虽然公钥是 Ed25519,但签名不是预期的 HMAC → 进入第三层
  • 不是:直接进入第三层

6.3 第二层(可选):ML-DSA-65 后量子签名

go
            if len(c.Config.Mldsa65Verify) > 0 {
                if len(certs[0].Extensions) > 0 {
                    h.Write(c.HandshakeState.Hello.Raw)
                    h.Write(c.HandshakeState.ServerHello.Raw)
                    verify, _ := mldsa65.Scheme().UnmarshalBinaryPublicKey(c.Config.Mldsa65Verify)
                    if mldsa65.Verify(verify.(*mldsa65.PublicKey), h.Sum(nil), nil, certs[0].Extensions[0].Value) {
                        c.Verified = true
                        return nil
                    }
                }
            } else {
                c.Verified = true
                return nil
            }

如果配置了 mldsa65Verify(ML-DSA-65 验证公钥),额外验证证书扩展中的后量子签名:

  1. 将 ClientHello 和 ServerHello 的 Raw 字节也加入 HMAC 计算
  2. 用 ML-DSA-65 公钥验证扩展字段中的签名

如果未配置 mldsa65Verify(大多数情况),直接设置 c.Verified = true 并返回 nil。

6.4 第三层:标准 x509 验证

go
    opts := x509.VerifyOptions{
        DNSName:       c.ServerName,
        Intermediates: x509.NewCertPool(),
    }
    for _, cert := range certs[1:] {
        opts.Intermediates.AddCert(cert)
    }
    if _, err := certs[0].Verify(opts); err != nil {
        return err  // 证书链无效 → TLS alert 断开
    }
    return nil  // 证书链有效但非临时证书 → c.Verified 仍为 false

退回到标准 x509 证书链验证:

  • 验证证书链是否可信(从叶证书到根 CA)
  • 验证证书的 DNSName 是否匹配 SNI
  • 通过 → 这是目标网站的真证书(非临时证书),返回 nil 但 c.Verified 为 false
  • 失败 → 返回 error,TLS 握手失败,连接断开

阶段 7:根据验证结果决定连接行为

源码位置:reality.go 行 180-276

7.1 正常情况:Verified = true

go
    if config.Show {
        fmt.Printf("REALITY localAddr: %v\tuConn.Verified: %v\n", localAddr, uConn.Verified)
    }
    if !uConn.Verified {
        // ... 爬虫模式 ...
    }
    return uConn, nil  // ← 正常返回:Verified = true

Verified = true 时,UClient 直接返回 uConn。上层(VLESS Inbound)将在这个 TLS 连接上继续代理协议的通信。

7.2 非正常情况:Verified = false(爬虫模式)

Verified = false(收到的是真证书而不是临时证书):

go
        errors.LogError(ctx, "REALITY: received real certificate (potential MITM or redirection)")
        go func() {
            client := &http.Client{
                Transport: &http2.Transport{
                    DialTLSContext: func(ctx context.Context, network, addr string, cfg *gotls.Config) (net.Conn, error) {
                        return uConn, nil  // 复用已有 TLS 连接
                    },
                },
            }
  1. 记录日志:"收到了真证书(可能是 MITM 或重定向)"
  2. 启动爬虫 goroutine(异步,不阻塞当前连接)
  3. 爬虫使用 http2.Transport —— 因为 target 网站必须支持 H2
  4. DialTLSContext 直接返回已有的 uConn,不建立新连接

爬虫逻辑的详细分析见 REALITY 协议详解 的"爬虫模式"章节。

爬虫启动后,主 goroutine 休眠一段时间后返回错误:

go
            time.Sleep(time.Duration(crypto.RandBetween(config.SpiderY[8], config.SpiderY[9])) * time.Millisecond)
            return nil, errors.New("REALITY: processed invalid connection").AtWarning()

上层的 VLESS Inbound 收到错误后,不会在此连接上发送代理数据。保证了代理数据不会泄露到非 REALITY 服务端


完整执行流程图


读者的操作清单

读完本文后,如果要修改 REALITY 客户端行为,以下是最可能的修改点:

想实现的效果修改位置注意事项
添加新的认证方式阶段 3:AuthKey 派生前需与服务端同步修改
修改 Session ID 中的信息格式阶段 3.2:Session ID 赋值注意不超过 32 字节限制
替换加密算法阶段 4:crypto.NewAesGcm需确保 uTLS 的内置密码套件列表兼容
修改临时证书验证逻辑阶段 6.2:HMAC 验证需与服务端 generate_cert.go 同步
修改爬虫行为阶段 7.2:爬虫 goroutineSpiderY 参数含义见 config.proto

外部参考资料

资料:REALITY 客户端实现源码 类型:源码 链接:xray-core/transport/internet/reality/reality.go 可信度:A

资料:uTLS 库文档 类型:开源项目 链接:https://github.com/refraction-networking/utls 可信度:A

下一步阅读