网络安全

Go 实现仿 Telegram 端到端加密:从 DH 密钥交换到 AES-GCM

2026-08-19 #Telegram#端到端加密#Go#AES-GCM#Diffie-Hellman#加密#MTProto

上一篇我们讲清了 Telegram 的秘密:私密聊天的服务器「只做看不懂的邮差」。
这一篇,我们用 Go 的标准库,亲手把这个「邮差」敲出来——让服务器眼睁睁看着密文进进出出,却永远解不开一个字。


引言:把原理变成代码

上一篇文章《Telegram 软件架构与端到端加密拆解》里,我们得到了一个核心结论:

端到端加密的本质,是「把密钥放在两端设备上,让服务器只转发密文」。

但「原理懂了」和「代码会写」之间,隔着一段距离。这篇实战,我们就用 Go 的标准库,零第三方依赖,实现一个「够演示原理即可」的仿 Telegram 端到端加密系统。它忠实还原 Telegram Secret Chats 的核心思想:

  1. DH-2048 密钥交换:两端协商共享密钥,服务器拿不到私钥,算不出密钥。
  2. SHA-256 派生 AES 密钥:把 DH 共享密钥哈希成定长 256 位密钥。
  3. AES-GCM 加解密:密文带认证标签,防篡改。
  4. 密钥指纹比对:两端展示指纹,肉眼防中间人。
  5. 服务端密文中转:服务端只注册/路由/转发,从不接触明文。

免责声明:本文代码仅为教学演示,未实现 MTProto 的 salt 防重放、会话密钥轮换、IGE 模式、auth_key 等生产级机制,不可用于真实保密通信。


一、整体架构:服务器是「看不懂的邮差」

我们设计一个最简模型:一个服务端 + 两个客户端(Alice 和 Bob)。

1
2
3
4
5
Alice ──注册──▶                ◀──注册── Bob
Alice ──公钥 A──▶ 服务端 ◀──公钥 B── Bob
Alice ──密文──▶ (只转发密文) ◀──密文── Bob
│
只有密文,没有私钥

服务端手里永远只有三样东西:公钥 A、公钥 B、密文。没有私钥,它就算不出共享密钥,也就解不开任何消息——这正是 Telegram 服务器面对 Secret Chats 时的处境。


二、协议帧:两端共享的「信封」

先定义一个 JSON 协议帧,作为客户端与服务端通信的契约:

1
2
3
4
5
6
7
8
// 协议帧:客户端 <-> 服务端(encoding/json 序列化)
type Frame struct {
Type string `json:"type"` // register | pubkey | message
From string `json:"from,omitempty"` // 发送方
To string `json:"to,omitempty"` // 接收方
PubKey string `json:"pubkey,omitempty"` // DH 公钥(hex)
Ciphertext string `json:"ciphertext,omitempty"` // AES-GCM 密文(base64)
}

三种类型各司其职:

Type 含义 携带字段
register 用户注册上线 from
pubkey 交换 DH 公钥 from / to / pubkey
message 发送加密消息 from / to / ciphertext

三、服务端:只做三件事,绝不碰明文

服务端的逻辑极其简单——注册、路由、转发。它把密文原封不动地传给目标用户,从头到尾不接触任何密钥:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 服务端:注册 / 路由 / 密文中转(只存密文,不接触明文与密钥)
type Server struct {
mu sync.Mutex
users map[string]net.Conn // 用户名 -> 连接
}

func (s *Server) handle(conn net.Conn) {
dec := json.NewDecoder(conn)
for {
var f Frame
if err := dec.Decode(&f); err != nil {
return
}
switch f.Type {
case "register":
s.mu.Lock()
s.users[f.From] = conn
s.mu.Unlock()
log.Printf("[server] %s 上线", f.From)

case "pubkey", "message":
// 关键:服务端只做转发,绝不解密、绝不存储密钥
s.mu.Lock()
to := s.users[f.To]
s.mu.Unlock()
if to != nil {
json.NewEncoder(to).Encode(f) // 原样转发
}
}
}
}

注意 case "pubkey", "message" 这两行的处理:服务端拿到帧后,只根据 f.To 找到目标连接,然后把整个帧原样转发。它不解析 pubkey 的含义,更不解 ciphertext——因为它根本没有解密所需的密钥。

