Skip to content

密码学基础

简介

本文从零开始讲解 REALITY 和 Xray-core 涉及的全部密码学概念,目标是让仅有基本编程知识的读者能够理解后续协议文档中的密码学操作。

本文不追求密码学理论的数学严谨性,而是聚焦于"这个原语做什么、为什么需要它、在 REALITY 中怎么用"。

本文回答

  • 对称加密和非对称加密的区别,各自解决什么问题
  • ECDH 密钥交换是如何在不安全的通道上协商出共享密钥的
  • Ed25519 数字签名如何证明"这条消息确实是我发的"
  • HMAC 和普通哈希的区别
  • HKDF 为什么需要从一个密钥派生出另一个密钥
  • AEAD 如何同时提供加密和防篡改

本文在项目中的位置

本文是理解所有协议文档的根基。建议在阅读任何协议分析文档之前先读完本文。

适合读者

密码学零基础的开发者。

前置知识

Go 语言基础(能读懂 []bytefuncinterface{})。

文章理解难度

入门。每个概念配有 Go 伪代码示例和 REALITY 中的实际用途。

重点

  • ECDH 的"双方各自生成密钥对,交换公钥后算出相同共享秘密"
  • Ed25519 的"私钥签名,公钥验证"
  • HMAC 的"带密钥的哈希,既能验证完整性又能验证身份"
  • AEAD 的"加密 + 防篡改一体"
  • 这些原语在 REALITY 握手中的组合方式

难点

  • ECDH 的椭圆曲线数学(本文用类比替代数学推导)
  • HKDF 的 Extract + Expand 两阶段设计

1. 为什么代理工具需要密码学

代理工具的核心任务是:在不安全的网络上,让客户端和服务端安全地交换数据。"安全"包含三个要求:

要求含义如果没有会怎样
机密性(Confidentiality)第三方看不懂通信内容审查方直接读取代理流量,识别协议并阻断
完整性(Integrity)第三方无法篡改通信内容审查方修改数据包,注入伪造内容
认证(Authentication)确认通信对方是自己人审查方冒充服务端(MITM),截获所有流量

密码学的各种原语就是为实现这三个目标而设计的工具。


2. 哈希函数

2.1 概念

哈希函数接收任意长度的数据,输出固定长度的"摘要"(digest)。

输入:"Hello, World!" → SHA256 → 输出:dffd6021bb2bd5b0af676290809ec3a5...
输入:"Hello, World."  → SHA256 → 输出:8c718269aa8039e7f5d8efd3a9b7c0e7...(完全不同)

关键特性:

  • 确定性:相同输入永远产生相同输出
  • 单向性:从输出无法反推输入
  • 雪崩效应:输入改动一个比特,输出半数以上的比特变化

2.2 在 REALITY 中的用途

SHA256 和 SHA512 在 REALITY 中用于:

  • HMAC 的底层哈希(SHA512)
  • HKDF 的底层哈希(SHA256)
  • 证书指纹计算

2.3 Go 代码示例

go
import "crypto/sha256"

data := []byte("REALITY authentication key derivation")
hash := sha256.Sum256(data)
// hash 是 [32]byte,固定 32 字节输出

3. 对称加密

3.1 概念

对称加密使用同一把密钥进行加密和解密。

加密:明文 + 密钥 → 密文
解密:密文 + 密钥 → 明文

类比:一个带锁的箱子。上锁和开锁用同一把钥匙。有钥匙的人可以读写箱子内容,没钥匙的人只能看到箱子外观。

3.2 在 REALITY 中的用途

  • 客户端在 Session ID 中嵌入的 ShortId 使用 AES-GCM 加密
  • Shadowsocks 和 VMess 的载荷加密也使用对称加密

3.3 Go 代码示例

go
import "crypto/aes"
import "crypto/cipher"

