Four codecs, one wire — how we test Sonar against bitchat

Sonar's iOS and Android apps spoke two slightly different dialects of the bitchat Bluetooth protocol, and neither matched the reference clients exactly. We moved the wire format into one Rust crate, pinned it with test vectors generated by bitchat's own code, and now hold four implementations to each other on every CI run. Along the way we found one bug that breaks stock bitchat iOS against stock bitchat-android, and sent the fix upstream.

Vincenzo Palazzo

29. Oktober 2026October 29, 2026 · 8 Min. Lesezeit8 min read

Four codecs, one wire — how we test Sonar against bitchat

Sonar finds people nearby over the bitchat Bluetooth mesh. That only works if every phone in range agrees on the bytes: a Sonar user on Android has to talk to a Sonar user on iOS, and both have to talk to people running stock bitchat on either platform. That makes four implementations of one protocol:

iOS Android
Stock bitchat bitchat 1.8.0 (Swift) bitchat-android 2.0.3 (Kotlin)
Sonar the app's Swift codec the Rust core, shared with desktop

For a long time our test for "do they interoperate" was two phones on a desk. Short private messages went through, so it looked fine. It wasn't, and this post is about what we built so we no longer have to guess.

What two phones on a desk hid#

Three bugs, all invisible in a quick test:

  1. Compressed frames vanished on Android. Both reference clients compress a payload with raw DEFLATE when it is at least 100 bytes and repetitive enough, and they sign the compressed frame. Sonar's Rust decoder returned nothing for a frame with the compressed flag set. Private messages are encrypted, and ciphertext never compresses, so DMs worked. Long public messages and long announces from iOS simply never arrived on Android.
  2. iOS rejected Android's signatures. To check a signature, Sonar's Swift codec rebuilt the signed bytes by compressing the decoded payload again with Apple's encoder. A frame that had been sent uncompressed, as Sonar Android sent everything, or compressed by any other encoder, no longer matched, so it failed verification. That was our long-standing "Sonar announce unverified" bug.
  3. One lost Bluetooth frame killed an encrypted session. The Rust Noise transport insisted that every message carry exactly the next nonce. Bluetooth drops frames, and one drop meant a new handshake. The reference clients accept any nonce inside a 1024-message sliding window.

There was a fourth problem pointing the other way. Sonar adds a few fields to private messages (longer text, a reply's parent) and to files. Both stock decoders reject a whole message when they meet a field they don't know, so a Sonar user writing to a stock bitchat user could lose the message entirely. Sonar now sends those fields only to a peer that has proved, with a signed announce, that it runs Sonar.

One crate, written from the code#

The fix started by giving the wire format a single home: core/bitchat-protocol, a Rust crate for the packet frame, the payload fields, Ed25519 packet signatures, peer ids and the Noise session with bitchat's framing. Sonar Android uses it directly. Sonar iOS still carries a Swift copy of the codec, kept in step until it moves onto the crate.

Its SPEC.md is written from the two reference clients' source, not from their documentation, and names the exact revisions it was read from. Where iOS and Android disagree, it says which one the crate follows and why.

One rule in it fixes bug 2 for good: a verifier builds the signed bytes from the payload exactly as it arrived, never by compressing it again. A decoded packet keeps its wire bytes, and re-encoding it (a relay lowering the hop count, say) reuses them. bitchat-android already worked this way.

Test vectors from bitchat itself#

A spec you wrote yourself only proves you agree with yourself. So the test vectors don't come from us. A small generator is dropped into each upstream repository, runs inside that project's own test harness against its real codec, and dumps what it produced: 181 cases from bitchat iOS and 93 from bitchat-android, every packet shape, payload field, padding bucket and compression edge we could build. The crate replays all of them byte for byte.

Upstream moves, so the vectors are regenerated rather than maintained. Every day, and on every pull request that touches the crate, CI clones both upstream repositories at their latest commit, runs the generators again and diffs the result case by case against the committed files. A second check watches the upstream codec files themselves, so a new field the generators don't cover yet still shows up as drift, not as a bug report from a user.

Holding four codecs to each other#

Vectors prove each Sonar codec matches each reference on its own. They don't show what happens when stock iOS decodes a frame stock Android built. For that there is a cross-replay harness. It turns both vector files into neutral operations (encode these fields, decode that frame, rebuild the signed bytes, re-encode it for a relay) and runs every operation through all four codecs:

Each codec also replays its own file, and must agree with it completely; that is how we know the harness itself is sound. Every remaining difference is an entry in a ledger file with its consequence on the wire. A new difference fails CI, and so does an entry that stops reproducing. That second rule is how upstream progress shows up: when bitchat-android adopted iOS's per-type size limits, the harness flagged our ledger entry as stale before we had read the commit.

Today the two Sonar codecs agree on every field of every operation, with no ledger entry between them.

The one bug between the stock apps#

The harness found seven differences between stock iOS and stock Android. Six are features one side has and the other lacks, or edge cases no current build produces. One breaks real traffic: stock bitchat iOS still verifies by compressing again, the same mistake as our bug 2.

That alone would be harmless if every encoder produced the same stream, and they don't. We measured 76 payloads built to separate zlib's compression levels. Apple's encoder turned out to be exactly reference zlib at level 5. The Java encoder bitchat-android uses defaults to level 6, and the two produce different bytes for 16 of the 76. So some ordinary signed frames from bitchat-android fail verification on stock bitchat iOS. Our vectors include a real Android frame that stock iOS rejects. In practice it hits long signed public messages and announces carrying a payment address; short announces and all encrypted traffic are unaffected.

We sent the fix upstream rather than only working around it:

Both were green on the upstream checkouts before we opened them. Both are still open as we write this.

How Sonar sidesteps it meanwhile#

Until stock iOS changes, a sender is safe only if it produces exactly Apple's stream. So the crate compresses with reference zlib built from source at level 5. Not miniz_oxide or zlib-rs, which produce different streams again, and never Android's system zlib, which is Chromium's fork. With that, Sonar's encoded packets, compressed ones included, are byte-identical to stock iOS's for every case in the vectors. Stock iOS verifies its own frames, so it verifies Sonar's too. Stock Android verifies over the bytes it received, so Sonar's frames pass there as well.

What this does not prove#

All of this is about bytes. No vector runs a live Noise handshake between two radios, times Bluetooth fragmentation, or relays a message through a third phone. That needs physical phones, and we have written the test plan down rather than pretending the vectors cover it: seven scenarios, from discovery to a relayed signed message to a file sent to a stock client, run in both directions for every pair of the four apps. We have not run that full matrix yet.

If you have a phone with stock bitchat and a friend with Sonar 0.1-alpha.15.5 or newer, send each other a short and a long public message and a private one. If anything arrives garbled or not at all, Settings → Diagnostics → Share in Sonar, and tell us which app and version was on the other side.

Use it#

The crate is on crates.io as bitchat-protocol, version 0.0.1. The 0.0 means the API may still move, not the wire format: the wire format is whatever the vectors say. If you are writing another bitchat client, the vectors and the cross-replay harness are the parts most worth borrowing. Running the replay against your own codec is the fastest way to find out which dialect you speak.

#engineering#sonarblogpost

Written by

Comments

No comments yet. Start the conversation.

Contents

8 sections

  1. What two phones on a desk hid
  2. One crate, written from the code
  3. Test vectors from bitchat itself
  4. Holding four codecs to each other
  5. The one bug between the stock apps
  6. How Sonar sidesteps it meanwhile
  7. What this does not prove
  8. Use it