在 阮一峰周刊 369 期 中这样一篇文章 破解加拿大航空的飞机上网 当时看了之后觉得很有意思,但没有额外作什么思考。过了两周的某一天早上在上班路上突然想到了这个文章,因为是上班路上有点无聊,所以就思考了一下这个问题: 如果在某一网络中,只有 DNS 协议的请求能成功,那么在这个网络中的人们能否自由彼此通信?
先说结论,肯定是可以的,只需要单建一个特殊的 DNS 服务器就可以了。
架构概览
┌──────────┐ DNS TXT Query ┌──────────┐ DNS TXT Query ┌──────────┐ │ Client A ├─────────────────────>│ Server │<─────────────────────┤ Client B │ │ │<─────────────────────┤ (盲转发) ├─────────────────────>│ │ └──────────┘ DNS TXT Reply └──────────┘ DNS TXT Reply └──────────┘ │ │ │ E2E Key = HMAC-SHA256("dnsay-key-v1", group) │ └───────────────────────────── 共享密钥 ──────────────────────────────┘
|
- Client: 负责 E2E 加密/解密、DNS 编码、TUI 交互、昵称注册
- Server: DNS 服务器,负责会话管理、消息中继、昵称去重,不接触加密密钥
密钥与路由分离
group 名同时用于路由和加密,但通过不同的 HMAC 路径派生,实现域分离
| 用途 |
派生方式 |
长度 |
可见性 |
| 路由 ID |
HMAC-SHA256("dnsay-route", group)[:8] |
8 字节 |
DNS 流量中可见 (base32 编码) |
| 加密密钥 |
HMAC-SHA256("dnsay-key-v1", group) |
32 字节 |
仅客户端持有,不传输 |
安全性: DNS 观察者只能看到路由 ID (8 字节哈希) ,无法反推 group 名,也无法推导加密密钥。
DNS 查询名格式 (QName)
所有通信通过 DNS TXT 查询实现。查询名 (qname) 的标签格式
<RouteID>.<SID>.<Dir>.<Seq-Total>.<QueryNonce>[.<Payload>...].
|
| 标签位置 |
内容 |
编码 |
说明 |
| 0 |
路由 ID |
base32, 无 padding, 小写 |
8 字节 HMAC 哈希,用于会话匹配 |
| 1 |
会话 ID |
base32, 无 padding, 小写 |
8 字节随机值,客户端启动时生成 |
| 2 |
方向 |
明文 |
u/p/j/n/l |
| 3 |
序号-总数 |
明文 |
上传 seq-total (如 0-3) ,其他方向为 0 |
| 4 |
查询 Nonce |
base32, 无 padding, 小写 |
12 字节随机值,防 DNS 缓存命中 |
| 5+ |
载荷 |
base32, 无 padding, 每 30 字符一个标签 |
仅 u 和 j 方向需要 |
重要: 载荷使用 base32 而非 base64。原因是 base64 的字母表包含 - 而 DNS 标签 不允许以 - 开头 RFC 1035
方向 (Dir) 说明
| 方向 |
含义 |
载荷 |
服务端响应 |
u |
上传消息 |
E2E 密文分块 |
ok (已注册) / unreg (未注册) |
p |
轮询消息 |
无 |
base32 编码的多消息帧 |
j |
注册昵称 |
base32(昵称字节) |
ok (成功) / dup (重名) / bad (空昵称) |
l |
离开 |
无 |
ok |
E2E 加密流程
加密 (发送方)
plaintext = nickname + '\x00' + message_text │ ▼ msgNonce = random(12 bytes) │ ▼ e2e_ct = AES-256-GCM.Encrypt( key = HMAC-SHA256("dnsay-key-v1", group), nonce = msgNonce, data = plaintext, aad = group ) │ ▼ wire_data = msgNonce(12) || e2e_ct(len + 16)
|
解密 (接收方)
wire_data │ ├─ msgNonce = wire_data[:12] ├─ e2e_ct = wire_data[12:] │ ▼ plaintext = AES-256-GCM.Decrypt( key = HMAC-SHA256("dnsay-key-v1", group), nonce = msgNonce, data = e2e_ct, aad = group ) │ ├─ nickname = plaintext[:null_byte_index] └─ message = plaintext[null_byte_index+1:]
|
加密参数
| 参数 |
值 |
| 算法 |
AES-256-GCM |
| 密钥长度 |
32 字节 |
| Nonce 长度 |
12 字节 (随机) |
| Tag 长度 |
16 字节 |
| AAD |
group 名原始字节 |
| 每消息开销 |
28 字节 (12 nonce + 16 tag) |
分块传输机制
发送方分块
E2E 密文按 80 字节 分块,每块作为独立 DNS 查询发送。每块重试最多 3 次:
wire_data (nonce || ciphertext || tag) │ ▼ 按 80 字节切割 ┌──────────┐ ┌──────────┐ ┌──────────┐ │ chunk 0 │ │ chunk 1 │ │ chunk 2 │ ... │ (80B) │ │ (80B) │ │ (<=80B) │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ ▼ ▼ ▼ DNS Query DNS Query DNS Query seq=0-3 seq=1-3 seq=2-3 (重试3次) (重试3次) (重试3次)
|
每个分块的 DNS 查询:
- 生成 12 字节随机 QueryNonce (仅防 DNS 缓存)
- 分块 base32 编码,每 30 字符一个 DNS 标签
- 组装 qname:
RouteID.SID.u.seq-total.QueryNonce.payload_labels...
- 发送 DNS TXT 查询;失败则生成新 QueryNonce 重试 (最多 3 次)
服务端重组
- 按
(sid, total) 缓冲分块
seq == 0 时重置缓冲区 (处理前一条未完成的消息)
- 全部块到齐后按序拼接,广播完整 E2E 密文到组内其他会话
大小限制
| 参数 |
值 |
| 最大消息载荷 |
4096 字节 |
| 分块大小 |
80 字节 |
| 分块重试次数 |
3 次 |
| DNS 标签段长 |
30 字符 |
| DNS 标签上限 |
63 字符 |
| DNS qname 上限 |
253 字符 |
| TXT 单条字符串上限 |
200 字符 (base32) |
Poll 响应帧格式
服务端返回多条消息时使用长度前缀帧格式:
┌─────────────────────────────────────────────────────────┐ │ 2B len │ E2E blob 1 │ 2B len │ E2E blob 2 │ ... │ └─────────────────────────────────────────────────────────┘ │ │ ▼ ▼ nonce (12 字节) nonce (12 字节) ct + tag (N+16) ct + tag (N+16)
|
- 长度前缀: 2 字节大端序 uint16 表示后续 E2E blob 长度
- E2E blob:
msgNonce(12) || AES-GCM-Ciphertext || Tag(16)
- 多条消息顺序拼接
- 空响应时返回空字符串 TXT 记录
响应大小限制
| 参数 |
值 |
| 每次 poll 最大消息数 |
10 |
| 每次 poll 最大字节数 |
4096 |
| TXT 记录最大长度 |
200 字符 base32 |
DNS 传输
帧数据 → base32 编码 → 按 200 字符切分 → 每段作为一条 TXT 记录回复。
客户端: 拼接 TXT 记录 → base32 解码 → 解析帧 → 逐条 E2E 解密。
自适应轮询
收到消息 │ ┌──────────▼──────────┐ │ interval = 250ms │◀─── 立即回到快速轮询 │ (baseInterval) │ └──────────┬──────────┘ │ 无消息 ▼ ┌─────────────────────┐ │ interval *= 2 │ │ cap at 2s │ │ (maxInterval) │ └──────────┬──────────┘ │ 无消息 ▼ 继续倍增直到 2s
|
- 活跃聊天: 每 250ms 轮询一次
- 空闲时: 250ms → 500ms → 1s → 2s (封顶)
- 任意消息到达: 立即回到 250ms
昵称管理
注册流程
- 客户端启动时调用
RegisterName
- 客户端发送
j 方向请求带上昵称
- 服务端检查同组 (routeID) 下是否已存在该昵称:
- 不存在 → 在该 SID 上注册昵称,回复
ok
- 已存在 → 回复
dup
- 客户端收到
dup:
- 命令行未指定
--name (auto 模式): 自动重新生成,最多重试 10 次
- 命令行显式指定
--name: 直接退出,提示昵称冲突
离开流程
- 主动关闭 (ESC / Ctrl+C) : 客户端发送
l 方向请求,服务端立即删除会话和昵称
- 被动断开 (崩溃 / 断网)会话最后活跃时间停止更新,超过
--timeout (默认 300 秒) 后被 cleanup 删除,昵称随之释放
会话过期自动恢复
如果会话被服务端清理 (如长时间网络中断后恢复) ,客户端再次发送消息时:
- 服务端
touch 创建新 session (无昵称)
- 服务端回复
unreg 而不是 ok
- 客户端检测到
unreg,自动重新注册昵称
完整消息生命周期
Client A (发送方) Server Client B (接收方) ────────────────── ──────── ────────────────── 1. 用户输入 "你好" 2. 构建 payload: "飞翔的开拓者\x00你好" 3. E2E 加密: nonce(12) || AES-GCM(...) 4. 分块 (80B each) 5. 每块 → DNS TXT Query qname: RouteID.SID.u.0-1... (失败自动重试最多 3 次) 6. 解析 qname (base32 解码) 7. addChunk 重组 8. broadcast → B.downq 9. 回复 "ok"/"unreg" 10. Poll Query qname: RouteID.SID.p.0... 11. popMessages 12. 帧编码 + base32 13. TXT 记录回复 14. base32 解码 15. 解析帧 16. E2E 解密 17. 解析 name\x00message 18. TUI 显示: "飞翔的开拓者: 你好"
|
服务端会话状态
每个 SID 对应一个 session
type session struct { grp []byte name string downq [][]byte last int64 msgBuf *msgBuffer }
|
服务端职责
- 解析 DNS qname 提取路由 ID 和会话 ID
- 按路由 ID 匹配同组会话
- 重组分块消息
- 广播 E2E 密文到组内其他会话
- 注册和检查昵称唯一性
- 管理会话生命周期 (10 秒一次 cleanup,超时清理)
- 不持有任何加密密钥,不解密消息内容
全流程
服务端收到 DNS 查询 │ ├─ 有 Question? ──否──> 静默丢弃 │ │ │ |是 │ │ ├─ 查询类型 TXT? ──否──> TXT "ok" │ │ │ |是 │ │ ├─ 解析域名标签 (parseQName) │ ├─ 标签数 ≥ 5? ──否──> TXT "ok" │ ├─ labels[0] = group routeID (Base32) │ ├─ labels[1] = sid (Base32) │ ├─ labels[2] = dir (u/p/j/l) │ ├─ labels[3] = "seq[-total]" │ ├─ labels[4] = query nonce (DNS 去重,不使用) │ ├─ labels[5..] = payload (Base32,可空) │ └─ 任一解码/解析失败? ──> TXT "ok" │ ├─ 更新会话 touch(sid, grp) │ └─ 若不存在则创建会话;记录 grp 与 last 时间戳 │ (注: 服务端不持有密钥,不解密任何 payload) │ ├─ 按 dir 分发 │ │ │ ├─ dir = "u" 上行消息 (E2E 密文) │ │ │ │ │ ├─ total ≤ 1 (单包) │ │ │ └─ broadcast(grp, sid, payload) │ │ │ └─ 把密文塞进同组其他会话的 downq │ │ │ │ │ ├─ total > 1 (分片) │ │ │ ├─ addChunk(sid, seq, total, payload) │ │ │ ├─ addChunk(sid, seq, total, payload) │ │ │ │ ├─ total 变化或 seq=0 ──> 重置缓冲 │ │ │ │ └─ 收齐全部分片 ──> 拼接整包 │ │ │ └─ 拼齐后 broadcast(grp, sid, full) │ │ │ │ │ └─ 已注册昵称? ── 是 ──> TXT "ok" │ │ └─ 否 ──> TXT "unreg" │ │ │ ├─ dir = "p" 轮询拉取 │ │ │ │ │ ├─ popMessages(sid, maxPollCount=10, maxPollBytes=4096) │ │ │ └─ 取出 downq 内若干条,受条数与字节上限约束 │ │ │ │ │ ├─ 拼装帧: [len_hi, len_lo, msg] × N (无消息则空 buf) │ │ │ │ │ └─ replyData │ │ ├─ buf 为空 ──> TXT "" │ │ └─ 否则 Base64URL → 按 maxTXTLength=200 切片 │ │ └─ 多条 TXT "<片1>" "<片2>" ... │ │ │ ├─ dir = "j" 加入群组 / 注册昵称 │ │ │ │ │ ├─ payload = 昵称 │ │ ├─ 昵称为空? ──> TXT "bad" │ │ └─ register(sid, grp, name) │ │ ├─ 同组内已存在同名(他人)? ──> TXT "dup" │ │ └─ 否则写入 grp+name ──> TXT "ok" │ │ │ ├─ dir = "l" 离开群组 │ │ ├─ remove(sid) 删除会话 │ │ └─ TXT "ok" │ │ │ └─ dir = 其他 ──> TXT "noop" │ └─ 发送 DNS 响应 (replyText: 单条字符串;replyData: 长数据 Base64URL + 多 TXT 分片)
并行: 周期性清理 (每 10s) └─ 移除 last 早于 timeout 的会话
|
最后就是实现
bob-zebedy/dnsay - GitHub