// key 必须是 16 (AES-128) 或 32 (AES-256) 字节
block, _ := aes.NewCipher(key)
// 使用 GCM 模式(同时提供加密和完整性)
gcm, _ := cipher.NewGCM(block)
// 加密
ciphertext := gcm.Seal(nil, nonce, plaintext, additionalData)
// 解密
plaintext, _ := gcm.Open(nil, nonce, ciphertext, additionalData)

4. 非对称加密与密钥交换

4.1 为什么需要非对称加密

对称加密的问题是:双方必须先共享同一把密钥。在不安全的网络上,如何安全地共享密钥?

非对称加密解决了这个问题:使用一对数学上关联的密钥——公钥和私钥

  • 私钥:必须保密,只有持有者知道
  • 公钥:可以公开,任何人都能获取
  • 数学关系:用公钥加密的数据只能用对应私钥解密(反之亦然)

4.2 ECDH 密钥交换

REALITY 不使用"用公钥加密数据"的模式,而是使用 ECDH(Elliptic Curve Diffie-Hellman)密钥交换

核心思想:双方各自生成自己的密钥对,交换公钥后,通过数学运算各自算出相同的共享秘密。网络上的观察者即使看到双方的公钥,也无法算出这个共享秘密。

步骤1:客户端生成密钥对
  clientPrivateKey = 随机数(保密)
  clientPublicKey  = 椭圆曲线基点 × clientPrivateKey(可公开)

步骤2:服务端生成密钥对
  serverPrivateKey = 随机数(保密)
  serverPublicKey  = 椭圆曲线基点 × serverPrivateKey(可公开)

步骤3:交换公钥(通过网络明文传输)

步骤4:各自计算共享秘密
  客户端:sharedSecret = serverPublicKey × clientPrivateKey
  服务端:sharedSecret = clientPublicKey × serverPrivateKey
  // 数学上保证两者结果相同!

类比:两个人各自想一种颜色(私钥),各自混合到一桶公共的白色颜料中(公钥 = 私钥 + 白色),交换混合后的颜料桶。然后各自再把自己的私钥颜色加入对方的桶中。两人最终得到相同的最终颜色(共享秘密)。旁观者只看到中间交换的颜料桶,无法分离出最终颜色。

4.3 X25519

REALITY 使用 X25519 作为 ECDH 的具体实现。X25519 使用 Curve25519 椭圆曲线:

  • 私钥:32 字节随机数
  • 公钥:32 字节(从私钥计算得出)
  • 共享秘密:32 字节

4.4 在 REALITY 中的用途

