编程 bitchat 深度拆解:无账号、无服务器、断网也能聊——蓝牙 Mesh + Nostr 双传输架构完整解析

2026-07-28 13:45:53 +0800 CST views 10

bitchat 深度拆解:无账号、无服务器、断网也能聊——蓝牙 Mesh + Nostr 双传输架构完整解析

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 传输选择的决策逻辑

私聊消息的路由策略是一个清晰的三级决策:

  1. 蓝牙优先:如果与对方存在已建立的 Noise 会话(物理上在多跳范围内),直连发送。这是延迟最低、隐私最好的路径——消息根本不出本地无线电范围。
  2. Nostr 回退:蓝牙不可达时,用对方的 Nostr 公钥加密,通过全球中继网络投递。
  3. 智能排队:两条路都不通时,消息进入本地队列,任一传输恢复后自动补投。

用伪代码表达这个路由器的骨架:

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):每个节点收到包后向所有邻居转发,靠三件事防止网络被淹没:

  1. TTL 上限 7:包的传播半径有硬上限;
  2. 消息去重缓存:每个节点维护一个近期见过的 messageID 集合(布隆过滤器或 LRU 集合),重复包直接丢弃;
  3. 自适应占空比(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 长度覆盖范围
block7 位一个街区(约 150m × 150m)
neighborhood6 位一个社区(约 1.2km × 0.6km)
city5 位城市级(约 5km × 5km)
province4 位省/州级
region2 位国家/大区级

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 热度带偏:

  1. Mesh 覆盖范围有限。7 跳 BLE 中继在人群密集场景(演唱会、集会)表现好,但在人稀的地方,两个人隔 500 米就是永远的失联。它是「特定场景的通讯保底」,不是日常主力 IM。
  2. 消息可靠性是尽力而为。洪泛 + 排队机制没有中心服务器的送达保证,消息可能就是丢了,而且没人能告诉你丢没丢。
  3. 元数据暴露。前面说过,持久设备标识对近距离射频观察者可见。真正的高危场景使用者需要理解这一点。
  4. iOS 后台限制。苹果对后台 BLE 的调度限制意味着 App 切后台后中继能力下降,Mesh 网络的实际密度会低于理论值。
  5. 私有加密信封未经过 NIP 标准的社区审计流程,虽然构造保守(XChaCha20-Poly1305),但「自定义密码学构造」这几个字本身就该让你多一分谨慎。

八、总结:值得抄的三张设计图纸

bitchat 是否会成为主流应用不重要,重要的是它演示了三个可以直接搬进你自己项目的设计模式:

  1. 双传输 + 智能路由:任何对可用性有极端要求的系统,都应该考虑「异构冗余」——两条故障模式完全不相关的路径,比一条路径做十个 9 更可靠。这个思想同样适用于支付系统的多通道路由、IoT 的蜂窝 + LoRa 双链路。
  2. 约束驱动的协议设计:BLE 的小 MTU 逼出了二进制紧凑协议 + LZ4 + 短标识。当你在做 WebSocket 长连接或 IoT 上行协议时,同样的「每字节都要有理由」思维能省下真金白银的带宽和电量。
  3. 诚实的安全文档:明确写出「哪里有前向保密、哪里没有」「什么元数据会暴露」,这种把安全边界画清楚的文档文化,比一百句「军用级加密」的营销话术更值得信任。

在一切上云、一切依赖平台的时代,bitchat 反其道而行之的技术路线像一个思想实验:如果把互联网从通讯软件里抽走,还剩下什么?它的答案是——还剩下无线电、密码学,和口袋里那台比阿波罗登月计算机强大百万倍的设备。这个答案的工程实现,值得每个做分布式系统的人翻一翻源码。

项目地址:github.com/permissionlesstech/bitchat(Public Domain,随便抄)。

推荐文章

Vue中的`key`属性有什么作用?
2024-11-17 11:49:45 +0800 CST
Vue3 结合 Driver.js 实现新手指引
2024-11-18 19:30:14 +0800 CST
CSS 实现金额数字滚动效果
2024-11-19 09:17:15 +0800 CST
程序员茄子在线接单