Back to Blog
Galen Guan

Reading bitchat's Source: What's Actually Inside the 35.2k-Star Bluetooth Mesh Chat

When the network goes down, how does your message get out?

Not a hypothetical. During Nepal's ban on 26 social platforms in September 2025, bitchat saw close to 50,000 downloads in a single day. In the three days before Iran's nationwide shutdown in January 2026, its localized fork Noghteha passed 70,000 downloads on Google Play. Later that year India asked GitHub to geoblock its repository; the maintainers declined. As of 12 August 2026, permissionlesstech/bitchat sits at 35,251 stars, 5,614 forks, 115 open issues, with the last commit on 10 August.

Those numbers make it easy to stop at "Jack Dorsey built a Bluetooth chat app." But something that hundreds of thousands of people installed during real blackouts deserves a read at the code level — because "Bluetooth mesh" hides a pile of non-obvious engineering decisions, and the quality of those decisions is what determines whether the app drowns itself when two thousand phones in a square are all running it.

I pulled the repo and read it (521 Swift files, ~154k lines). Here are three things you only see by opening the source, and where they leave Briar, Meshtastic, and Bridgefy.

Two transports, and an underrated router

The map first. bitchat has two independent transports: BLE mesh for offline, Nostr for online. That much is in the README.

bitchat's dual transport stack: MessageRouter degrades through four tiers above BLE mesh and Nostr

What the README doesn't say is that the MessageRouter in between is not an "internet? use it : fall back to Bluetooth" switch. It degrades through four conditions, and the comment on each tier explains why that tier cannot be optimized away. More on that below.

The stack itself is disciplined: the Swift package has exactly one external dependency (swift-secp256k1 0.21.1); the other three are local packages in-repo, one of which (Arti) binds the Rust implementation of Tor. iOS 16+ / macOS 13+. The license is the Unlicense — released into the public domain, looser than MIT. Android is a separate repo (7,430 stars) under GPL-3.0.

1. The real brake on flooding is jitter, not TTL

The whitepaper describes mesh relay as a "controlled flood" and mentions that TTL is clamped by connection density. That's true, and it buries the lede.

Start with fanout. BLEFanoutSelector.swift decides how many links a broadcast actually goes out on:

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))
}

That's k = bit_length(n−1) + 1 — logarithmic. Eight links send on four, sixteen send on five, sixty-four send on seven: the share drops from 50% to 11%. Which seven is not random:

let data = (seed + "::" + id).data(using: .utf8) ?? Data()
let digest = Array(SHA256.hash(data: data))
scored.append((digest, id))

seed is the messageID. Hash SHA256(messageID + "::" + linkID) per link, sort, take the first k. This is rendezvous hashing: stateless, deterministic, independently recomputable on two devices, and because every message reshuffles the set, long-run load across links stays even. Three packet types are exempted and go out on every link — announce, fragment, requestSync. The comment on the announce case is precise about why: the announce is the packet that binds a link to a peer, so subsetting it starves duplicate same-peer links of the announce they need, leaving them permanently "pre-announce" — at which point every broadcast sprays down all of them anyway.

Logarithmic fanout curve and the degree-scaled jitter window: 64 links send on 7, and denser graphs wait longer before relaying

Now the interesting part. RelayController.decide() is the single relay decision point, and it ends like this:

// 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 clamping lives in the same function: degree ≥ 6 clamps to 5, degree ≤ 2 relays at full incoming depth, the middle band gets 6 (7 for announces and urgent board posts). But TTL only moves between 7 and 5 — two hops. The jitter window stretches from 40 ms to 220 ms — 5.5×.

The second one is what saves airtime. The reasoning is in the comment: the longer you wait before relaying, the likelier you are to receive someone else's copy first and cancel your own. TTL bounds how far a message travels; jitter determines how many redundant copies get suppressed at each hop. In a square with two thousand people, the latter is an order-of-magnitude difference and the former is not.

The cost is flagged in the code too: sparse graphs must relay fast (10–40 ms), because on a thin chain waiting triggers "cancellation races" — everyone waits for everyone, nobody relays. So jitter can't simply be turned up globally.

Flooding also isn't the only path. docs/SOURCE_ROUTING.md specifies a v2 source-routing extension: a sender can embed an explicit relay path in the header (HAS_ROUTE flag 0x08), and a node that finds itself on the path forwards directionally and suppresses the flood. Both iOS and Android decode and forward routed packets; iOS origination is policy-gated and only fires when every node on the path has been observed speaking v2. That's a clear trajectory: broadcast mesh drifting toward hybrid mesh.

2. The sync filter is borrowed from Bitcoin

