From ab8c06613db84eda7716d3cb61bc225cca0cb598 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 23 Aug 2026 19:56:50 +0530 Subject: [PATCH 1/2] fix: stop fragmentation eating a signature byte off an unpadded frame createFragments encoded the packet with padding on and then called MessagePadding.unpad to strip it again. Across most of the fragmentation range no padding was ever applied: pad() declines a request needing more than 255 bytes, and optimalBlockSize() returns the input size unchanged above 2048, so frames of 513-768, 1009-1792 and >=2033 bytes come back untouched. An unpadded frame ends with the last byte of the signature, which is uniformly random. unpad reads it as a PKCS#7 pad length, and a value of 0x01 satisfies the tail check trivially, so a real signature byte is stripped. Every receiver reassembles the fragments faithfully and then fails the size check in decodeCore, which is why the message dies on all of them at once with nothing in the sender's log. Ask the encoder for the unpadded frame instead. Byte-identical for frames that really were padded, so nothing changes on the wire. --- .../bitchat/android/mesh/FragmentManager.kt | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt b/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt index 3a10f998..ada67f92 100644 --- a/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt @@ -3,7 +3,6 @@ package com.bitchat.android.mesh import android.util.Log import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType -import com.bitchat.android.protocol.MessagePadding import com.bitchat.android.model.FragmentPayload import kotlinx.coroutines.* import java.util.concurrent.ConcurrentHashMap @@ -66,20 +65,19 @@ class FragmentManager { Log.w(TAG, "Rejecting invalid outbound fragment limit: $maxFragments") return emptyList() } - val encoded = packet.toBinaryData() - if (encoded == null) { + // Fragment the unpadded frame; each fragment will be encoded (and padded) independently - iOS fix. + // Ask the encoder to skip padding rather than un-padding afterwards: + // pad() declines a request needing more than 255 bytes and + // optimalBlockSize() returns the input size above 2048, so frames of + // 513-768, 1009-1792 and >=2033 bytes are never padded. Those end + // with the last byte of the signature, and unpad() reads a value of + // 0x01 as a one-byte pad and strips a real signature byte. + val fullData = packet.toBinaryData(padding = false) + if (fullData == null) { Log.e(TAG, "Failed to encode packet to binary data") return emptyList() } - // Fragment the unpadded frame; each fragment will be encoded (and padded) independently - iOS fix - val fullData = try { - MessagePadding.unpad(encoded) - } catch (e: Exception) { - Log.e(TAG, "Failed to unpad data: ${e.message}", e) - return emptyList() - } - // iOS logic: if data.count > 512 && packet.type != MessageType.fragment.rawValue if (fullData.size <= FRAGMENT_SIZE_THRESHOLD) { return listOf(packet) // No fragmentation needed From 38bc03fb2dd1c7c298cc08b07fddcc145716782d Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 23 Aug 2026 19:56:51 +0530 Subject: [PATCH 2/2] test: cover fragmentation of frames that are never padded One case walks the three unpadded bands with a signature ending in 0x01 and asserts the fragments carry the whole frame; it loses a byte without the fix. The other pins the padded case, so the frame keeps being fragmented without its padding. --- .../android/mesh/FragmentManagerTest.kt | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt b/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt index bf3e3d01..a0be567e 100644 --- a/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt +++ b/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt @@ -3,6 +3,7 @@ package com.bitchat.android.mesh import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.model.FragmentPayload +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -244,6 +245,68 @@ class FragmentManagerTest { assertEquals(257, plan(low).size) } + /** + * pad() declines any request needing more than 255 bytes and + * optimalBlockSize() returns the input size unchanged above 2048, so frames + * of 513-768, 1009-1792 and >=2033 bytes are emitted unpadded. The frame + * then ends with the last byte of the signature, which is uniformly random, + * and a value of 0x01 reads as a one-byte PKCS#7 pad. + */ + @Test + fun `an unpadded frame keeps every byte through fragmentation`() { + for (payloadSize in listOf(600, 1500, 2400)) { + val packet = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = hexStringToByteArray(senderID), + recipientID = hexStringToByteArray(recipientID), + timestamp = 1u, + payload = ByteArray(payloadSize).also { Random(payloadSize.toLong()).nextBytes(it) }, + signature = ByteArray(64) { index -> if (index == 63) 0x01 else 0x7F }, + ttl = 7u + ) + + val frame = packet.toBinaryData(padding = false)!! + val padded = packet.toBinaryData()!! + assertEquals("frame of $payloadSize should not be padded", frame.size, padded.size) + + val fragments = fragmentManager.createFragments(packet) + assertTrue("frame of $payloadSize should fragment", fragments.size > 1) + + val reassembled = fragments + .map { FragmentPayload.decode(it.payload)!!.data } + .reduce { acc, next -> acc + next } + assertArrayEquals( + "fragments of a $payloadSize-byte payload must carry the whole frame", + frame, + reassembled + ) + } + } + + /** A frame that really was padded must still fragment unpadded. */ + @Test + fun `a padded frame is fragmented without its padding`() { + val packet = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = hexStringToByteArray(senderID), + recipientID = hexStringToByteArray(recipientID), + timestamp = 1u, + payload = ByteArray(900).also { Random(900).nextBytes(it) }, + signature = ByteArray(64) { 0x7F }, + ttl = 7u + ) + + val frame = packet.toBinaryData(padding = false)!! + assertTrue("frame should be padded", packet.toBinaryData()!!.size > frame.size) + + val reassembled = fragmentManager.createFragments(packet) + .map { FragmentPayload.decode(it.payload)!!.data } + .reduce { acc, next -> acc + next } + assertArrayEquals(frame, reassembled) + } + private fun hexStringToByteArray(hexString: String): ByteArray { val result = ByteArray(8) for (i in 0 until 8) {