动机#
我想写一个 UDP 加密聊天,最主要的原因是学习 P2P 网络的通讯方式——觉得很好玩,没有中心化的服务器,点到点直接聊天。所以我给自己定下了几条规则:
- 第一版一定是我自己写
- 不用任何框架,从零开始
- 把 AI 的使用降到最低(查 API、后期审查)
于是这一切就开始了……
问题:一切从提问开始#
所有设计都是从一个个问题开始的。我反复问自己:我究竟要解决什么问题?哪些问题是我马上会遇到的?
我的第一个想法是:在一个不需要中心服务器的通讯里,对方都不知道彼此的存在——双方是怎么发现对方的(握手)?对方怎么知道一个包是在握手还是在发消息?消息怎么加密?
我接下来做的第一件事就是去查 UDP 的格式定义。我发现 UDP 本身自带 Source Port 和 Destination Port——也就是说,我不需要自己在包里携带节点的地址和目的地,这对我是个好消息。
那么,如何区分握手包和消息包?参考 SOCKS5,可以用不同的开头字节码来决定:比如 0x00 表示握手,0x01 表示发消息。
加密方案我选了 X25519 的 ECDH 密钥交换 + AES-GCM。这里有个容易混淆的点:ECDH 是一个密钥协商协议,建立在非对称密码学之上——双方各持私钥、交换公钥,各自算出同一个共享密钥;这个共享密钥才用来做对称加密(AES-GCM)。如果每一次握手都生成全新的临时密钥对、用完即弃,那么即使某次会话的密钥泄露,也影响不到其他会话,这就是(完美)前向安全的保障。
所以最简单的消息类型可以这么定义:
握手:[0x00] [公钥]
消息:[0x01] [nonce + ciphertext]这是一个最小化的消息定义,但可以完全区分聊天和握手。
其中,握手部分是和 TCP 模式区别最大的地方:TCP 会默认一个 Server 负责被动收发、一个 Client 负责主动获取信息,所以是两套代码;而 UDP 是无连接(connectionless)的,不需要先建立连接再发消息,而且双方都需要双向交流的能力,一套代码就够了。我觉得这是最值得在写之前就想清楚的东西——一种模式转换。
于是我就写出了最简单的 Version 1:
var keyReady = false // 握手是否完成
var sharedSecret []byte // 共享密钥(ECDH 结果)
switch buf[0] {
case 0x00: // 握手:对方公钥
peerpk, _ := ecdh.X25519().NewPublicKey(buf[1:n])
sharedSecret, _ = privatekey.ECDH(peerpk)
keyReady = true
log.Printf("secret key generated: %s\n", sharedSecret[:5])
// 收到就回发自己的公钥 —— 对称握手的关键
var msg []byte
msg = append([]byte{0x00}, publickey...)
_, err = conn.WriteToUDP(msg, addr)
case 0x01: // 聊天:解密
if !keyReady {
continue
}
ciphertext := buf[1:n]
decrypted, err := decrypt(sharedSecret, ciphertext)
if err != nil {
log.Printf("Error decrypting ciphertext: %s\n", err)
return
}
fmt.Printf("decrypted ciphertext: %s\n", decrypted)
}然后,因为双方每次启动都不会是同时启动,所以我加入了一个定时发送公钥的发送器:
// goroutine 1:每 2 秒重发公钥,直到握手完成
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
go func() {
for range ticker.C {
if !keyReady {
var msg []byte
msg = append([]byte{0x00}, publickey...)
_, err = conn.WriteToUDP(msg, &net.UDPAddr{Port: *peer})
if err != nil {
log.Printf("Error writing to UDP: %s\n", err)
return
}
}
}
}()然后是读键盘输入的 goroutine,负责把输入加密发出去:
go func() {
for {
message, err := bufio.NewReader(os.Stdin).ReadString('\n')
message = strings.TrimSpace(message)
if err != nil {
return
}
encrypted, err := encrypt(sharedSecret, []byte(message))
if err != nil {
log.Printf("Error encrypting message: %s\n", err)
}
var msg []byte
msg = append([]byte{0x01}, encrypted...)
_, err = conn.WriteToUDP(msg, &net.UDPAddr{Port: *peer})
}
}()踩坑实录#
读 stdin 的 goroutine 一开始放在了握手逻辑的后面(没有开 goroutine)。
ReadString('\n')是阻塞的,程序卡在等待输入,后面的收包、回发握手全都执行不了——必须先输入一条消息,程序才"动"一下。后来才把它挪进独立 goroutine。stdin goroutine 没检查
keyReady→ 握手完成前打字 →invalid key size 0。握手死锁:一开始没考虑双方错峰启动的情况。比如 A 先启动、B 后启动,B 启动前 A 已经给 B 发了握手包,但 B 收不到;B 启动后给 A 发自己的公钥,A 收到后生成了密钥、标记
keyReady = true——但 B 那边什么都没收到,只能继续发握手包,A 持续忽略 → 死锁。这就是为什么后来我加了"只要收到对方公钥就回发一次":只有这时候才能确定双方都在(当然,前提是这个回发的包不能丢)。
怎么证明它工作#
开两个终端,一个 9001 一个 9002(9001 先启动),然后:
sudo tcpdump -i lo0 -n udp port 9001 or udp port 9002就会看到这样的效果:
20:08:00.872195 IP 127.0.0.1.9001 > 127.0.0.1.9002: UDP, length 33
20:08:00.873572 IP 127.0.0.1.9002 > 127.0.0.1.9001: UDP, length 33
20:08:11.706885 IP 127.0.0.1.9001 > 127.0.0.1.9002: UDP, length 33
2026/08/28 20:08:11 Read [1 37 231 82 123 59 134 153 44 210 43 164 173 129 176 170 171 115 196 251 20 149 107 28 42 146 178 0 81 12 213 93] bytes from UDP: 127.0.0.1:9001
message from 127.0.0.1:9001 : hi最终跑通了最小版本。但现在有几个问题需要解决:
keyReady、sharedSecret的并发读写是噩梦 —— 需要锁- 只能和指定的一个人握手 —— 需要 map
所以下一篇我会分享我是如何给这个进行升级的。
本文对应的代码在 M1ngdaXie/mini-P2P,下一篇继续。