When a node comes back into range it needs to learn which historical messages it's missing. The naive answer is to send a list of message IDs — 16 bytes each, 16 KB for a thousand, which over BLE is a disaster.

bitchat's answer is Sync/GCSFilter.swift. From the file header:

// 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.

This is BIP-158 — Bitcoin's compact block filters, designed for light clients in 2017 — ported almost verbatim onto mesh message sync. P derives from a target false-positive rate (P = ceil(log2(1/f)), FPR ≈ 1/2^P), costing roughly P+2 bits per element. At P=10 (~0.1% FPR), a thousand message IDs compress to about 1.5 KB, an order of magnitude under the raw list.

This is not a coincidence — it's the same social circle. Reusing a filter that has survived eight years of hostile networks beats inventing a Bloom variant: at equal FPR, Bloom runs about 40% larger and doesn't support delta encoding.

The part that shows real engineering care is the overflow handling:

// 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)
}

When encoding blows the byte budget, it shrinks by 10% at a time and drops from the input tail. Callers pass IDs newest-first, so what survives is always a contiguous newest-prefix rather than an arbitrary slice in hash order — which is exactly what lets the peer derive an exact time cursor. The returned includedCount field exists to serve that one property.

Details like this are a reliable signal: the author knows that "truncate" and "drop half in hash order" are entirely different things downstream.

3. Turning strangers into couriers: spray-and-wait, industrialized

What if the recipient isn't in range at all? That's the classic delay-tolerant networking problem, and bitchat's answer is couriers.

CourierStore.swift names the algorithm in a field comment:

/// 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 is Spyropoulos et al.'s 2005 DTN routing scheme: give each message a copy budget, halve it when carriers meet, and once it hits 1 just carry without spreading further. Its value is being far cheaper than epidemic routing while keeping most of the delivery rate. bitchat implements binary spray: 4 copies initially, capped at 8, halved when two couriers meet.

The quotas are more interesting than the algorithm:

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
}

Two trust tiers: mutual favorites deposit up to 5 each, signature-verified strangers 2 each, and the stranger tier is globally capped at 20 so it can never squeeze out a favorite's share. That solves a concrete attack — without tiering, an adversary with a pile of fabricated identities could stuff your mailbag until real mail has nowhere to go.

Back to MessageRouter. Its four-tier degradation looks like this:

Four-tier private-message degradation: from an established Noise session, to no secure session, to the Nostr queue, to handing a sealed copy to strangers

The tier-2 comment is the most worthwhile paragraph in the repo:

// "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.

It does three things at once: names a concrete attack (a replayed announce binds an absent peer's ID to the attacker's link, stalling the send on a handshake that can never complete), admits the metadata price paid to counter it (nearby peers learn a DM to X exists), and leaves a standing order for future maintainers. That's more effective than any architecture doc — it nails a seemingly redundant call in place. It's Chesterton's Fence implemented at the code level: the author anticipated "why is this here?" and carved the answer into the fence.

