feat: Implement iOS-compatible selective padding for BLE messages (#501)

* feat: Implement iOS-compatible selective padding for BLE messages

* regression tests for padding

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
Hamza Öztürk
2026-06-25 22:45:03 +02:00
committed by GitHub
co-authored by callebtc
parent 793b215457
commit c26460ae02
5 changed files with 224 additions and 9 deletions
@@ -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
}
}
}
@@ -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
@@ -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
// Apply padding if requested (iOS-compatible: selective padding for privacy)
if (padding) {
val optimalSize = MessagePadding.optimalBlockSize(result.size)
val paddedData = MessagePadding.pad(result, optimalSize)
return MessagePadding.pad(result, optimalSize)
}
return paddedData
return result
} catch (e: Exception) {
Log.e("BinaryProtocol", "Error encoding packet type ${packet.type}: ${e.message}")
@@ -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 }
}
}
@@ -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)