diff --git a/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt b/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt new file mode 100644 index 00000000..279fe527 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt @@ -0,0 +1,18 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.protocol.MessageType + +/** + * iOS-compatible BLE padding policy. + * + * Keep this aligned with iOS BLEOutboundPacketPolicy.padsBLEFrame(for:): + * only Noise frames are padded over BLE. + */ +object BLEPacketPaddingPolicy { + fun shouldPadForBLE(type: UByte): Boolean { + return when (MessageType.fromValue(type)) { + MessageType.NOISE_ENCRYPTED, MessageType.NOISE_HANDSHAKE -> true + else -> false + } + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index efb3c4df..3c49315e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -170,7 +170,9 @@ class BluetoothPacketBroadcaster( characteristic: BluetoothGattCharacteristic? ): Boolean { val packet = routed.packet - val data = packet.toBinaryData() ?: return false + // iOS-compatible: Use selective padding policy for BLE + val padForBLE = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type) + val data = packet.toBinaryData(padding = padForBLE) ?: return false val isFile = packet.type == MessageType.FILE_TRANSFER.value if (isFile) { Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes") @@ -261,7 +263,9 @@ class BluetoothPacketBroadcaster( characteristic: BluetoothGattCharacteristic? ) { val packet = routed.packet - val data = packet.toBinaryData() ?: return + // iOS-compatible: Use selective padding policy for BLE + val padForBLE = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type) + val data = packet.toBinaryData(padding = padForBLE) ?: return val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString() val senderPeerID = routed.peerID ?: packet.senderID.toHexString() val incomingAddr = routed.relayAddress diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 88e5f783..a952d5fa 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -79,8 +79,8 @@ data class BitchatPacket( ttl = ttl ) - fun toBinaryData(): ByteArray? { - return BinaryProtocol.encode(this) + fun toBinaryData(padding: Boolean = true): ByteArray? { + return BinaryProtocol.encode(this, padding = padding) } /** @@ -198,7 +198,7 @@ object BinaryProtocol { } } - fun encode(packet: BitchatPacket): ByteArray? { + fun encode(packet: BitchatPacket, padding: Boolean = true): ByteArray? { try { // Try to compress payload if beneficial var payload = packet.payload @@ -310,11 +310,13 @@ object BinaryProtocol { buffer.rewind() buffer.get(result) - // Apply padding to standard block sizes for traffic analysis resistance - val optimalSize = MessagePadding.optimalBlockSize(result.size) - val paddedData = MessagePadding.pad(result, optimalSize) + // Apply padding if requested (iOS-compatible: selective padding for privacy) + if (padding) { + val optimalSize = MessagePadding.optimalBlockSize(result.size) + return MessagePadding.pad(result, optimalSize) + } - return paddedData + return result } catch (e: Exception) { Log.e("BinaryProtocol", "Error encoding packet type ${packet.type}: ${e.message}") diff --git a/app/src/test/java/com/bitchat/android/mesh/BLEPacketPaddingPolicyTest.kt b/app/src/test/java/com/bitchat/android/mesh/BLEPacketPaddingPolicyTest.kt new file mode 100644 index 00000000..96aba61c --- /dev/null +++ b/app/src/test/java/com/bitchat/android/mesh/BLEPacketPaddingPolicyTest.kt @@ -0,0 +1,133 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.protocol.BinaryProtocol +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessagePadding +import com.bitchat.android.protocol.MessageType +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class BLEPacketPaddingPolicyTest { + private val senderID = byteArrayOf(0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x00) + private val timestamp = 1_709_600_000_000uL + + @Test + fun `BLE padding policy matches iOS packet type table`() { + val expected = mapOf( + MessageType.ANNOUNCE to false, + MessageType.MESSAGE to false, + MessageType.LEAVE to false, + MessageType.REQUEST_SYNC to false, + MessageType.FRAGMENT to false, + MessageType.FILE_TRANSFER to false, + MessageType.NOISE_ENCRYPTED to true, + MessageType.NOISE_HANDSHAKE to true + ) + + expected.forEach { (type, shouldPad) -> + assertEquals( + "${type.name} BLE padding policy must match iOS", + shouldPad, + BLEPacketPaddingPolicy.shouldPadForBLE(type.value) + ) + } + + assertFalse( + "Unknown packet types should be sent unpadded, matching iOS default", + BLEPacketPaddingPolicy.shouldPadForBLE(0x7Fu) + ) + } + + @Test + fun `public BLE packet types are encoded without PKCS7 padding tails`() { + val publicTypes = listOf( + MessageType.ANNOUNCE, + MessageType.MESSAGE, + MessageType.LEAVE, + MessageType.REQUEST_SYNC, + MessageType.FRAGMENT, + MessageType.FILE_TRANSFER + ) + + publicTypes.forEach { type -> + val packet = packet(type.value, payload = payloadEndingWithoutPaddingShape(type)) + val raw = packet.toBinaryData(padding = false)!! + val encodedForBLE = packet.toBinaryData( + padding = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type) + )!! + + assertArrayEquals( + "${type.name} BLE encoding should be the unpadded frame", + raw, + encodedForBLE + ) + assertFalse( + "${type.name} BLE encoding must not leave iOS-visible PKCS#7 tail bytes", + hasPkcs7PaddingTail(encodedForBLE) + ) + assertNotNull( + "${type.name} unpadded BLE frame must remain decodable", + BinaryProtocol.decode(encodedForBLE) + ) + } + } + + @Test + fun `Noise BLE packet types remain padded and decodable`() { + val noiseTypes = listOf(MessageType.NOISE_HANDSHAKE, MessageType.NOISE_ENCRYPTED) + + noiseTypes.forEach { type -> + val packet = packet(type.value, payload = "noise-${type.name}".toByteArray()) + val raw = packet.toBinaryData(padding = false)!! + val encodedForBLE = packet.toBinaryData( + padding = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type) + )!! + + assertTrue("${type.name} BLE frame should be padded", encodedForBLE.size > raw.size) + assertTrue( + "${type.name} BLE frame should end with valid PKCS#7 padding", + hasPkcs7PaddingTail(encodedForBLE) + ) + assertArrayEquals( + "${type.name} padding should strip back to the raw frame", + raw, + MessagePadding.unpad(encodedForBLE) + ) + assertNotNull( + "${type.name} padded BLE frame must remain decodable", + BinaryProtocol.decode(encodedForBLE) + ) + } + } + + private fun packet(type: UByte, payload: ByteArray): BitchatPacket { + val version = if (type == MessageType.FILE_TRANSFER.value) 2u.toUByte() else 1u.toUByte() + return BitchatPacket( + version = version, + type = type, + senderID = senderID, + recipientID = null, + timestamp = timestamp, + payload = payload, + signature = null, + ttl = 7u, + route = null + ) + } + + private fun payloadEndingWithoutPaddingShape(type: MessageType): ByteArray { + return "ios-ble-policy-${type.name}-z".toByteArray() + } + + private fun hasPkcs7PaddingTail(data: ByteArray): Boolean { + if (data.isEmpty()) return false + val paddingLength = data.last().toInt() and 0xFF + if (paddingLength <= 0 || paddingLength > data.size) return false + val start = data.size - paddingLength + return data.copyOfRange(start, data.size).all { (it.toInt() and 0xFF) == paddingLength } + } +} diff --git a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt index 8945088b..c1b2327b 100644 --- a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt +++ b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt @@ -1,6 +1,8 @@ package com.bitchat.android.protocol import org.junit.Assert.assertEquals +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -368,6 +370,54 @@ class BinaryProtocolTest { medPayload.contentEquals(medDecoded.payload)) } + @Test + fun `encoding with padding false returns exact unpadded frame`() { + val payload = "ios-compatible-unpadded".toByteArray() + val packet = makePacket(payload = payload) + + val unpadded = BinaryProtocol.encode(packet, padding = false)!! + val padded = BinaryProtocol.encode(packet, padding = true)!! + + assertEquals( + "v1 unpadded frame size must be header + sender + payload", + 14 + 8 + payload.size, + unpadded.size + ) + assertEquals("small padded frame should pad to 256 bytes", 256, padded.size) + assertFalse( + "padding=false must not append PKCS#7 bytes", + hasPkcs7PaddingTail(unpadded) + ) + assertArrayEquals( + "padded frame must unpad to the exact raw frame", + unpadded, + MessagePadding.unpad(padded) + ) + assertPacketEquals(packet, BinaryProtocol.decode(unpadded)!!) + assertPacketEquals(packet, BinaryProtocol.decode(padded)!!) + } + + @Test + fun `BitchatPacket toBinaryData propagates padding flag`() { + val packet = makePacket(payload = "packet-helper-padding-flag".toByteArray()) + + val defaultEncoded = packet.toBinaryData()!! + val explicitlyPadded = packet.toBinaryData(padding = true)!! + val unpadded = packet.toBinaryData(padding = false)!! + + assertArrayEquals( + "default helper must remain padded for backward compatibility", + explicitlyPadded, + defaultEncoded + ) + assertTrue("default helper should produce a padded frame", defaultEncoded.size > unpadded.size) + assertArrayEquals( + "helper padding=false must match BinaryProtocol padding=false", + BinaryProtocol.encode(packet, padding = false)!!, + unpadded + ) + } + /** * Oversized packet bypasses padding * @@ -1141,6 +1191,14 @@ class BinaryProtocolTest { return decoded!! } + private fun hasPkcs7PaddingTail(data: ByteArray): Boolean { + if (data.isEmpty()) return false + val paddingLength = data.last().toInt() and 0xFF + if (paddingLength <= 0 || paddingLength > data.size) return false + val start = data.size - paddingLength + return data.copyOfRange(start, data.size).all { (it.toInt() and 0xFF) == paddingLength } + } + private fun assertPacketEquals(expected: BitchatPacket, actual: BitchatPacket) { assertEquals("version", expected.version, actual.version) assertEquals("type", expected.type, actual.type)