A note on prekeys. Services/Prekeys/LocalPrekeyStore.swift adds one-time prekeys (Signal's X3DH shape) to give offline mail forward secrecy. It's honest about its own seam:

/// 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.

The forward-secrecy clock starts at deletion, not first open. That's a structural conflict between spray-and-wait and forward secrecy: the recipient cannot distinguish a redelivery from a fresh ciphertext, so the window can only be kept short — never reasoned away.

4. A split identity model

Everything so far reads well. Now the big problem.

On the Nostr side, identity derives like this (NostrIdentityBridge.swift):

/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material

One independent secp256k1 key per geohash cell. Your key in one neighborhood is unlinkable to your key in another, and it's derived deterministically — no key store to maintain, and returning to the same cell restores the same identity. Clean and correct.

On the BLE side, docs/PEER-ID-ROTATION.md spends a full section on the status quo. Its candor is worth quoting at length:

Today a passive listener with a BLE dongle, standing in a crowd, can do the following with no cryptographic attack and no active participation:

  1. Detect that a phone is running bitchat. The service UUID is a fixed constant.
  2. Assign that phone a permanent identifier. The 8-byte sender ID in every packet header is SHA-256(noiseStaticPublicKey)[0..8], and the Noise static key is generated once and kept in the keychain. It does not rotate. Same phone, same bytes, next week, next city.
  3. Learn the phone's long-term public keys and its self-chosen nickname. The announce carries the 32-byte Noise static key, the 32-byte Ed25519 signing key, and the nickname, all in cleartext, re-broadcast every 4–30 seconds.
  4. Reconstruct who was standing near whom. The announce also carries up to ten neighbour IDs, so one receiver gets the local adjacency graph without needing several receivers or signal-strength trilateration.

iOS BLE address randomization does not help. It randomizes the link-layer address underneath an application layer that publishes a stable identifier above it.

Same app: unlinkable per-geohash identities on Nostr, a permanent fingerprint plus a social graph broadcast to everyone within radio range on BLE. And BLE is the side built for blackout protests.

The most valuable line in that document is its self-correction on the fix:

The correction that matters most: rotating the peer ID alone accomplishes nothing. As long as the announce carries the static keys in cleartext, a rotated ID is re-linked to the same device on its first announce. Rotation and announce confidentiality have to land together or not at all.

The reason is that peerID == SHA-256(noiseStaticPublicKey)[0..8] is not a convention — it's the mechanism that makes peer IDs unforgeable, enforced at both announce preflight and link binding. Remove it and you owe the protocol a replacement identity binding. That's the flip side of the same tradeoff behind iroh dialing public keys instead of IPs: anchoring the address to the key buys self-certifying addressing at the cost of an address that inherently cannot rotate. iroh can dilute that cost on the internet with relays and short-lived connections; a BLE beacon has no such luxury.

The document's status line reads: derivations and wire format implemented and tested, nothing wired into the shipping mesh — BLEService parses the new announceV2 = 0x2C type and explicitly ignores it. Which means that as of August 2026, the problem is still live in the App Store build.

Against the field

These four axes were chosen because each maps to a distinct failure. Transport decides what you have left when the network dies. Crypto and authentication decides how much you lose when ciphertext is captured. Metadata resistance decides whether using the tool exposes you. Audit history decides whether the first three claims are believable. Feature count and UI polish don't make the top four in a blackout.

bitchat Briar Meshtastic Bridgefy
Transport BLE mesh + Nostr (optional Tor) Tor + Bluetooth + Wi-Fi Direct + USB stick LoRa (dedicated hardware) BLE / Wi-Fi mesh
Range ~30 m per hop, ~300 m multi-hop Bluetooth 10–30 m, Wi-Fi Direct ~150 m Kilometers ~100 m per hop
Crypto Noise XX (live sessions) + one-time prekeys (offline mail) Bramble protocol suite, purpose-built for DTN AES256-CTR + per-channel PSK No effective confidentiality early on; since rewritten
Forward secrecy Yes for live sessions; prekeys for offline mail, with a grace window Yes No (documented; vulnerable to harvest-now-decrypt-later) Yes in modern versions
Message authentication Ed25519 signatures Yes No (anyone with the PSK can impersonate anyone on the channel) Yes in modern versions
Metadata resistance Unlinkable per geohash on Nostr; permanent ID + cleartext neighbour list on BLE All traffic over Tor; the most conservative documented threat model Cleartext headers carry node IDs; trackable long-term Social-graph reconstruction demonstrated in published research
Independent audit None; community reports + private GitHub disclosure Cure53, 2017 — 12 findings, all fixed No formal audit; limitations published by the project CT-RSA 2021 paper broke the early version comprehensively
Barrier to entry Install an app Install an app Buy hardware (~$30–80/node) Install an app
License Unlicense (iOS) / GPL-3.0 (Android) GPL-3.0 GPL-3.0 Closed-source SDK

Some necessary qualifications.

Bridgefy is the cautionary tale of this category. Albrecht, Blasco, Jensen and Mareková's Mesh Messaging in Large-scale Protests: Breaking Bridgefy (CT-RSA 2021) showed that the app then in use permitted user tracking, offered no authenticity and no effective confidentiality, and could be shut down network-wide by a single crafted message — while being actively recommended to protesters in Hong Kong, India, Iran, and Belarus. It has since rewritten its protocol, but the case establishes a rule: in this field, "already used at scale in protests" is not evidence of security. It's a reason to demand an audit.

Meshtastic isn't a competitor, it's a different species. It runs LoRa at kilometer range, and the price is buying hardware per node (the firmware repo has 8,108 stars as of 12 August 2026). Its crypto weaknesses are stated plainly in its own docs: AES256-CTR with a channel PSK, no integrity check, no forward secrecy, and anyone holding the PSK can impersonate anyone on that channel. That's acceptable in its native use case (outdoor comms, emergency prep) and unacceptable under an adversary. Meshtastic's candor is a point in its favor — it never claimed to be a secure messenger.

Briar is the only one with an independent audit record. Cure53 put six testers and thirteen person-days into it in March 2017 and produced 12 findings (one high-severity DNS leak, several medium), all since fixed, with the report calling the source quality "rather exceptional." Its Bramble suite was designed for delay-tolerant networks rather than retrofitted from an online protocol. The price is a far smaller ecosystem — the GitHub mirror shows 677 stars as of 12 August 2026 (primary development lives on a self-hosted GitLab), and the UI is genuinely behind bitchat's.

Academia has moved on. Amigo, at ACM CCS 2025, proposes secure group mesh messaging for realistic protest settings. Which tells you something: bitchat's metadata problem is not an unsolvable physical limit, it's an available solution not yet adopted.

An honest scorecard

Scoring the four axes above, plus engineering quality and reusability:

Axis Score Why
Routing & congestion control 9/10 Logarithmic fanout, degree-tiered jitter, and a source-routing path forward — the most complete set I've seen in an open-source mesh. Docked for flooding still being the default and source routing not fully enabled
Store-and-forward 8/10 Spray-and-wait with two-tier trust quotas; the attack surface is clearly reasoned about. Docked for the prekey grace window weakening forward secrecy
Cryptographic engineering 7/10 Noise XX used properly, prekeys close the offline forward-secrecy gap. But the bespoke Nostr envelope format, abandoning NIP-44 compatibility, is a pure loss
Metadata resistance 4/10 Never-rotating peer ID, cleartext static keys, cleartext neighbour list. The fix is well written and unshipped. The only shortfall here that can get someone hurt
Code readability 9/10 521 files of aggressive decomposition, nearly every policy an independently testable pure function; comments explain why, not what
Trustworthiness 5/10 No third-party audit, and a CVSS 9.8 buffer overflow in its history. But the disclosure process is disciplined, and SECURITY.md draws an explicit line between vulnerabilities and documented design properties

Conclusion

If you're deciding whether to rely on it in a high-risk setting: not on its own, not yet. A permanent peer ID plus a cleartext neighbour list means an adversary parked at the edge of a square can learn who was present, who stood next to whom, and who shows up again in another city next week — without breaking any encryption. That is precisely the class of information that hurts people in this scenario. Revisit once announceV2 ships together with announce confidentiality; the fix is specified more clearly than most features that have already shipped.

If you're building P2P or offline-first systems: three things transfer directly. First, use the jitter window, not TTL, as your primary congestion control — TTL bounds propagation radius, jitter determines the duplicate suppression rate, and in dense networks the latter is an order of magnitude. Second, use a GCS filter for set reconciliation — BIP-158 has eight years of hostile-network validation behind it and beats hand-tuning Bloom parameters; keep the trim-from-the-tail property. Third, write down why a redundant-looking call cannot be deletedMessageRouter's "Don't optimize the courier call away" is the most effective single comment line I've read this year.

Don't copy: the bespoke Nostr envelope. Dropping NIP-17/44/59 compatibility bought a permanent island that only bitchat clients can read, for zero gain. Classic "we could write our own, so we wrote our own."

One closing observation. This project's best artifact may not be code but docs/PEER-ID-ROTATION.md: it turns the product's most serious privacy defect into a public specification with executable test vectors, labels it implemented-but-unwired, and states plainly why half a fix is more dangerous than none. Plenty of open-source projects can write a logarithmic fanout. Far fewer will write that down and leave it in the repo.

Sources

  1. permissionlesstech — bitchat main repository (iOS/macOS, Swift) (as of 2026-08-12: 35,251 stars / 5,614 forks, last commit 2026-08-10)
  2. permissionlesstech — bitchat WHITEPAPER.md
  3. permissionlesstech — bitchat-android (as of 2026-08-12: 7,430 stars, GPL-3.0)
  4. Source files cited in this article: 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
  5. GitHub Issue #376 — BitChat protocol security report (BinaryProtocol.swift audit, including a CVSS 9.8 buffer overflow)
  6. Martin R. Albrecht, Jorge Blasco, Rikke Bjerg Jensen, Lenka Mareková — Mesh Messaging in Large-scale Protests: Breaking Bridgefy, CT-RSA 2021 (May 2021)
  7. ACM CCS 2025 — Amigo: Secure Group Mesh Messaging in Realistic Protest Settings
  8. Briar Project — Darknet Messenger Releases Beta, Passes Security Audit (Cure53 audit, March 2017)
  9. Meshtastic — Known Limitations and Future Plans of Meshtastic's Encryption
  10. Meshtastic — Encryption Overview
  11. Rest of World — Why India asked GitHub to geoblock Jack Dorsey's Bitchat code (2026)
  12. Bloomsbury Intelligence and Security Institute — Bitchat: Bluetooth Mesh Networks and Internet Shutdowns
  13. T. Spyropoulos, K. Psounis, C. S. Raghavendra — Spray and Wait: An Efficient Routing Scheme for Intermittently Connected Mobile Networks, ACM SIGCOMM WDTN 2005
  14. Bitcoin — BIP-158: Compact Block Filters for Light Clients