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?
|
||||
): 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
|
||||
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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user