Summary
Two related changes to the binary protocol, each in its own commit:
-
Verify and relay signed packets over the received payload bytes (
BitFoundation).BinaryProtocol.decodeinflates a compressed payload and drops the wire bytes, so the relay re-encode andtoBinaryDataForSigningboth runCompressionUtilover the decoded payload again. DEFLATE output is encoder-specific (Apple'sCompression,java.util.zipon Android and Rustminizall emit different streams for the same input), and a sender may also have chosen not to compress a payload we would compress. The rebuilt preimage then differs from what the originator signed, and every signed packet with a compressible payload (≥ 100 bytes, < 90 % distinct) from another encoder fails verification here although its signature is valid over the bytes that were sent. bitchat-android fixed its side in permissionlesstech/bitchat-android#863; this is the same change for the Swift codec:BitchatPacket.wirePayload(received bytes + compressed flag + SHA-256 of the payload they decode to), filled bydecode, reused byencodewhile it still matches the payload, carried into the signing preimage. A locally built packet hasniland encodes exactly as before; a payload changed after decode is compressed afresh.wirePayloadis excluded fromCodable. No public signature changes.Retaining the wire bytes has a cost the stores must see: a valid DEFLATE stream can be far larger than the payload it inflates to (stored blocks; many empty blocks before a one-byte final block), and
GossipSyncManager.PacketStore, its archive restore andBLEDirectedRelaySpoolchargedpayload.countagainst their byte budgets. They now chargeBitchatPacket.retainedPayloadBytes=max(payload.count, wirePayload?.bytes.count ?? 0), which equalspayload.countfor every frame a reference encoder emits (compressed bytes are always smaller than the payload) and the stream size for a crafted one. Tests:retainedBytesCountTheWireStreamWhenItIsLarger(BitFoundation),enqueueChargesRetainedWireBytesNotJustThePayload(spool),messageStoreChargesRetainedWireBytes(gossip). -
Byte-exact wire-format vectors shared with bitchat-android.
bitchat-ios.jsonholds 181 cases produced by this codebase's own encoders at 30c58eee (1.8.0), plus two replay tests that assertBinaryProtocol,MessagePadding,CompressionUtil,PeerID,PeerCapabilities,AnnouncementPacket,AuthenticatedPeerStatePacket,PrivateMessagePacket,NoisePayload,BitchatFilePacketandBLEOutboundFragmentPlannerstill emit and accept exactly these bytes. The same file is replayed by the Android client (permissionlesstech/bitchat-android#994) and by a third-party Rust implementation, so the three stay interoperable by construction: a change to the wire format now fails a unit test on every platform and has to ship with regenerated vectors instead of being discovered in the field. The vectors also pin receive-side behaviour that has no other test today (trailing bytes accepted after a frame,HAS_ROUTEignored on v1,PrivateMessagePacketrejecting unknown TLVs,BitchatFilePacketrejecting its own encoding of empty content, non-minimal capability encodings rejected byAuthenticatedPeerStatePacket). -
A real bitchat-android frame pins the fix (
BinaryProtocolWirePayloadTests.stockAndroidFrameKeepsTheAndroidSigningPreimage). The payload "A" followed by 33 x "B", repeated to 300 bytes, compresses differently underjava.util.zip.Deflater(zlib level 6, Android) and Apple'sCOMPRESSION_ZLIB(level 5). The test decodes the signed v1 frame bitchat-android actually emits for it (case10_v1_300_a_b33_sig22in #994's vector file) and asserts that the signing preimage is Android's bytes. The same assertion fails onmain@ 30c58eee: that is the stock-iOS verification failure against stock Android, reproduced with a native Android frame instead of a hand-built stored block. The vector file gains the matching iOS-side casescompress_a_b33_300andv1_a_b33_300_signed.
Companion PR: permissionlesstech/bitchat-android#994 replays the Android codec's own vectors in the same file format.
How the vectors work
- One JSON file, two copies (
localPackages/BitFoundation/Tests/BitFoundationTests/andbitchatTests/Protocols/), because the app-target types are not visible from the package. Each test resolves the file viaSONAR_VECTOR_FILEor next to its own source (#filePath) and skips rather than fails when neither exists. - Byte fields are lowercase hex; a run of one byte repeated 1024+ times is written
repeat:xx*N(e.g.repeat:00*65535).nullmeans "encoder returned nil". compresscases are compared byte-for-byte against libcompression's raw DEFLATE and additionally assert the inflate round-trip (the real interop contract). If a future libcompression changes its output, the test comment says where to relax to the round-trip and regenerate.everyKindInTheFileIsReplayedSomewherefails if the file grows a kind neither test knows about. Each test prints oneREPLAY kind=… cases=…line.
Verification (clean checkout of main @ 30c58eee, Xcode 27 / Swift 6.4)
| Step | Result |
|---|---|
swift test in localPackages/BitFoundation | 207 tests in 16 suites passed (188 before; +8 BinaryProtocolWirePayloadTests, +11 BitchatVectorReplayTests, 101 cases) |
xcodebuild test -scheme "bitchat (iOS)" -only-testing:bitchatTests_iOS/BitchatAppVectorReplayTests (iPhone 17 Pro simulator) | 7 tests, 80 cases, 0 failures |
stockAndroidFrameKeepsTheAndroidSigningPreimage on unpatched main @ 30c58eee | fails: the re-compressed preimage is not the one Android signed |
swiftlint lint --strict (ghcr.io/realm/swiftlint:0.65.0, the CI image) over the changed files | 0 violations (SwiftLint 0.65.1 from Homebrew; the pinned 0.65.0 container image would not pull here) |
| Mutation check | five expected values corrupted in a copy of the JSON produced five failures |
The replay passes with and without commit 1, as expected: the fix only changes how decoded packets are re-encoded, and no vector encodes a decoded packet.
Notes for reviewers
WirePayload.payloadSHA256is what ties the stored bytes to the payload;BinaryProtocol.encodefalls back to fresh compression the moment the payload no longer matches, so thevar payloadmutation paths in the app keep working.wirePayloadis deliberately outsideEquatable/Codable: two packets with the same fields are the same packet whether or not one remembers its wire bytes.- The vectors are generated, not hand-written: a small Swift Testing dump is installed into a checkout of this repository at the pinned revision and run with
swift test; the fixture'snotefield names the script and the generator's location. The generator can move in-tree too if you prefer a self-contained regeneration path.
🤖 Generated with Claude Code
Activity
7💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e59747cbda
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 004b9787c5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Codex Review Summary
This comment shows the latest Codex review activity on this pull request.
004b978ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.