这就是「看不懂的邮差」:邮差全程捧着你的信,但他既没有开信刀,也没有密码本。


四、客户端:DH 密钥交换的完整流程

客户端的核心是 Diffie-Hellman 密钥交换。它的精妙之处在于:双方在公开信道上交换公钥,却各自算出同一个共享密钥,而偷听者永远算不出。

1. 使用 RFC 3526 固定安全素数

DH 需要一个「够大」的素数 p 和生成元 g。我们不自己生成质数(有陷阱且慢),直接用 RFC 3526 MODP Group 14 的 2048 位安全素数:

1
2
3
4
5
6
// RFC 3526 MODP Group 14 的 2048 位安全素数(十六进制,已省略中段)
var p, _ = new(big.Int).SetString(
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1"+
// …… 中间省略,完整 512 个十六进制字符 ……
"FFFFFFFFFFFFFFFF", 16)
var g = big.NewInt(2) // 生成元

完整的素数 p 是 512 个十六进制字符(2048 位),可在 RFC 3526 第 3 节「2048-bit MODP Group with 256-bit Prime Order Subgroup」查到。这里为排版省略中段。

2. 生成密钥对

1
2
3
4
5
6
// 生成私钥 a(随机 256 位大整数),计算公钥 A = g^a mod p
func genKey() (*big.Int, *big.Int) {
a, _ := rand.Int(rand.Reader, new(big.Int).Sub(p, big.NewInt(2))) // 私钥 a
A := new(big.Int).Exp(g, a, p) // 公钥 A = g^a mod p
return a, A
}

rand.Int 从加密安全的随机源 crypto/rand 取一个小于 p-2 的大整数作为私钥 a,再用模幂运算算出公钥 A = g^a mod p。私钥永远留在本地,只把公钥发出去。

3. 交换公钥,计算共享密钥

1
2
3
4
5
// 双方交换公钥后,各自计算共享密钥
// Alice:s = B^a mod p;Bob:s = A^b mod p(两者相等)
func sharedSecret(peerPub *big.Int, priv *big.Int) *big.Int {
return new(big.Int).Exp(peerPub, priv, p)
}

数学上 (g^b)^a = (g^a)^b = g^(ab) mod p,所以 Alice 用「Bob 的公钥 + 自己的私钥」、Bob 用「Alice 的公钥 + 自己的私钥」,算出的 s 完全一致。而偷听者手里只有 g、p、A、B 四个公开值,从它们反推私钥 a 或 b 是离散对数难题(2048 位下在计算上不可行)。


五、密钥派生与加密:SHA-256 + AES-GCM

1. 派生 AES 密钥

DH 共享密钥 s 是一个 2048 位的大整数,直接当 AES 密钥太长且分布不均。我们用 SHA-256 把它哈希成定长 256 位:

1
2
3
4
5
// AES 密钥 key = SHA-256(共享密钥 s),得到均匀分布的 256 位密钥
func deriveKey(s *big.Int) []byte {
h := sha256.Sum256(s.Bytes())
return h[:]
}

2. AES-GCM 加密

AES-GCM 是一种「认证加密」,密文里自带认证标签,任何篡改都会被检测到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 加密:AES-GCM Seal(返回 nonce + 密文 + 认证标签)
func encrypt(key, plaintext []byte) ([]byte, error) {
block, _ := aes.NewCipher(key)
gcm, _ := cipher.NewGCM(block)
nonce := make([]byte, gcm.NonceSize())
rand.Read(nonce) // 随机 nonce,保证同一条明文两次加密结果不同
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}

// 解密:AES-GCM Open(校验认证标签,篡改即报错)
func decrypt(key, ciphertext []byte) ([]byte, error) {
block, _ := aes.NewCipher(key)
gcm, _ := cipher.NewGCM(block)
nonce := ciphertext[:gcm.NonceSize()]
return gcm.Open(nil, nonce, ciphertext[gcm.NonceSize():], nil)
}

注意两点:

  • nonce 用 crypto/rand 随机生成,同一明文加密两次会得到不同密文,防止流量分析。
  • Open 一旦发现认证标签不匹配,会直接返回错误——篡改密文的人无法无声地替换消息。