这是 REALITY 认证体系的

  1. 服务端持有长期 X25519 私钥(配置中的 privateKey
  2. 客户端持有对应的公钥(配置中的 publicKey/password
  3. 客户端在 TLS 握手中生成临时的 ECDH 密钥对(用于 TLS 1.3 的密钥共享)
  4. 客户端用自己的临时私钥 + 服务端的长期公钥 → 算出 SharedSecret
  5. SharedSecret 经 HKDF 派生为 AuthKey
  6. AuthKey 是整个认证体系的根密钥

为什么这样设计:即使 TLS 1.3 层的 ECDH 被破解(如量子计算机),只要 X25519 没有被破解,AuthKey 仍然是安全的。这种"双层 ECDH"提供了深度防御。

4.5 Go 代码示例

go
import "crypto/ecdh"

// 服务端生成长期密钥对
serverPrivateKey, _ := ecdh.X25519().GenerateKey(rand.Reader)
serverPublicKey := serverPrivateKey.PublicKey()
// serverPublicKey.Bytes() → 配置中的 publicKey,发送给客户端

// 客户端使用服务端公钥 + 自己的临时私钥
clientEphemeralPrivateKey, _ := ecdh.X25519().GenerateKey(rand.Reader)
sharedSecret, _ := clientEphemeralPrivateKey.ECDH(serverPublicKey)
// sharedSecret 是 32 字节的共享秘密

5. 数字签名(Ed25519)

5.1 概念

数字签名解决的问题是:如何证明某条消息确实是由特定的人发出的,且未被篡改

签名:消息 + 私钥 → 签名值
验证:消息 + 签名值 + 公钥 → 有效 / 无效

与加密的区别:

  • 加密是"不让别人看"(机密性)
  • 签名是"证明是我发的"(认证 + 完整性)

5.2 Ed25519

Ed25519 是一种基于 Edwards 曲线的数字签名算法:

  • 私钥:32 字节随机种子
  • 公钥:32 字节(从私钥派生)
  • 签名:64 字节
  • 速度快、签名短、安全性高

5.3 在 REALITY 中的用途

Ed25519 是 REALITY 临时证书的密钥算法:

  • 服务端认证通过后,生成临时 Ed25519 密钥对
  • 使用 Ed25519 私钥签发 X.509 证书
  • 关键改造:证书的签名字段不被用于正常的 Ed25519 签名,而是被替换为 HMAC-SHA512(AuthKey, Ed25519公钥)

这保证了只有持有 AuthKey 的人(客户端和服务端)能验证这个证书。

5.4 Go 代码示例

go
import "crypto/ed25519"

// 生成密钥对
publicKey, privateKey, _ := ed25519.GenerateKey(rand.Reader)

// 签名
message := []byte("TLS handshake transcript")
signature := ed25519.Sign(privateKey, message) // 64 字节

// 验证
valid := ed25519.Verify(publicKey, message, signature) // true/false

6. HMAC(哈希消息认证码)

6.1 概念

HMAC(Hash-based Message Authentication Code)是带密钥的哈希

普通哈希:SHA256(消息) → 摘要
          任何人可以计算,只能验证完整性

HMAC:    HMAC-SHA256(密钥, 消息) → 认证码
          只有持有密钥的人能计算,同时验证完整性和身份

6.2 HMAC 与普通哈希的关键区别

假设服务端想确认收到的消息是客户端发来的:

用普通哈希(不安全)

客户端发送:消息 + SHA256(消息)
攻击者截获,修改消息,重新计算 SHA256(新消息),发送修改后的消息 + 新哈希
服务端验证:SHA256(收到的消息) == 收到的哈希 → 通过!

用 HMAC(安全)

客户端发送:消息 + HMAC-SHA256(共享密钥, 消息)
攻击者截获,修改消息,但不知道共享密钥,无法计算正确的 HMAC
服务端验证:HMAC-SHA256(共享密钥, 收到的消息) == 收到的HMAC → 失败!

6.3 在 REALITY 中的用途

核心用法:验证临时证书

go
// 服务端生成临时证书时
h := hmac.New(sha512.New, authKey)
h.Write(ed25519PublicKey)
certificate.Signature = h.Sum(nil)  // 用 HMAC 值替代正常的 CA 签名

// 客户端验证证书时
h := hmac.New(sha512.New, authKey)
h.Write(cert.PublicKey.(ed25519.PublicKey))
if bytes.Equal(h.Sum(nil), cert.Signature) {
    // 证书签名匹配 → 对方确实持有 AuthKey → 是 REALITY 服务端
}

HMAC 同时实现了两个目标:

  1. 认证:只有持有 AuthKey 的 REALITY 服务端能生成有效的证书签名
  2. 完整性:证书内容(公钥)被篡改会导致 HMAC 不匹配

6.4 Go 代码示例

go
import "crypto/hmac"
import "crypto/sha512"

key := []byte("shared-secret-key")
message := []byte("data-to-authenticate")

mac := hmac.New(sha512.New, key)
mac.Write(message)
result := mac.Sum(nil) // 64 字节的 HMAC-SHA512 结果

// 验证
mac2 := hmac.New(sha512.New, key)
mac2.Write(message)
valid := hmac.Equal(mac.Sum(nil), result) // constant-time comparison

7. HKDF(基于 HMAC 的密钥派生函数)

7.1 为什么需要密钥派生

密钥交换产生的 SharedSecret 是"原材料",不应该直接作为加密密钥使用。原因:

  1. 长度问题:SharedSecret 是 32 字节,但可能需要多种不同长度的密钥
  2. 安全隔离:不同用途应该使用不同的密钥。如果一个密钥泄露,不应该影响其他用途
  3. 随机性增强:ECDH 产生的 SharedSecret 可能不是均匀分布的,需要通过 HKDF 的 Extract 步骤"压平"

7.2 HKDF 的两阶段

HKDF 由两个阶段组成:

阶段1:Extract(提取)
  PRK = HMAC-Hash(salt, IKM)
  - salt:可选,用于增加随机性。无 salt 时使用全零
  - IKM:Input Keying Material,即输入密钥材料(如 SharedSecret)
  - PRK:Pseudo-Random Key,作为下一阶段的输入

阶段2:Expand(扩展)
  OKM = 从 PRK 派生任意长度的输出
  - 每次派生时附加不同的 info 参数
  - info:上下文信息字符串,确保不同用途产生不同密钥

7.3 在 REALITY 中的用途

go
// REALITY 中 AuthKey 的派生
sharedSecret, _ := ecdhe.ECDH(serverPublicKey)  // IKM: ECDH 共享秘密
// HKDF:
//   Hash = SHA256
//   IKM = sharedSecret
//   Salt = ClientHello.Random[0:20](TLS 客户端随机数的前 20 字节)
//   Info = "REALITY"
//   Output Length = 32 字节
hkdf.New(sha256.New, sharedSecret, clientHelloRandom[:20], []byte("REALITY")).Read(authKey)

每个参数的含义:

  • IKM = SharedSecret:ECDH 的产物,双方共享的 32 字节秘密
  • Salt = ClientHello.Random[:20]:让每次连接产生不同的 AuthKey,即使 SharedSecret 相同(防止重放)
  • Info = "REALITY":域分离(domain separation),确保这个密钥只能用于 REALITY 用途,不会被误用到其他场景

7.4 Go 代码示例

go
import "crypto/sha256"
import "golang.org/x/crypto/hkdf"

ikm := []byte("shared-secret-from-ecdh")       // 输入密钥材料
salt := []byte("random-salt-value")             // 可选盐值
info := []byte("REALITY-auth-key-derivation")   // 上下文信息

derivedKey := make([]byte, 32)                  // 输出 32 字节
hkdf.New(sha256.New, ikm, salt, info).Read(derivedKey)
// derivedKey 现在可以安全地作为 AuthKey 使用

8. AEAD(带关联数据的认证加密)

8.1 概念

AEAD = Authenticated Encryption with Associated Data

传统的加密只提供机密性。接收方解密后不知道密文是否被篡改。AEAD 同时提供:

  • 加密(机密性):第三方看不懂
  • 认证(完整性 + 身份):任何篡改都会被检测到
  • 关联数据(Associated Data):部分数据不加密但参与认证。保证"这个密文是为这个特定上下文创建的"

8.2 AES-GCM

AES-GCM 是最常用的 AEAD 算法:

加密:密文 + 认证标签 = AES-GCM(密钥, Nonce, 明文, 关联数据)
解密:明文 = AES-GCM(密钥, Nonce, 密文, 认证标签, 关联数据)
      如果认证标签不匹配,解密失败 → 数据被篡改

参数说明:

  • 密钥:16 (AES-128) 或 32 (AES-256) 字节
  • Nonce:12 字节,每次加密必须使用不同的 Nonce(同一密钥下,Nonce 重复使用会严重破坏安全性)
  • 关联数据:不加密,但参与认证计算。如果关联数据被篡改,认证也会失败

8.3 在 REALITY 中的用途

REALITY 使用 AES-GCM 加密 Session ID 中嵌入的认证信息:

go
aead := crypto.NewAesGcm(authKey)
// 加密 ShortId 到 Session ID 中
//   Plaintext:  SessionId[0:16](版本号 + 时间戳 + ShortId)
//   Nonce:      ClientHello.Random[20:32](TLS 随机数的后 12 字节)
//   Additional: ClientHello.Raw(完整的 ClientHello 原始字节)
aead.Seal(SessionId[:0], ClientHello.Random[20:], SessionId[:16], ClientHello.Raw)

这个设计保证了:

  1. 机密性:审查方即使看到 Session ID,也无法解密出 ShortId
  2. 完整性:任何人修改 Session ID 或 ClientHello,认证会失败
  3. 绑定到上下文:关联数据(ClientHello.Raw)确保这个加密的 Session ID 只能用于这个特定的 ClientHello,不能被复制到另一个连接重放

8.4 Go 代码示例

go
import "crypto/aes"
import "crypto/cipher"

key := make([]byte, 32) // AES-256
nonce := make([]byte, 12)
additionalData := []byte("contextual-binding-data")
plaintext := []byte("secret-message")

block, _ := aes.NewCipher(key)
gcm, _ := cipher.NewGCM(block)

// 加密:ciphertext 的前 16 字节是认证标签
ciphertext := gcm.Seal(nil, nonce, plaintext, additionalData)

// 解密:如果认证失败,返回 error
decrypted, err := gcm.Open(nil, nonce, ciphertext, additionalData)
if err != nil {
    // 数据被篡改或密钥不匹配
}

9. 原语在 REALITY 中的协作

以上每种密码学原语在 REALITY 协议中都有具体应用。完整的握手中原语协作全景见 REALITY 协议详解 的握手机制分析。 | Ed25519 | 为临时证书提供标准的密钥格式 |


10. 常见误解澄清

"加密了就是安全的"

加密只解决了机密性。没有认证的加密是不安全的:攻击者虽然看不到内容,但可以修改密文。AEAD 同时提供了加密和认证。

"哈希了就能验证完整性"

哈希可以验证完整性,但不能验证身份。任何能计算哈希的人都能伪造。HMAC 通过引入密钥解决了这个问题。

"密钥越长越安全"

在达到算法设计的安全强度后,更长的密钥不会增加安全性。例如 AES-256 的 256 位密钥在经典计算机上已经足够安全,更长的密钥只会增加计算开销。

"ECDH 的共享秘密可以直接用作加密密钥"

不应该。ECDH 输出的共享秘密不是均匀分布的随机数——它的数学结构可能导致某些比特的熵不足。HKDF 的 Extract 步骤专门解决这个问题。


外部参考资料

资料:RFC 5869 - HMAC-based Extract-and-Expand Key Derivation Function (HKDF) 类型:IETF 标准 链接:https://www.rfc-editor.org/rfc/rfc5869 可信度:A

资料:RFC 7748 - Elliptic Curves for Security (X25519) 类型:IETF 标准 链接:https://www.rfc-editor.org/rfc/rfc7748 可信度:A

资料:RFC 8032 - Edwards-Curve Digital Signature Algorithm (Ed25519) 类型:IETF 标准 链接:https://www.rfc-editor.org/rfc/rfc8032 可信度:A

资料:RFC 5116 - An Interface and Algorithms for Authenticated Encryption (AEAD) 类型:IETF 标准 链接:https://www.rfc-editor.org/rfc/rfc5116 可信度:A

资料:A Graduate Course in Applied Cryptography (Boneh & Shoup) 类型:教材 链接:https://toc.cryptobook.us/ 可信度:A,免费在线教材

读者自检

读完本文后应能回答:

  • ECDH 是如何让双方在不安全通道上建立共享秘密的
  • 为什么 HKDF 不从 ECDH 输出直接作为加密密钥
  • HMAC 和普通哈希的本质区别
  • AEAD 的"关联数据"参数的作用
  • REALITY 握手中五个密码学原语各自解决什么问题

下一步阅读