断网的时候,你的消息要怎么走出去?
这不是一个假设问题。2025 年 9 月尼泊尔封禁 26 个社交平台期间,bitchat 单日下载量接近 5 万;2026 年 1 月伊朗全国断网前的三天里,它的本地化分支 Noghteha 在 Google Play 拿到 7 万多次下载;同年印度要求 GitHub 地域封锁它的代码仓库,项目方拒绝配合。截至 2026 年 8 月 12 日,permissionlesstech/bitchat 有 35,251 star、5,614 fork、115 个 open issue,最近一次提交在 8 月 10 日。
这些数字很容易让人停在「Jack Dorsey 做了个蓝牙聊天 App」的层面。但一个在真实断网现场被几十万人装上的东西,值得从代码层看一遍——因为「蓝牙 mesh」这四个字掩盖了大量非显然的工程决策,而这些决策的质量,直接决定了广场上两千部手机同时开着这个 App 时它会不会自己把自己淹死。
我把仓库拉下来读了一遍(521 个 Swift 文件,约 15.4 万行)。以下是三个只有翻开源码才看得到的东西,以及它们和 Briar、Meshtastic、Bridgefy 的差距在哪。
双传输栈,以及一个被低估的路由器
先建立地图。bitchat 有两条独立的传输通道:BLE mesh 负责离线,Nostr 负责有网。这是 README 上写着的。
README 没写的是,中间那层 MessageRouter 并不是「有网走网、没网走蓝牙」的开关。它按四个条件依次降级,每一级的注释都在解释这一级为什么不能被优化掉。这个稍后展开。
技术栈本身很克制:Swift Package 只有一个外部依赖(swift-secp256k1 0.21.1),其余三个是仓库内的本地包,其中 Arti 是 Tor 的 Rust 实现绑定。iOS 16+ / macOS 13+。License 是 Unlicense——完全释放到公共领域,比 MIT 还宽松。Android 版是独立仓库(7,430 star),走 GPL-3.0。
一、泛洪的真正刹车是抖动,不是 TTL
白皮书把 mesh 转发描述为「受控泛洪」(controlled flood),并提到 TTL 会按连接密度钳制。这话对,但它把重点说反了。
先看扇出。BLEFanoutSelector.swift 决定一条广播实际要发给几条链路:
private static func subsetSize(for count: Int) -> Int {
guard count > 0 else { return 0 }
if count <= 2 { return count }
var value = count - 1
var bits = 0
while value > 0 {
value >>= 1
bits += 1
}
return min(count, max(1, bits + 1))
}
这是 k = bit_length(n−1) + 1,一个对数函数。8 条链路发 4 条,16 条发 5 条,64 条只发 7 条——占比从 50% 掉到 11%。选哪 7 条不是随机的:
let data = (seed + "::" + id).data(using: .utf8) ?? Data()
let digest = Array(SHA256.hash(data: data))
scored.append((digest, id))
seed 是 messageID。对每条链路算 SHA256(messageID + "::" + linkID),排序取前 k。这本质是 rendezvous hashing:无状态、确定性、可在两台设备上独立复算,而且每条消息换一批链路,所以长期看每条链路的负载是均匀的。三种包被排除在子集之外,全链路发送——announce、fragment、requestSync。announce 那条的注释解释得很清楚:announce 正是把一条链路绑定到某个 peer 的那个包,如果它被子集掉了,重复链路会永远停在「pre-announce」状态,反而导致后续每次广播都往所有重复链路上喷。
现在看真正有意思的部分。RelayController.decide() 是全局的转发决策点,它的最后一段是:
// Wider jitter window to allow duplicate suppression to win more often
// For sparse graphs (<=2), relay quickly to avoid cancellation races
let delayMs: Int
switch degree {
case 0...2: delayMs = Int.random(in: 10...40)
case 3...5: delayMs = Int.random(in: 60...150)
case 6...9: delayMs = Int.random(in: 80...180)
default: delayMs = Int.random(in: 100...220)
}
TTL 那部分同一个函数里也有:degree ≥ 6 时钳到 5,degree ≤ 2 时按原样放行,中间档 6(announce 和紧急公告 7)。但 TTL 只有 7 到 5 的差别,两跳;抖动窗口却从 40ms 拉到 220ms,5.5 倍。
省下空口时间的是后者。 逻辑在注释里:转发前等得越久,越可能先从别人那里收到同一条消息的副本,从而取消自己这次转发。TTL 限制的是「最远能传多远」,抖动决定的是「同一跳上有多少个副本会被抑制掉」。在一个两千人的广场上,后者是数量级的差别,前者不是。
这个设计的代价也在代码里标着:稀疏图必须快速转发(10-40ms),因为在细链上等待反而会引发「取消竞态」——大家互相等着,最后谁都没转发。所以抖动不能一刀切地加大。
顺带一提,泛洪并不是唯一路径。docs/SOURCE_ROUTING.md 描述了 v2 协议的源路由扩展:发送方可以在包头里塞一条显式的中继路径(HAS_ROUTE 标志 0x08),沿途节点看到自己在路径里就定向转发并抑制泛洪。iOS 与 Android 都已实现解码与转发,iOS 的路径发起是策略门控的,且只在整条路径上每个节点都被观察到讲过 v2 时才发起。这是个明确的演进方向:从广播 mesh 往混合 mesh 走。
二、同步滤波器是从 Bitcoin 抄来的
节点重新进入范围时,需要知道对方有哪些自己没有的历史消息。朴素做法是发一份消息 ID 列表——16 字节一个,1000 条就是 16KB,在 BLE 上是灾难。
bitchat 的做法是 Sync/GCSFilter.swift。文件头的注释:
// Golomb-Coded Set (GCS) filter utilities for sync.
// - Map to [1, M) by computing (h64 % M) and remapping 0 -> 1 to avoid zero-length deltas.
// - Sort mapped values ascending; encode deltas as positive integers x >= 1.
// - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary, then P-bit remainder.
这是 BIP-158——Bitcoin 的紧凑区块滤波器,2017 年为轻钱包设计的那套东西——几乎逐字搬到了 mesh 消息同步上。参数 P 由目标误判率推导(P = ceil(log2(1/f)),误判率约 1/2^P),每元素成本约 P+2 比特。取 P=10(约 0.1% 误判),1000 条消息压到 1.5KB 左右,比裸 ID 列表小一个数量级。
这不是巧合。这个项目的圈层就是 Bitcoin 圈层,把已经在恶劣网络下跑了八年的滤波器直接迁过来,比自己发明一个 Bloom filter 变体要理性得多——Bloom filter 在同等误判率下比 GCS 大约 40%,而且不支持增量编码。
真正体现工程成色的是溢出处理:
// The caller passes IDs newest-first, so trimming from the tail drops the
// oldest — which is what lets a since-cursor stay exact: the surviving set
// is always a contiguous newest-prefix, never a hash-order-arbitrary subset.
var count = min(ids.count, cap)
var encoded = encodeFirst(count)
while encoded.count > maxBytes && count > 1 {
count = max(1, (count * 9) / 10)
encoded = encodeFirst(count)
}
当编码超出字节预算时,按 90% 逐步收缩,并且从输入尾部丢弃。调用方按「最新在前」传入,所以幸存的永远是一个连续的最新前缀,而不是哈希序上任意的子集。这保证了对端可以据此推出一个精确的时间游标。返回值里专门带了 includedCount 字段就是为这件事服务的。
这类细节是判断一个开源项目成色的可靠信号:作者知道「截断」和「按哈希序丢一半」在下游是完全不同的两件事。
三、把陌生人变成邮差:spray-and-wait 的一次工业实现
如果收件人根本不在范围里呢?这是延迟容忍网络(DTN)的经典问题,bitchat 的答案是 courier。
CourierStore.swift 的字段注释直接点名了算法:
/// Remaining spray-and-wait budget (1 = carry-only).
var copies: UInt8
/// Couriers this envelope was already sprayed to, so a repeat announce
/// from the same peer doesn't burn budget on a copy they already hold.
var sprayedTo: Set<Data>
Spray and Wait 是 Spyropoulos 等人 2005 年提出的 DTN 路由算法:给每份消息一个副本预算,携带者相遇时把预算对半分,降到 1 就只携带不再扩散。它的价值在于比 epidemic routing(见谁传谁)省得多,同时保留了大部分投递率。bitchat 的实现是 binary spray:初始 4 份,上限 8,两个 courier 相遇时预算减半。
配额设计比算法本身更值得看:
enum Limits {
static let maxEnvelopes = 40
/// Verified-tier mail can never crowd out favorites' share.
static let maxVerifiedEnvelopes = 20
static let maxPerFavoriteDepositor = 5
static let maxPerVerifiedDepositor = 2
static let maxExpirySlack: TimeInterval = 60 * 60
}
两层信任分级:互为 favorite 的人每人可存 5 封,仅通过签名验证的陌生人每人 2 封,且陌生人层整体不超过 20 封,永远挤不掉 favorite 的份额。这是在解决一个具体的攻击面——如果不分级,一个攻击者可以用大量伪造身份把你的邮袋塞满,让真正的信件无处可放。
回到前面提到的 MessageRouter。它的四级降级是这样的:
第二级的注释是全文最值得读的一段:
// "Connected" without an established secure session is forgeable:
// link bindings heal on signature-verified "direct" announces, but
// directness rides on the unsigned TTL, so a replayed announce can
// bind an absent peer's ID to the replayer's link — where the send
// stalls on a handshake the replayer can never complete.
//
// Deliberate metadata tradeoff: every pre-handshake first DM to a
// connected peer hands nearby verified peers a sealed copy, so
// they learn a DM to this recipient exists (never its content).
// Accepted for delivery robustness; the deposit is cleared on ack.
// Don't "optimize" the courier call away.
它同时做了三件事:说明了一个具体的攻击(重放 announce 把缺席节点的 ID 绑到攻击者链路上,让消息卡在一个永远完不成的握手上)、承认了为对抗它而付出的元数据代价(附近的人会知道「有一封给 X 的信存在」),以及给未来的维护者留了一条明确的禁令。这种写法比任何架构文档都有效——它把一个看起来冗余的调用钉死在了原地。这本质上是切斯特顿栅栏的一次代码级实践:作者预判了「这里为什么要多此一举」的疑问,把栅栏的用途刻在了栅栏上。
顺带说一句 prekey。Services/Prekeys/LocalPrekeyStore.swift 引入了一次性预密钥(类似 Signal 的 X3DH),用于给离线信件提供前向保密。但它诚实地写下了自己的裂缝:
/// Redelivery grace: spray-and-wait means the same prekey-sealed ciphertext
/// can arrive via several couriers days apart. A consumed prekey's private
/// key is therefore retained for `consumedGraceSeconds` after first use.
/// Tradeoff: during the grace window a compromise of the device still exposes
/// mail sealed to that prekey — the forward-secrecy clock starts at deletion,
/// not at first open.
前向保密的时钟从「删除」而不是「首次打开」开始走。这是 spray-and-wait 与前向保密的结构性冲突:接收方无法区分「重复投递」和「新密文」,所以窗口只能靠短来控制,不能靠逻辑消除。
四、撕裂的身份模型
到这里为止,代码给人的印象都很好。现在说最大的问题。
Nostr 那一侧,身份是这样推导的(NostrIdentityBridge.swift):
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material
每个 geohash 格子一把独立的 secp256k1 密钥。 你在北京朝阳区的公钥和你在上海徐汇区的公钥不可关联,而且是确定性推导的——不需要存一堆密钥,回到同一格子还是同一个身份。这是干净、正确的设计。
BLE 那一侧,docs/PEER-ID-ROTATION.md 用了整整一节来描述现状。这份文档的坦率程度值得整段引用:
今天,一个带着 BLE dongle 站在人群里的被动监听者,无需任何密码学攻击、无需主动参与,就可以做到:
- 检测到一部手机在跑 bitchat。 service UUID 是固定常量。
- 给这部手机分配一个永久标识符。 每个包头里的 8 字节 sender ID 是
SHA-256(noiseStaticPublicKey)[0..8],而 Noise 静态密钥只生成一次并存在钥匙串里。它不轮换。同一部手机,同样的字节,下周,另一个城市。- 拿到这部手机的长期公钥和自选昵称。 announce 携带 32 字节 Noise 静态密钥、32 字节 Ed25519 签名密钥和昵称,全部明文,每 4–30 秒重播一次。
- 重建谁站在谁旁边。 announce 还携带最多十个邻居 ID,所以一个接收机就能拿到本地邻接图,不需要多点接收或信号强度三角定位。
iOS 的 BLE 地址随机化在这里帮不上忙。它随机化的是链路层地址,而其上的应用层正在发布一个稳定标识符。
同一个 App,Nostr 侧做到了 per-geohash 不可关联,BLE 侧却在向物理半径内的所有人广播一个永久指纹加社交图。而 BLE 侧恰恰是给断网抗议现场用的那一侧。
这份文档最有价值的一句是它对修复方案的自我修正:
最重要的一处纠正:单独轮换 peer ID 什么也解决不了。 只要 announce 仍然明文携带静态密钥,一个轮换后的 ID 会在它的第一个 announce 上被重新链接回同一部设备。轮换和 announce 机密性必须同时落地,否则就都不要落地。
原因在于 peerID == SHA-256(noiseStaticPublicKey)[0..8] 不只是一个约定,它是让 peer ID 不可伪造的那个机制,在 announce 预检和链路绑定两处强制执行。拆掉它就得同时补上替代的身份绑定。这和 iroh 用公钥而非 IP 拨号是同一个权衡的两面:把地址直接锚在密钥上,换来了寻址的自证性,代价是地址天然不可轮换。iroh 在互联网上可以靠中继和短生命期连接稀释这个代价,BLE 广播没有这个奢侈。
文档的状态栏写着:推导和线格式已实现并测试,但没有任何东西接进出货的 mesh——BLEService 解析新的 announceV2 = 0x2C 类型然后显式忽略它。也就是说,截至 2026 年 8 月,这个问题在 App Store 版本里仍然存在。
与同类的对照
选这四个维度是因为它们各自对应一类失败:传输层决定断网时你还剩什么,加密与认证决定被抓到密文后损失多大,元数据抗性决定你会不会因为用了它而暴露,审计历史决定前三项的声明是否可信。功能多少、UI 好坏在断网场景下都不进前四。
| bitchat | Briar | Meshtastic | Bridgefy | |
|---|---|---|---|---|
| 传输层 | BLE mesh + Nostr(可选 Tor) | Tor + 蓝牙 + Wi-Fi Direct + U 盘 | LoRa(需专用硬件) | BLE / Wi-Fi mesh |
| 距离 | 单跳约 30m,多跳约 300m | 蓝牙 10–30m,Wi-Fi Direct 约 150m | 数公里 | 单跳约 100m |
| 加密 | Noise XX(在线会话)+ 一次性 prekey(离线信件) | Bramble 协议套件,专为 DTN 设计 | AES256-CTR + 频道预共享密钥 | 早期无有效机密性,后已重写 |
| 前向保密 | 在线会话有;离线信件靠 prekey,有宽限窗口 | 有 | 无(官方文档承认,易受「先收后解」) | 现代版本有 |
| 消息认证 | Ed25519 签名 | 有 | 无(持 PSK 者可冒充频道内任何人) | 现代版本有 |
| 元数据抗性 | Nostr 侧 per-geohash 不可关联;BLE 侧永久 ID + 邻居表明文 | 全流量走 Tor,威胁模型文档最保守 | 明文头部含节点 ID,长期可追踪 | 论文证实可构建社交图 |
| 独立审计 | 无第三方审计;社区报告 + GitHub 私密披露流程 | Cure53 2017 年审计,12 项发现全部修复 | 无正式审计,官方主动公开局限 | CT-RSA 2021 论文全面攻破早期版本 |
| 门槛 | 装个 App | 装个 App | 买硬件(约 $30–80/节点) | 装个 App |
| License | Unlicense(iOS)/ GPL-3.0(Android) | GPL-3.0 | GPL-3.0 | 闭源 SDK |
几点必要的说明。
Bridgefy 是这个赛道的教训本身。 Albrecht、Blasco、Jensen、Mareková 四人在 CT-RSA 2021 发表的《Mesh Messaging in Large-scale Protests: Breaking Bridgefy》证明:当时的 Bridgefy 允许追踪用户、没有认证性、没有有效机密性,并且一条恶意构造的消息就能瘫痪整个网络——而它当时正在被推荐给香港、印度、伊朗、白俄罗斯的抗议者使用。它后来重写了协议,但这个案例确立了一条规则:在这个领域,「已被大规模用于抗议」不是安全性的证据,反而是需要被审计的理由。
Meshtastic 不是同类竞品,是不同物种。 它跑 LoRa,距离是公里级,代价是每个节点要买硬件(firmware 仓库截至 2026 年 8 月 12 日有 8,108 star)。它的加密弱点是官方明确写在文档里的:AES256-CTR + 频道 PSK,没有完整性校验、没有前向保密,任何持有 PSK 的人都能冒充频道内任何人。这在它的原始场景(户外通联、应急)里可接受,在对抗性场景里不可接受。Meshtastic 的坦率反而是它的优点——它没有假装自己是安全通信工具。
Briar 是唯一有独立审计记录的。 Cure53 在 2017 年 3 月投入 6 名测试人员、13 个人日,出具 12 项发现(1 项高危 DNS 泄漏、若干中危),全部已修复,报告里称其源码质量「相当出色」。它的 Bramble 协议套件是专门为延迟容忍网络设计的,而不是把在线协议硬套到离线场景。代价是生态小得多——GitHub 镜像截至 2026 年 8 月 12 日只有 677 star(主开发在自建 GitLab),UI 也确实不如 bitchat。
学术界在往前走。 ACM CCS 2025 上的 Amigo 提出了面向真实抗议场景的安全群组 mesh 消息方案。这提示了一件事:bitchat 目前的元数据问题不是「无解的物理限制」,而是「尚未采纳的已知方案」。
诚实的评分
按上面四个维度,加上工程成色和可复用性:
| 维度 | 评分 | 理由 |
|---|---|---|
| 路由与拥塞控制 | 9/10 | 对数扇出 + 按度数分级的抖动窗口 + 源路由演进,是我在开源 mesh 里见过最完整的一套。扣分在于泛洪仍是默认路径,源路由未全面启用 |
| 存转发设计 | 8/10 | Spray-and-wait 加双层信任配额,攻击面想得很清楚。扣分在 prekey 宽限窗口对前向保密的削弱 |
| 密码学工程 | 7/10 | Noise XX 用得规范,prekey 补上了离线前向保密。但自创 Nostr 信封格式放弃 NIP-44 兼容,是纯亏损的决定 |
| 元数据抗性 | 4/10 | 永不轮换的 peer ID + 明文静态密钥 + 明文邻居表。修复方案写得很好但没上线。这是唯一一个会真正伤到人的短板 |
| 代码可读性 | 9/10 | 521 个文件的高度分解,几乎每个策略都是独立可测的纯函数;注释解释「为什么」而不是「是什么」 |
| 可信度 | 5/10 | 无第三方审计,早期有 CVSS 9.8 的缓冲区溢出。但披露流程规范,SECURITY.md 明确区分了「漏洞」和「已披露的设计属性」 |
结论
如果你在评估要不要在高风险场景用它: 现在不要单独依赖它。永久 peer ID 加明文邻居表意味着,一个在广场边缘架设备的对手,不需要破解任何加密就能拿到「谁在场、谁挨着谁、谁下周又出现在另一个城市」。这恰好是抗议场景里最要命的那类信息。等 announceV2 连同 announce 机密性一起上线之后,这个结论应该重新评估——修复方案本身写得比大多数已上线的功能都清楚。
如果你在做 P2P 或离线优先的系统: 三样东西值得直接拿走。一是用抖动窗口而不是 TTL 做主要的拥塞控制——TTL 限制传播半径,抖动决定重复副本的抑制率,后者在密集网络里是数量级的差别。二是 GCS filter 做集合协调——BIP-158 已经在恶劣网络下验证了八年,比自己调 Bloom filter 参数省事得多,注意保留「按输入尾部截断」这个性质。三是在注释里写清一个冗余调用为什么不能删——MessageRouter 那句 "Don't optimize the courier call away" 是我今年读到的最有效的一行注释。
不要抄的: 自创的 Nostr 信封格式。放弃 NIP-17/44/59 兼容换来了一个永久孤岛——只有 bitchat 客户端能解,收益是零。这是典型的「因为我们能自己写,所以我们自己写」。
最后一点观察。 这个项目最好的产出可能不是代码,而是 docs/PEER-ID-ROTATION.md 那份文档:它把自己产品最严重的隐私缺陷写成了一份带可执行测试向量的公开规格,标注「已实现但未接线」,并且明确说明为什么半个修复比不修复更危险。开源项目里,肯把这种东西写下来并挂在仓库里的,比会写对数扇出的少得多。
参考资料
- permissionlesstech — bitchat 主仓库(iOS/macOS,Swift)(截至 2026-08-12:35,251 star / 5,614 fork,最近提交 2026-08-10)
- permissionlesstech — bitchat WHITEPAPER.md
- permissionlesstech — bitchat-android(截至 2026-08-12:7,430 star,GPL-3.0)
- 本文引用的源码文件:
bitchat/Services/BLE/BLEFanoutSelector.swift、bitchat/Services/RelayController.swift、bitchat/Sync/GCSFilter.swift、bitchat/Services/Courier/CourierStore.swift、bitchat/Services/MessageRouter.swift、bitchat/Services/Prekeys/LocalPrekeyStore.swift、bitchat/Nostr/NostrIdentityBridge.swift、docs/PEER-ID-ROTATION.md、docs/SOURCE_ROUTING.md、SECURITY.md - GitHub Issue #376 — BitChat protocol security report(BinaryProtocol.swift 审计,含 CVSS 9.8 缓冲区溢出)
- Martin R. Albrecht, Jorge Blasco, Rikke Bjerg Jensen, Lenka Mareková — Mesh Messaging in Large-scale Protests: Breaking Bridgefy,CT-RSA 2021(2021 年 5 月)
- ACM CCS 2025 — Amigo: Secure Group Mesh Messaging in Realistic Protest Settings
- Briar Project — Darknet Messenger Releases Beta, Passes Security Audit(Cure53 审计,2017 年 3 月)
- Meshtastic — Known Limitations and Future Plans of Meshtastic's Encryption
- Meshtastic — Encryption Overview
- Rest of World — Why India asked GitHub to geoblock Jack Dorsey's Bitchat code(2026)
- Bloomsbury Intelligence and Security Institute — Bitchat: Bluetooth Mesh Networks and Internet Shutdowns
- T. Spyropoulos, K. Psounis, C. S. Raghavendra — Spray and Wait: An Efficient Routing Scheme for Intermittently Connected Mobile Networks,ACM SIGCOMM WDTN 2005
- Bitcoin — BIP-158: Compact Block Filters for Light Clients