六、密钥指纹:肉眼防中间人

端到端加密最大的天敌是中间人攻击(MITM)。我们的防线是密钥指纹——两端各自计算公钥指纹,通过可信渠道(打电话/见面)肉眼比对:

1
2
3
4
5
6
7
8
9
// 密钥指纹:SHA-256(公钥) 前 8 字节,格式化为 AA:BB:CC:...:HH
func fingerprint(pub *big.Int) string {
h := sha256.Sum256(pub.Bytes())
parts := make([]string, 8)
for i := 0; i < 8; i++ {
parts[i] = strings.ToUpper(hex.EncodeToString(h[i : i+1]))
}
return strings.Join(parts, ":")
}

运行后,Alice 和 Bob 的终端会各自打印出同一串指纹:

1
2
[alice] 密钥指纹: 1A:2B:3C:4D:5E:6F:70:81
[bob] 密钥指纹: 1A:2B:3C:4D:5E:6F:70:81

两边一致 → 没有中间人。若有人在中途冒充 Bob 和 Alice 分别完成 DH 交换,两端算出的公钥指纹必然不同——这正是 Telegram 私密聊天让你「比对图形指纹」的原理,我们用 8 字节十六进制串做了同样的实现。


七、客户端主流程串起来

把上面的函数组装成客户端的完整流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 客户端:注册 -> DH 交换 -> 加解密收发
func main() {
conn, _ := net.Dial("tcp", "localhost:9000")
enc := json.NewEncoder(conn)
dec := json.NewDecoder(conn)

// 1. 注册
enc.Encode(Frame{Type: "register", From: "alice"})

// 2. 生成密钥对,发送公钥
priv, pub := genKey()
enc.Encode(Frame{Type: "pubkey", From: "alice", To: "bob",
PubKey: fmt.Sprintf("%x", pub.Bytes())})

// 3. 接收对方公钥,计算共享密钥与 AES 密钥
var f Frame
dec.Decode(&f)
peerPub, _ := new(big.Int).SetString(f.PubKey, 16)
key := deriveKey(sharedSecret(peerPub, priv))

// 4. 展示密钥指纹,肉眼比对防中间人
fmt.Println("密钥指纹:", fingerprint(pub))

// 5. 加密发送 / 接收解密
ct, _ := encrypt(key, []byte("你好,Bob!这是端到端加密消息"))
enc.Encode(Frame{Type: "message", From: "alice", To: "bob",
Ciphertext: base64.StdEncoding.EncodeToString(ct)})

dec.Decode(&f)
raw, _ := base64.StdEncoding.DecodeString(f.Ciphertext)
pt, _ := decrypt(key, raw)
fmt.Println("收到:", string(pt)) // 输出:收到: ...
}

完整跑起来,你需要三个终端:

1
2
3
4
5
6
7
8
# 终端 1:启动服务端
go run server.go # 监听 localhost:9000

# 终端 2:Alice
go run client.go alice bob

# 终端 3:Bob
go run client.go bob alice

最终效果:Alice 和 Bob 各自看到相同的密钥指纹,互发的密文只有对方能解开,而服务端的日志里,只会出现一行行它自己都读不懂的 ciphertext。


八、局限与边界:为什么它还不能当「真 Telegram」

演示归演示,我们必须诚实标注这套代码的边界:

我们实现了 MTProto / Telegram 还做了什么
DH-2048 密钥交换 auth_key 长期复用 + 会话密钥轮换
SHA-256 派生密钥 salt + 会话号参与的 msg_key 派生
AES-GCM 认证加密 AES-256-IGE 模式
密钥指纹比对 图形化指纹 + 二维码
服务端密文中转 防重放(msg_id)、前向保密(Perfect Forward Secrecy)

最后一句:
我们写出的,是一把「能演示原理的锁」,而不是「能守住的保险柜」。
真正的端到端加密产品,安全性不只在加密算法里,更在前向保密、防重放、密钥轮换这些「被演示代码省略掉的细节」里。
记住:你省掉的每一行「麻烦」,都可能成为攻击者的一扇后门。


参考


相关文章

本文是「端到端加密与安全通信」系列的一员:


评论
分享

评论