mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 22:45:20 +00:00
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:
@@ -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?
|
characteristic: BluetoothGattCharacteristic?
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val packet = routed.packet
|
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
|
val isFile = packet.type == MessageType.FILE_TRANSFER.value
|
||||||
if (isFile) {
|
if (isFile) {
|
||||||
Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes")
|
Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes")
|
||||||
@@ -261,7 +263,9 @@ class BluetoothPacketBroadcaster(
|
|||||||
characteristic: BluetoothGattCharacteristic?
|
characteristic: BluetoothGattCharacteristic?
|
||||||
) {
|
) {
|
||||||
val packet = routed.packet
|
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 typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
|
||||||
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
|
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
|
||||||
val incomingAddr = routed.relayAddress
|
val incomingAddr = routed.relayAddress
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ data class BitchatPacket(
|
|||||||
ttl = ttl
|
ttl = ttl
|
||||||
)
|
)
|
||||||
|
|
||||||
fun toBinaryData(): ByteArray? {
|
fun toBinaryData(padding: Boolean = true): ByteArray? {
|
||||||
return BinaryProtocol.encode(this)
|
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 {
|
||||||
// Try to compress payload if beneficial
|
// Try to compress payload if beneficial
|
||||||
var payload = packet.payload
|
var payload = packet.payload
|
||||||
@@ -310,11 +310,13 @@ object BinaryProtocol {
|
|||||||
buffer.rewind()
|
buffer.rewind()
|
||||||
buffer.get(result)
|
buffer.get(result)
|
||||||
|
|
||||||
// Apply padding to standard block sizes for traffic analysis resistance
|
// Apply padding if requested (iOS-compatible: selective padding for privacy)
|
||||||
val optimalSize = MessagePadding.optimalBlockSize(result.size)
|
if (padding) {
|
||||||
val paddedData = MessagePadding.pad(result, optimalSize)
|
val optimalSize = MessagePadding.optimalBlockSize(result.size)
|
||||||
|
return MessagePadding.pad(result, optimalSize)
|
||||||
|
}
|
||||||
|
|
||||||
return paddedData
|
return result
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("BinaryProtocol", "Error encoding packet type ${packet.type}: ${e.message}")
|
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
|
package com.bitchat.android.protocol
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -368,6 +370,54 @@ class BinaryProtocolTest {
|
|||||||
medPayload.contentEquals(medDecoded.payload))
|
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
|
* Oversized packet bypasses padding
|
||||||
*
|
*
|
||||||
@@ -1141,6 +1191,14 @@ class BinaryProtocolTest {
|
|||||||
return decoded!!
|
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) {
|
private fun assertPacketEquals(expected: BitchatPacket, actual: BitchatPacket) {
|
||||||
assertEquals("version", expected.version, actual.version)
|
assertEquals("version", expected.version, actual.version)
|
||||||
assertEquals("type", expected.type, actual.type)
|
assertEquals("type", expected.type, actual.type)
|
||||||
|
|||||||
Reference in New Issue
Block a user