bitchat 深度拆解:无账号、无服务器、断网也能聊——蓝牙 Mesh + Nostr 双传输架构完整解析
一、背景:一个「反互联网」的聊天应用为什么反复登上 Trending
2026 年 7 月底,bitchat 再一次冲上 GitHub Trending 日榜第一。这个由 Twitter 联合创始人发起、后交由 permissionlesstech 社区维护的开源项目,做的事情听起来像是在跟整个互联网基础设施对着干:
- 没有账号体系——不需要手机号、邮箱,甚至不需要注册
- 没有中心服务器——没有任何一台机器存着你的消息
- 不需要互联网——蓝牙 Mesh 组网,断网状态下照样多跳转发消息
- 公有领域授权——连开源许可证都懒得要,直接 Public Domain
更有意思的是,这个仓库在 README 里明确写着:本仓库曾多次成为下架要求(takedown demands)的目标,因此官方专门写了一份《如何验证你拿到的构建是可信的》文档,教用户比对每个 release 的哈希清单。一个聊天软件把「供应链验证」写进入门文档,这本身就说明了它的目标场景:当网络被切断、当中心化服务不可用、当你不希望任何中间人知道你在和谁说话的时候,通讯应该如何继续?
作为程序员,抛开立场不谈,bitchat 的技术架构非常值得拆:它把 BLE Mesh 组网、Noise 协议加密、二进制紧凑协议设计、Nostr 去中心化中继、geohash 地理频道这些平时很少同时出现的技术,捏进了一个 iOS/macOS 原生应用里。这篇文章我们从架构到协议细节完整过一遍。
二、核心架构:双传输层(Dual Transport)设计
bitchat 的第一性设计是「消息的送达不应该依赖任何单一传输路径」。它实现了两条完全独立的传输层:
┌─────────────────────────────────────────────┐
│ 应用层 (Chat UI) │
├─────────────────────────────────────────────┤
│ 智能路由层 (Transport Router) │
│ Bluetooth 优先 → Nostr 回退 → 智能排队 │
├──────────────────────┬──────────────────────┤
│ BLE Mesh 传输层 │ Nostr 传输层 │
│ · 多跳中继(≤7跳) │ · 290+ 全球中继 │
│ · Noise 端到端加密 │ · 私有信封加密 │
│ · 二进制紧凑协议 │ · geohash 地理频道 │
│ · 离线可用 │ · 需要互联网 │
└──────────────────────┴──────────────────────┘
2.1 传输选择的决策逻辑
私聊消息的路由策略是一个清晰的三级决策:
- 蓝牙优先:如果与对方存在已建立的 Noise 会话(物理上在多跳范围内),直连发送。这是延迟最低、隐私最好的路径——消息根本不出本地无线电范围。
- Nostr 回退:蓝牙不可达时,用对方的 Nostr 公钥加密,通过全球中继网络投递。
- 智能排队:两条路都不通时,消息进入本地队列,任一传输恢复后自动补投。
用伪代码表达这个路由器的骨架:
enum Transport {
case bleMesh(session: NoiseSession)
case nostr(recipientPubkey: PublicKey)
case queued
}
func selectTransport(for peer: Peer) -> Transport {
// 1. 蓝牙可达且 Noise 会话有效 → 走 Mesh
if let session = meshManager.activeSession(with: peer),
session.isEstablished {
return .bleMesh(session: session)
}
// 2. 有互联网且知道对方 Nostr 公钥 → 走中继
if reachability.hasInternet, let npub = peer.nostrPublicKey {
return .nostr(recipientPubkey: npub)
}
// 3. 都不行 → 排队等待
return .queued
}
这个设计的妙处在于故障模式互补:BLE Mesh 的弱点是物理范围(哪怕 7 跳中继,覆盖也就几百米到几公里),Nostr 的弱点是依赖互联网。两者叠加后,「城市日常用 Nostr、断网/灾害/无网区域用 Mesh」形成了完整闭环。
三、BLE Mesh 层:在蓝牙的约束下做协议设计
3.1 为什么 BLE 上跑 Mesh 这么难
蓝牙 LE 的 MTU 天生小气:默认 ATT MTU 只有 23 字节,协商后一般也就 185~512 字节。而且 BLE 是为「低功耗、间歇性通信」设计的,连接数有限、广播信道拥挤。要在这种链路上跑一个多跳消息网络,协议设计必须极度节俭。
bitchat 的做法是自定义二进制紧凑协议(Binary Protocol),而不是偷懒用 JSON。一个典型的 mesh 数据包结构大致如下(依据其协议文档整理):
┌────────┬────────┬─────────┬──────────┬─────────┬──────────┐
│ version│ type │ TTL │ timestamp│ flags │ payload │
│ 1 byte │ 1 byte │ 1 byte │ 8 bytes │ 1 byte │ variable │
└────────┴────────┴─────────┴──────────┴─────────┴──────────┘
+ senderID (8 bytes) + recipientID (8 bytes, 可选)
+ signature (64 bytes, 可选)
关键字段的设计取舍:
- TTL(Time To Live):初始为 7,每经过一跳减 1,归零即丢弃。这就是「最大 7 跳」限制的来源——它不是产品限制,而是防广播风暴的协议安全阀。
- senderID 用 8 字节短标识而不是完整的 32 字节公钥:在 MTU 只有一两百字节的链路上,头部每省一个字节都是吞吐量。
- LZ4 压缩:payload 超过阈值时启用 LZ4。LZ4 的解压速度在移动端 CPU 上接近 memcpy,对电池几乎无感,但对文本消息通常能省 40%~60% 的空间——这在多跳转发场景里是乘法收益(省 1 跳的字节 = 省 N 跳的字节)。
用 Python 模拟一个最小化的包编解码器,感受一下这种「按位抠字节」的风格:
import struct
import time
MSG_TYPE_CHAT = 0x01
MSG_TYPE_ANNOUNCE = 0x02
FLAG_HAS_RECIPIENT = 0x01
FLAG_COMPRESSED = 0x02
def encode_packet(msg_type: int, sender_id: bytes, payload: bytes,
recipient_id: bytes | None = None, ttl: int = 7) -> bytes:
flags = 0
if recipient_id:
flags |= FLAG_HAS_RECIPIENT
# header: version(1) type(1) ttl(1) ts(8) flags(1)
header = struct.pack(">BBBQB", 1, msg_type, ttl,
int(time.time() * 1000), flags)
body = sender_id # 8 bytes
if recipient_id:
body += recipient_id # 8 bytes
body += struct.pack(">H", len(payload)) + payload
return header + body
def relay(packet: bytes) -> bytes | None:
"""中继节点逻辑:TTL 减一,归零丢弃"""
version, msg_type, ttl = packet[0], packet[1], packet[2]
if ttl <= 1:
return None # 丢弃,不再转发
return packet[:2] + bytes([ttl - 1]) + packet[3:]
3.2 洪泛 + 去重:Mesh 路由的实用主义选择
bitchat 的 Mesh 没有采用复杂的路由表(如 AODV、Babel 这类 MANET 路由协议),而是用受控洪泛(controlled flooding):每个节点收到包后向所有邻居转发,靠三件事防止网络被淹没:
- TTL 上限 7:包的传播半径有硬上限;
- 消息去重缓存:每个节点维护一个近期见过的 messageID 集合(布隆过滤器或 LRU 集合),重复包直接丢弃;
- 自适应占空比(duty cycling):根据电量调整扫描/广播的频率,低电量时降低参与度。
为什么不用「更聪明」的路由协议?因为 Mesh 聊天场景的拓扑是高度动态的——人在走动,节点随时加入退出,维护路由表的开销(控制消息)很可能超过它省下的数据转发。在小规模(几十到几百节点)、小消息(几百字节)的场景下,洪泛 + 去重是被无数 MANET 论文验证过的实用主义最优解。这是很典型的「工程上正确」压倒「理论上优雅」。
3.3 store-and-forward:给离线的人捎句话
Mesh 网络里对方可能暂时不在范围内。bitchat 实现了存储转发:中间节点可以为不在线的目标缓存加密消息,等目标出现后补投。README 里有一个诚实的安全说明:实时会话的 Noise 加密具备前向保密(forward secrecy),但 store-and-forward 的「信箱」消息是密封投递、不带前向保密的——因为前向保密要求双方在线完成握手轮换,而信箱消息本质上是「给未来的对方」加密,只能用长期密钥封装。
这种在文档里明确标注安全边界的做法值得点赞:太多项目宣传「端到端加密」时对这类细节含糊其辞。
四、加密层:Noise Protocol 的正确打开方式
4.1 为什么是 Noise,而不是自己攒一套
bitchat 的 mesh 端到端加密基于 Noise Protocol Framework——这是 WireGuard、WhatsApp(部分)、Lightning Network 使用的握手框架。Noise 的核心价值是:它把「如何组合 DH、加密、哈希原语」这个最容易出错的环节标准化成了可验证的握手模式。
bitchat 使用的是 Noise_XX_25519_ChaChaPoly_SHA256 这类 XX 模式。XX 模式的特点是双方都不需要预先知道对方的静态公钥——这正好匹配「陌生设备在蓝牙范围内相遇」的场景:
XX 握手(三条消息完成双向认证 + 会话密钥协商):
-> e # 发起方发临时公钥
<- e, ee, s, es # 响应方发临时公钥+静态公钥(加密),完成半程DH
-> s, se # 发起方发静态公钥(加密),完成全部DH
之后: 双方派生出发送/接收两条独立的 ChaCha20-Poly1305 密钥链
三次消息交换后,双方获得:
- 双向身份认证(基于静态密钥 Curve25519)
- 前向保密(临时密钥 ephemeral key 参与所有 DH 运算)
- 身份隐藏(静态公钥在传输中是加密的,被动窃听者拿不到)
用 Swift 伪代码示意会话建立后的收发:
final class NoiseSession {
private var sendCipher: CipherState // ChaCha20-Poly1305 + 递增 nonce
private var recvCipher: CipherState
func encrypt(_ plaintext: Data) throws -> Data {
// nonce 单调递增,天然防重放;每条消息带 Poly1305 认证标签
return try sendCipher.encryptWithAd(ad: Data(), plaintext: plaintext)
}
func decrypt(_ ciphertext: Data) throws -> Data {
return try recvCipher.decryptWithAd(ad: Data(), ciphertext: ciphertext)
}
}
4.2 Nostr 侧:自定义信封,明确不兼容 NIP-44
内行看门道的一个细节:bitchat 在 README 里专门声明,它的 Nostr 私信格式是私有的,不兼容 NIP-17/NIP-44/NIP-59。它只是把 Nostr 当作中继传输层用:私密载荷装在 kind-1059 事件里,内容是带 v2: 前缀的 XChaCha20-Poly1305 自定义构造。
这个选择有利有弊:
- 利:XChaCha20 的 192-bit 随机 nonce 让「随机生成 nonce」在统计上绝对安全(ChaCha20 的 96-bit nonce 在高频随机生成下有生日碰撞的理论风险);自定义信封可以塞进 bitchat 自己的元数据。
- 弊:放弃了与 Nostr 生态其他客户端的互操作性——你不能用 Damus 给 bitchat 用户发私信。项目的取舍很明确:Nostr 只是「借道」,不是「入伙」。
4.3 隐私的诚实边界:无线电元数据
bitchat 的白皮书没有回避一个尴尬的事实:mesh 使用从身份密钥派生的持久化设备标识。这意味着一个近距离的无线电观察者(拿着抓包设备的人)虽然读不到你的消息内容,但可以观察到「这个标识的设备在这个位置出现过」。内容加密 ≠ 元数据隐身,这是所有无线 Mesh 网络的共同宿命。项目选择把这一点写进白皮书而不是假装不存在——工程诚实度加分。
五、Nostr 层:geohash 把「附近的人」做成了去中心化频道
bitchat 最有产品巧思的功能是地理位置频道:用 geohash 前缀精度定义聊天室的地理范围,消息通过全球 290+ 个 Nostr 中继分发:
| 频道类型 | geohash 长度 | 覆盖范围 |
|---|---|---|
| block | 7 位 | 一个街区(约 150m × 150m) |
| neighborhood | 6 位 | 一个社区(约 1.2km × 0.6km) |
| city | 5 位 | 城市级(约 5km × 5km) |
| province | 4 位 | 省/州级 |
| region | 2 位 | 国家/大区级 |
geohash 的精妙之处在于它是前缀可裁剪的:wx4g0e 属于 wx4g0(城市)也属于 wx4g(省级),订阅粗粒度频道自然覆盖细粒度区域。实现一个 geohash 编码器不过几十行:
package geohash
const base32 = "0123456789bcdefghjkmnpqrstuvwxyz"
func Encode(lat, lon float64, precision int) string {
latRange := [2]float64{-90, 90}
lonRange := [2]float64{-180, 180}
var result []byte
var bit, ch int
even := true // 偶数位编码经度,奇数位编码纬度
for len(result) < precision {
if even {
mid := (lonRange[0] + lonRange[1]) / 2
if lon >= mid { ch |= 1 << (4 - bit); lonRange[0] = mid
} else { lonRange[1] = mid }
} else {
mid := (latRange[0] + latRange[1]) / 2
if lat >= mid { ch |= 1 << (4 - bit); latRange[0] = mid
} else { latRange[1] = mid }
}
even = !even
if bit < 4 { bit++
} else {
result = append(result, base32[ch])
bit, ch = 0, 0
}
}
return string(result)
}
隐私上还有一层设计:每个 geohash 区域使用独立的临时密钥(ephemeral keys)。你在「城市频道」的身份和「街区频道」的身份在密码学上不可关联——换个频道就是换个人。这比传统 LBS 应用「一个账号走天下、轨迹全记录」的模式,在隐私模型上是代际差异。
六、工程细节:一个 P2P 应用的生存技巧
6.1 电量:P2P 应用的第一杀手
BLE 持续扫描是耗电大户。bitchat 实现了自适应电源模式:电量充足时高频扫描/广播(低消息延迟),电量下降后拉长扫描间隔、降低作为中继节点的参与度。这本质上是把「网络性能」和「设备续航」做成了一个可调节的滑块,而不是二选一。
enum PowerMode {
case performance // 充电中/满电: scan interval 短, 全力中继
case balanced // 常态: 间歇扫描
case saver // 低电量: 只收发自己的消息, 降低中继TTL预算
var scanDutyCycle: Double {
switch self {
case .performance: return 1.0
case .balanced: return 0.4
case .saver: return 0.1
}
}
}
6.2 紧急擦除:三连击清空一切
「Emergency Wipe:三击屏幕,立刻清除所有数据。」这个功能在普通聊天软件里像个噱头,但结合 bitchat 的目标场景(灾害、抗议、无网地区),它是一个严肃的威胁模型应对:设备可能被物理夺取,所以数据销毁必须比解锁手机更快。安全设计永远是场景的函数——同一个功能,在不同威胁模型下的必要性天差地别。
6.3 可复现构建与信任链
由于仓库遭遇过下架要求,官方在文档里给出了完整的构建验证流程:每个 release 附带哈希清单(per-release hash manifest),用户可以自己从源码构建并比对哈希。macOS 下的免签名验证构建:
git clone https://github.com/permissionlesstech/bitchat && cd bitchat
# 免签名 Debug 构建(用于源码验证)
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" \
-configuration Debug CODE_SIGNING_ALLOWED=NO build
# 跑完整测试套件
swift test
# 或者用 just 一键搞定
brew install just
just check && just run
对于一个「消息内容 = 用户人身安全」的应用,这套供应链验证不是可选项。反过来想,我们平时开发的普通应用如果也能做到 release 哈希清单 + 可复现构建,供应链攻击的攻击面会小得多——这是 bitchat 给所有开发者的免费一课。
七、冷静评估:它不是你的下一个微信
优点说完,说说边界,避免被 Trending 热度带偏:
- Mesh 覆盖范围有限。7 跳 BLE 中继在人群密集场景(演唱会、集会)表现好,但在人稀的地方,两个人隔 500 米就是永远的失联。它是「特定场景的通讯保底」,不是日常主力 IM。
- 消息可靠性是尽力而为。洪泛 + 排队机制没有中心服务器的送达保证,消息可能就是丢了,而且没人能告诉你丢没丢。
- 元数据暴露。前面说过,持久设备标识对近距离射频观察者可见。真正的高危场景使用者需要理解这一点。
- iOS 后台限制。苹果对后台 BLE 的调度限制意味着 App 切后台后中继能力下降,Mesh 网络的实际密度会低于理论值。
- 私有加密信封未经过 NIP 标准的社区审计流程,虽然构造保守(XChaCha20-Poly1305),但「自定义密码学构造」这几个字本身就该让你多一分谨慎。
八、总结:值得抄的三张设计图纸
bitchat 是否会成为主流应用不重要,重要的是它演示了三个可以直接搬进你自己项目的设计模式:
- 双传输 + 智能路由:任何对可用性有极端要求的系统,都应该考虑「异构冗余」——两条故障模式完全不相关的路径,比一条路径做十个 9 更可靠。这个思想同样适用于支付系统的多通道路由、IoT 的蜂窝 + LoRa 双链路。
- 约束驱动的协议设计:BLE 的小 MTU 逼出了二进制紧凑协议 + LZ4 + 短标识。当你在做 WebSocket 长连接或 IoT 上行协议时,同样的「每字节都要有理由」思维能省下真金白银的带宽和电量。
- 诚实的安全文档:明确写出「哪里有前向保密、哪里没有」「什么元数据会暴露」,这种把安全边界画清楚的文档文化,比一百句「军用级加密」的营销话术更值得信任。
在一切上云、一切依赖平台的时代,bitchat 反其道而行之的技术路线像一个思想实验:如果把互联网从通讯软件里抽走,还剩下什么?它的答案是——还剩下无线电、密码学,和口袋里那台比阿波罗登月计算机强大百万倍的设备。这个答案的工程实现,值得每个做分布式系统的人翻一翻源码。
项目地址:github.com/permissionlesstech/bitchat(Public Domain,随便抄)。