mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
Merge pull request #729 from permissionlesstech/codex/bound-compressed-payload-expansion
Bound pre-auth compressed payload expansion
This commit is contained in:
commit
d814ac7f80
@ -183,7 +183,6 @@ object BinaryProtocol {
|
||||
private const val SENDER_ID_SIZE = 8
|
||||
private const val RECIPIENT_ID_SIZE = 8
|
||||
private const val SIGNATURE_SIZE = 64
|
||||
|
||||
object Flags {
|
||||
const val HAS_RECIPIENT: UByte = 0x01u
|
||||
const val HAS_SIGNATURE: UByte = 0x02u
|
||||
@ -200,6 +199,15 @@ object BinaryProtocol {
|
||||
|
||||
fun encode(packet: BitchatPacket, padding: Boolean = true): ByteArray? {
|
||||
try {
|
||||
val maxPayloadLength = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
|
||||
if (packet.payload.size > maxPayloadLength) {
|
||||
Log.w(
|
||||
"BinaryProtocol",
|
||||
"Cannot encode payload ${packet.payload.size} above receiver limit $maxPayloadLength"
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Try to compress payload if beneficial
|
||||
var payload = packet.payload
|
||||
var originalPayloadSize: Int? = null
|
||||
@ -324,21 +332,36 @@ object BinaryProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
fun decode(data: ByteArray): BitchatPacket? {
|
||||
fun decode(data: ByteArray): BitchatPacket? =
|
||||
decode(data, CompressionUtil::decompressWithResourcesReserved)
|
||||
|
||||
/** Test seam used to prove rejected expansion sizes never reach inflation. */
|
||||
internal fun decodeForTesting(
|
||||
data: ByteArray,
|
||||
decompress: (ByteArray, Int) -> ByteArray?
|
||||
): BitchatPacket? = decode(data, decompress)
|
||||
|
||||
private fun decode(
|
||||
data: ByteArray,
|
||||
decompress: (ByteArray, Int) -> ByteArray?
|
||||
): BitchatPacket? {
|
||||
// Try decode as-is first (robust when padding wasn't applied) - iOS fix
|
||||
decodeCore(data)?.let { return it }
|
||||
decodeCore(data, decompress)?.let { return it }
|
||||
|
||||
// If that fails, try after removing padding
|
||||
val unpadded = MessagePadding.unpad(data)
|
||||
if (unpadded.contentEquals(data)) return null // No padding was removed, already failed
|
||||
|
||||
return decodeCore(unpadded)
|
||||
return decodeCore(unpadded, decompress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Core decoding implementation used by decode() with and without padding removal - iOS fix
|
||||
*/
|
||||
private fun decodeCore(raw: ByteArray): BitchatPacket? {
|
||||
private fun decodeCore(
|
||||
raw: ByteArray,
|
||||
decompress: (ByteArray, Int) -> ByteArray?
|
||||
): BitchatPacket? {
|
||||
try {
|
||||
if (raw.size < HEADER_SIZE_V1 + SENDER_ID_SIZE) return null
|
||||
|
||||
@ -435,23 +458,45 @@ object BinaryProtocol {
|
||||
} else {
|
||||
buffer.getShort().toUShort().toInt()
|
||||
}
|
||||
|
||||
val maxExpandedSize = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
|
||||
if (originalSize <= 0 || originalSize > maxExpandedSize) {
|
||||
Log.w(
|
||||
"BinaryProtocol",
|
||||
"Expanded payload size $originalSize is outside the allowed range 1..$maxExpandedSize"
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Compressed payload
|
||||
val compressedSize = payloadLength.toInt() - lengthFieldBytes
|
||||
val compressedPayload = ByteArray(compressedSize)
|
||||
buffer.get(compressedPayload)
|
||||
|
||||
if (compressedSize == 0) {
|
||||
Log.w("BinaryProtocol", "Compressed payload has no deflate bytes")
|
||||
return null
|
||||
}
|
||||
// Security check: Compression bomb protection
|
||||
if (compressedSize > 0) {
|
||||
val ratio = originalSize.toDouble() / compressedSize.toDouble()
|
||||
if (ratio > 50_000.0) {
|
||||
Log.w("BinaryProtocol", "🚫 Suspicious compression ratio: ${ratio}:1")
|
||||
return null
|
||||
}
|
||||
val ratio = originalSize.toDouble() / compressedSize.toDouble()
|
||||
if (ratio > 50_000.0) {
|
||||
Log.w("BinaryProtocol", "🚫 Suspicious compression ratio: ${ratio}:1")
|
||||
return null
|
||||
}
|
||||
|
||||
// Decompress
|
||||
CompressionUtil.decompress(compressedPayload, originalSize) ?: return null
|
||||
// Reserve the compressed copy plus expanded output before either allocation.
|
||||
// Small packets share the memory pool; packets wait only while its budget is full.
|
||||
val resourceBytes = compressedSize.toLong() + originalSize.toLong()
|
||||
val expandedPayload = CompressionUtil.withDecompressionResources(resourceBytes) {
|
||||
val compressedPayload = ByteArray(compressedSize)
|
||||
buffer.get(compressedPayload)
|
||||
decompress(compressedPayload, originalSize)
|
||||
} ?: return null
|
||||
if (expandedPayload.size != originalSize) {
|
||||
Log.w(
|
||||
"BinaryProtocol",
|
||||
"Expanded payload size ${expandedPayload.size} did not match declared size $originalSize"
|
||||
)
|
||||
return null
|
||||
}
|
||||
expandedPayload
|
||||
} else {
|
||||
val payloadBytes = ByteArray(payloadLength.toInt())
|
||||
buffer.get(payloadBytes)
|
||||
@ -477,7 +522,7 @@ object BinaryProtocol {
|
||||
route = route
|
||||
)
|
||||
|
||||
} catch (e: Throwable) {
|
||||
} catch (e: Exception) {
|
||||
Log.e("BinaryProtocol", "Error decoding packet: ${e.message}")
|
||||
return null
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package com.bitchat.android.protocol
|
||||
|
||||
import android.util.Log
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.zip.DataFormatException
|
||||
import java.util.zip.Deflater
|
||||
import java.util.zip.Inflater
|
||||
|
||||
@ -11,6 +12,8 @@ import java.util.zip.Inflater
|
||||
*/
|
||||
object CompressionUtil {
|
||||
private const val COMPRESSION_THRESHOLD = com.bitchat.android.util.AppConstants.Protocol.COMPRESSION_THRESHOLD_BYTES // bytes - same as iOS
|
||||
|
||||
private val decompressionPool = DecompressionResourcePool.forRuntime()
|
||||
|
||||
/**
|
||||
* Helper to check if compression is worth it - exact same logic as iOS
|
||||
@ -73,47 +76,124 @@ object CompressionUtil {
|
||||
* iOS COMPRESSION_ZLIB produces raw deflate data (no headers)
|
||||
*/
|
||||
fun decompress(compressedData: ByteArray, originalSize: Int): ByteArray? {
|
||||
// iOS COMPRESSION_ZLIB produces raw deflate format (no headers)
|
||||
try {
|
||||
val inflater = Inflater(true) // true = raw deflate, no headers
|
||||
inflater.setInput(compressedData)
|
||||
|
||||
val decompressedBuffer = ByteArray(originalSize)
|
||||
val actualSize = inflater.inflate(decompressedBuffer)
|
||||
inflater.end()
|
||||
|
||||
// Verify decompressed size matches expected (same validation as iOS)
|
||||
return if (actualSize == originalSize) {
|
||||
decompressedBuffer
|
||||
} else if (actualSize > 0) {
|
||||
// Handle case where actual size is different
|
||||
decompressedBuffer.copyOfRange(0, actualSize)
|
||||
} else {
|
||||
if (!isValidRequest(compressedData, originalSize)) return null
|
||||
return withDecompressionResources(originalSize.toLong()) {
|
||||
decompressWithResourcesReserved(compressedData, originalSize)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun <T> withDecompressionResources(bytes: Long, block: () -> T): T? =
|
||||
decompressionPool.withReservation(bytes, block)
|
||||
|
||||
/**
|
||||
* Inflate after the caller has reserved all packet-specific allocations.
|
||||
* This avoids nested acquisition when BinaryProtocol reserves both its input copy and output.
|
||||
*/
|
||||
internal fun decompressWithResourcesReserved(
|
||||
compressedData: ByteArray,
|
||||
originalSize: Int
|
||||
): ByteArray? {
|
||||
if (!isValidRequest(compressedData, originalSize)) return null
|
||||
return decompressExact(compressedData, originalSize)
|
||||
}
|
||||
|
||||
private fun isValidRequest(compressedData: ByteArray, originalSize: Int): Boolean {
|
||||
val maxExpandedSize = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
|
||||
if (compressedData.isEmpty()) {
|
||||
Log.w("CompressionUtil", "Refusing an empty compressed payload")
|
||||
return false
|
||||
}
|
||||
if (originalSize <= 0 || originalSize > maxExpandedSize) {
|
||||
Log.w(
|
||||
"CompressionUtil",
|
||||
"Refusing expanded payload size $originalSize outside 1..$maxExpandedSize"
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun decompressExact(compressedData: ByteArray, originalSize: Int): ByteArray? {
|
||||
return if (looksLikeZlib(compressedData)) {
|
||||
// A raw stream can coincidentally begin with a valid-looking zlib header. The
|
||||
// header therefore only determines which format to try first; any non-exact zlib
|
||||
// result must still fall back to raw under the same size/completion bounds.
|
||||
val zlibResult = try {
|
||||
inflateExact(compressedData, originalSize, nowrap = false)
|
||||
} catch (zlibException: DataFormatException) {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d("CompressionUtil", "Raw deflate decompression failed: ${e.message}, trying with zlib headers...")
|
||||
|
||||
// Fallback: try with zlib headers in case of mixed usage
|
||||
try {
|
||||
val inflater = Inflater(false) // false = expect zlib headers
|
||||
inflater.setInput(compressedData)
|
||||
|
||||
val decompressedBuffer = ByteArray(originalSize)
|
||||
val actualSize = inflater.inflate(decompressedBuffer)
|
||||
inflater.end()
|
||||
|
||||
return if (actualSize == originalSize) {
|
||||
decompressedBuffer
|
||||
} else if (actualSize > 0) {
|
||||
decompressedBuffer.copyOfRange(0, actualSize)
|
||||
} else {
|
||||
if (zlibResult != null) {
|
||||
zlibResult
|
||||
} else {
|
||||
try {
|
||||
inflateExact(compressedData, originalSize, nowrap = true)
|
||||
} catch (rawException: DataFormatException) {
|
||||
Log.d("CompressionUtil", "Invalid zlib/raw deflate stream")
|
||||
null
|
||||
}
|
||||
} catch (fallbackException: Exception) {
|
||||
Log.e("CompressionUtil", "Both raw deflate and zlib decompression failed: ${fallbackException.message}")
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
inflateExact(compressedData, originalSize, nowrap = true)
|
||||
} catch (rawException: DataFormatException) {
|
||||
Log.d("CompressionUtil", "Invalid raw deflate stream")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** RFC 1950 header check used to avoid speculative double inflation. */
|
||||
private fun looksLikeZlib(data: ByteArray): Boolean {
|
||||
if (data.size < 2) return false
|
||||
val cmf = data[0].toInt() and 0xFF
|
||||
val flg = data[1].toInt() and 0xFF
|
||||
return (cmf and 0x0F) == 8 &&
|
||||
(cmf ushr 4) <= 7 &&
|
||||
((cmf shl 8) or flg) % 31 == 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Inflate one complete stream into exactly [originalSize] bytes.
|
||||
*
|
||||
* A full output buffer alone is not success: an attacker can under-declare a larger stream so
|
||||
* the first inflate call fills the buffer while [Inflater.finished] remains false. Conversely,
|
||||
* a truncated or over-declared stream can produce a non-empty prefix. Both forms are rejected,
|
||||
* as are trailing bytes after the compressed stream.
|
||||
*
|
||||
* [DataFormatException] is deliberately allowed to escape so the caller can try the legacy
|
||||
* zlib-wrapped format. Size/completion mismatches return null; the fallback must then prove the
|
||||
* same bytes are a complete, exact-sized zlib stream before they can be accepted.
|
||||
*/
|
||||
@Throws(DataFormatException::class)
|
||||
private fun inflateExact(
|
||||
compressedData: ByteArray,
|
||||
originalSize: Int,
|
||||
nowrap: Boolean
|
||||
): ByteArray? {
|
||||
val inflater = Inflater(nowrap)
|
||||
return try {
|
||||
inflater.setInput(compressedData)
|
||||
val output = ByteArray(originalSize)
|
||||
var written = 0
|
||||
|
||||
while (written < originalSize) {
|
||||
val count = inflater.inflate(output, written, originalSize - written)
|
||||
if (count == 0) break
|
||||
written += count
|
||||
}
|
||||
|
||||
if (written != originalSize) return null
|
||||
|
||||
// Give Inflater one byte of room to consume the end marker. Any produced byte proves
|
||||
// the declared size was smaller than the actual expansion.
|
||||
val overflowProbe = ByteArray(1)
|
||||
if (inflater.inflate(overflowProbe) != 0) return null
|
||||
|
||||
if (!inflater.finished() || inflater.remaining != 0) return null
|
||||
output
|
||||
} finally {
|
||||
inflater.end()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
package com.bitchat.android.protocol
|
||||
|
||||
import java.util.concurrent.Semaphore
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.ceil
|
||||
|
||||
/**
|
||||
* Fair, weighted admission control for decompression allocations.
|
||||
*
|
||||
* Permits represent memory rather than workers: small packets can proceed concurrently while
|
||||
* near-limit packets consume most of the budget. Callers must reserve before allocating any
|
||||
* packet-specific compressed copy or expanded output.
|
||||
*/
|
||||
internal class DecompressionResourcePool(
|
||||
budgetBytes: Long,
|
||||
private val unitBytes: Int,
|
||||
private val waitTimeoutMs: Long
|
||||
) {
|
||||
private val totalPermits = (budgetBytes / unitBytes).toInt().coerceAtLeast(1)
|
||||
private val permits = Semaphore(totalPermits, true)
|
||||
|
||||
fun <T> withReservation(bytes: Long, block: () -> T): T? {
|
||||
val requiredPermits = permitsFor(bytes)
|
||||
val acquired = try {
|
||||
permits.tryAcquire(requiredPermits, waitTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
false
|
||||
}
|
||||
if (!acquired) return null
|
||||
|
||||
return try {
|
||||
block()
|
||||
} finally {
|
||||
permits.release(requiredPermits)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun permitsFor(bytes: Long): Int =
|
||||
ceil(bytes.coerceAtLeast(1).toDouble() / unitBytes.toDouble())
|
||||
.toInt()
|
||||
.coerceAtMost(totalPermits)
|
||||
|
||||
internal val availablePermits: Int
|
||||
get() = permits.availablePermits()
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_UNIT_BYTES = 256 * 1024
|
||||
private const val DEFAULT_WAIT_TIMEOUT_MS = 1_000L
|
||||
private const val HEAP_BUDGET_DIVISOR = 8L
|
||||
private const val MAX_BUDGET_BYTES = 64L * 1024 * 1024
|
||||
|
||||
fun forRuntime(
|
||||
maxHeapBytes: Long = Runtime.getRuntime().maxMemory(),
|
||||
maxPacketResourceBytes: Long =
|
||||
2L * com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
|
||||
): DecompressionResourcePool {
|
||||
val budget = recommendedBudgetBytes(maxHeapBytes, maxPacketResourceBytes)
|
||||
return DecompressionResourcePool(
|
||||
budgetBytes = budget,
|
||||
unitBytes = DEFAULT_UNIT_BYTES,
|
||||
waitTimeoutMs = DEFAULT_WAIT_TIMEOUT_MS
|
||||
)
|
||||
}
|
||||
|
||||
internal fun recommendedBudgetBytes(
|
||||
maxHeapBytes: Long,
|
||||
maxPacketResourceBytes: Long
|
||||
): Long = (maxHeapBytes / HEAP_BUDGET_DIVISOR)
|
||||
.coerceAtLeast(maxPacketResourceBytes)
|
||||
.coerceAtMost(MAX_BUDGET_BYTES.coerceAtLeast(maxPacketResourceBytes))
|
||||
}
|
||||
}
|
||||
@ -43,7 +43,7 @@ class MediaSendingManager(
|
||||
get() = getMeshService()
|
||||
companion object {
|
||||
private const val TAG = "MediaSendingManager"
|
||||
private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES // 50MB limit
|
||||
private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES
|
||||
private const val PENDING_PRIVATE_MEDIA_TIMEOUT_MS = 15_000L
|
||||
}
|
||||
|
||||
@ -81,6 +81,46 @@ class MediaSendingManager(
|
||||
private var automaticRetryRequestedFor: String? = null
|
||||
private var pendingAutomaticTimeoutRequestId: String? = null
|
||||
|
||||
/**
|
||||
* Enforce the send-size cap with a user-visible failure posted to the
|
||||
* conversation the user is sending from. Returns true if the file is
|
||||
* oversized and the send was aborted.
|
||||
*/
|
||||
private fun rejectIfOversized(
|
||||
file: java.io.File,
|
||||
toPeerIDOrNull: String?,
|
||||
channelOrNull: String?
|
||||
): Boolean {
|
||||
val size = file.length()
|
||||
if (size <= MAX_FILE_SIZE) return false
|
||||
Log.e(TAG, "❌ File too large: $size bytes (max: $MAX_FILE_SIZE)")
|
||||
val sizeMb = size / (1024 * 1024)
|
||||
val maxMb = MAX_FILE_SIZE / (1024 * 1024)
|
||||
val text = "cannot send ${file.name}: file is too large (${sizeMb} MB, max $maxMb MB)"
|
||||
when {
|
||||
toPeerIDOrNull != null -> {
|
||||
val sys = BitchatMessage(
|
||||
sender = "system",
|
||||
content = text,
|
||||
timestamp = Date(),
|
||||
isRelay = false
|
||||
)
|
||||
messageManager.addPrivateMessageNoUnread(toPeerIDOrNull, sys)
|
||||
}
|
||||
channelOrNull != null -> {
|
||||
val sys = BitchatMessage(
|
||||
sender = "system",
|
||||
content = text,
|
||||
timestamp = Date(),
|
||||
isRelay = false
|
||||
)
|
||||
messageManager.addChannelMessage(channelOrNull, sys)
|
||||
}
|
||||
else -> messageManager.addSystemMessage(text)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a voice note (audio file)
|
||||
*/
|
||||
@ -103,8 +143,7 @@ class MediaSendingManager(
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
if (file.length() > MAX_FILE_SIZE) {
|
||||
Log.e(TAG, "File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
if (rejectIfOversized(file, toPeerIDOrNull, channelOrNull)) {
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
@ -148,8 +187,7 @@ class MediaSendingManager(
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
if (file.length() > MAX_FILE_SIZE) {
|
||||
Log.e(TAG, "File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
if (rejectIfOversized(file, toPeerIDOrNull, channelOrNull)) {
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
@ -193,8 +231,7 @@ class MediaSendingManager(
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
if (file.length() > MAX_FILE_SIZE) {
|
||||
Log.e(TAG, "File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
if (rejectIfOversized(file, toPeerIDOrNull, channelOrNull)) {
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
|
||||
@ -131,7 +131,9 @@ object AppConstants {
|
||||
}
|
||||
|
||||
object Media {
|
||||
const val MAX_FILE_SIZE_BYTES: Long = 50L * 1024 * 1024
|
||||
// A file is currently encoded into one protocol payload before BLE fragmentation.
|
||||
// Reserve room for maximum filename/MIME TLVs and encryption envelope overhead.
|
||||
const val MAX_FILE_SIZE_BYTES: Long = (10L * 1024 * 1024) - (132L * 1024)
|
||||
}
|
||||
|
||||
object Services {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.bitchat.android.protocol
|
||||
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@ -7,9 +8,11 @@ import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.Random
|
||||
import java.util.zip.Deflater
|
||||
|
||||
class BinaryProtocolTest {
|
||||
|
||||
@ -987,6 +990,315 @@ class BinaryProtocolTest {
|
||||
assertNull("v2 compression bomb (ratio > 50,000:1) must be rejected", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `v2 expanded payload at exact maximum passes bound without allocating output`() {
|
||||
val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
|
||||
val raw = compressedPacket(
|
||||
version = 2u,
|
||||
originalSize = max,
|
||||
compressedData = ByteArray(256) { it.toByte() }
|
||||
)
|
||||
var calls = 0
|
||||
|
||||
val result = BinaryProtocol.decodeForTesting(raw) { _, requestedSize ->
|
||||
calls += 1
|
||||
assertEquals(max, requestedSize)
|
||||
null // Prove the boundary reached this seam without allocating a 10 MiB result.
|
||||
}
|
||||
|
||||
assertNull("The test decompressor deliberately returns no payload", result)
|
||||
assertEquals(1, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `v2 expanded payload above maximum never reaches decompressor`() {
|
||||
val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
|
||||
val raw = compressedPacket(
|
||||
version = 2u,
|
||||
originalSize = max + 1,
|
||||
compressedData = ByteArray(256) { it.toByte() }
|
||||
)
|
||||
var calls = 0
|
||||
|
||||
val result = BinaryProtocol.decodeForTesting(raw) { _, _ ->
|
||||
calls += 1
|
||||
byteArrayOf(0x42)
|
||||
}
|
||||
|
||||
assertNull("An oversized expansion must be rejected before inflation", result)
|
||||
assertEquals("The decompressor must not be invoked", 0, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `v2 negative expanded payload never reaches decompressor`() {
|
||||
val raw = compressedPacket(
|
||||
version = 2u,
|
||||
originalSize = -1,
|
||||
compressedData = byteArrayOf(0x03)
|
||||
)
|
||||
var calls = 0
|
||||
|
||||
val result = BinaryProtocol.decodeForTesting(raw) { _, _ ->
|
||||
calls += 1
|
||||
byteArrayOf(0x42)
|
||||
}
|
||||
|
||||
assertNull("A negative expansion must be rejected before inflation", result)
|
||||
assertEquals("The decompressor must not be invoked", 0, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero expanded payload never reaches decompressor`() {
|
||||
val raw = compressedPacket(
|
||||
version = 2u,
|
||||
originalSize = 0,
|
||||
compressedData = rawDeflate(ByteArray(0))
|
||||
)
|
||||
var calls = 0
|
||||
|
||||
val result = BinaryProtocol.decodeForTesting(raw) { _, _ ->
|
||||
calls += 1
|
||||
ByteArray(0)
|
||||
}
|
||||
|
||||
assertNull("Compressed zero-length payloads are non-canonical and must be rejected", result)
|
||||
assertEquals("The decompressor must not be invoked", 0, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty compressed body never reaches decompressor`() {
|
||||
val raw = compressedPacket(
|
||||
version = 2u,
|
||||
originalSize = 128,
|
||||
compressedData = ByteArray(0)
|
||||
)
|
||||
var calls = 0
|
||||
|
||||
val result = BinaryProtocol.decodeForTesting(raw) { _, _ ->
|
||||
calls += 1
|
||||
ByteArray(128)
|
||||
}
|
||||
|
||||
assertNull("A compressed payload must contain deflate bytes", result)
|
||||
assertEquals("The decompressor must not be invoked", 0, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `v1 unsigned maximum expanded size reaches decompressor`() {
|
||||
val originalSize = 0xFFFF
|
||||
val raw = compressedPacket(
|
||||
version = 1u,
|
||||
originalSize = originalSize,
|
||||
compressedData = byteArrayOf(0x01, 0x02)
|
||||
)
|
||||
var calls = 0
|
||||
|
||||
val result = BinaryProtocol.decodeForTesting(raw) { _, requestedSize ->
|
||||
calls += 1
|
||||
assertEquals(originalSize, requestedSize)
|
||||
null
|
||||
}
|
||||
|
||||
assertNull("The test decompressor deliberately returns no payload", result)
|
||||
assertEquals(1, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compression utility rejects invalid expansion sizes directly`() {
|
||||
val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
|
||||
|
||||
assertNull(CompressionUtil.decompress(ByteArray(0), 1))
|
||||
assertNull(CompressionUtil.decompress(rawDeflate(ByteArray(0)), 0))
|
||||
assertNull(CompressionUtil.decompress(byteArrayOf(0x03), -1))
|
||||
assertNull(CompressionUtil.decompress(byteArrayOf(0x03), max + 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `raw deflate expands only when size and stream completion are exact`() {
|
||||
val payload = ByteArray(4_096) { index -> (index % 17).toByte() }
|
||||
val compressed = rawDeflate(payload)
|
||||
|
||||
val decoded = BinaryProtocol.decode(
|
||||
compressedPacket(version = 2u, originalSize = payload.size, compressedData = compressed)
|
||||
)
|
||||
|
||||
assertNotNull(decoded)
|
||||
assertArrayEquals(payload, decoded!!.payload)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zlib wrapped payload remains compatible when size and stream completion are exact`() {
|
||||
val payload = ByteArray(4_096) { index -> (index % 23).toByte() }
|
||||
val compressed = zlibDeflate(payload)
|
||||
|
||||
val decoded = BinaryProtocol.decode(
|
||||
compressedPacket(version = 2u, originalSize = payload.size, compressedData = compressed)
|
||||
)
|
||||
|
||||
assertNotNull(decoded)
|
||||
assertArrayEquals(payload, decoded!!.payload)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `raw deflate with zlib-looking prefix falls back after non-exact zlib parse`() {
|
||||
val payload = ByteArray(29) { index -> (index + 1).toByte() }
|
||||
val compressed = byteArrayOf(
|
||||
0x08, // non-final raw stored block; also zlib CMF
|
||||
0x1d, 0x00, // LEN = 29; 0x08 0x1d passes the RFC 1950 header check
|
||||
0xe2.toByte(), 0xff.toByte() // one's complement of LEN
|
||||
) + payload + byteArrayOf(0x03, 0x00) // final empty fixed-Huffman block
|
||||
|
||||
assertArrayEquals(payload, CompressionUtil.decompress(compressed, payload.size))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `under-declared zlib expansion is rejected by fallback`() {
|
||||
val payload = ByteArray(4_096) { 0x51 }
|
||||
val compressed = zlibDeflate(payload)
|
||||
|
||||
val decoded = BinaryProtocol.decode(
|
||||
compressedPacket(version = 2u, originalSize = 128, compressedData = compressed)
|
||||
)
|
||||
|
||||
assertNull("Zlib fallback must reject output beyond the declaration", decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `over-declared zlib expansion is rejected by fallback`() {
|
||||
val payload = ByteArray(128) { 0x52 }
|
||||
val compressed = zlibDeflate(payload)
|
||||
|
||||
val decoded = BinaryProtocol.decode(
|
||||
compressedPacket(version = 2u, originalSize = 256, compressedData = compressed)
|
||||
)
|
||||
|
||||
assertNull("Zlib fallback must reject output shorter than the declaration", decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `truncated zlib stream is rejected by fallback`() {
|
||||
val payload = ByteArray(4_096) { index -> (index % 29).toByte() }
|
||||
val compressed = zlibDeflate(payload)
|
||||
val truncated = compressed.copyOf(compressed.size - 1)
|
||||
|
||||
val decoded = BinaryProtocol.decode(
|
||||
compressedPacket(version = 2u, originalSize = payload.size, compressedData = truncated)
|
||||
)
|
||||
|
||||
assertNull("Zlib fallback must require the stream end marker and checksum", decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zlib stream with trailing bytes is rejected by fallback`() {
|
||||
val payload = ByteArray(4_096) { index -> (index % 13).toByte() }
|
||||
val compressedWithTrailingByte = zlibDeflate(payload) + byteArrayOf(0x00)
|
||||
|
||||
val decoded = BinaryProtocol.decode(
|
||||
compressedPacket(
|
||||
version = 2u,
|
||||
originalSize = payload.size,
|
||||
compressedData = compressedWithTrailingByte
|
||||
)
|
||||
)
|
||||
|
||||
assertNull("Zlib fallback must consume the complete input and nothing more", decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `under-declared raw expansion is rejected even when output buffer fills`() {
|
||||
val payload = ByteArray(4_096) { 0x41 }
|
||||
val compressed = rawDeflate(payload)
|
||||
|
||||
val decoded = BinaryProtocol.decode(
|
||||
compressedPacket(version = 2u, originalSize = 128, compressedData = compressed)
|
||||
)
|
||||
|
||||
assertNull("Inflater must be finished, not merely fill the declared buffer", decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `over-declared raw expansion is rejected instead of returning a prefix`() {
|
||||
val payload = ByteArray(128) { 0x42 }
|
||||
val compressed = rawDeflate(payload)
|
||||
|
||||
val decoded = BinaryProtocol.decode(
|
||||
compressedPacket(version = 2u, originalSize = 256, compressedData = compressed)
|
||||
)
|
||||
|
||||
assertNull("The expanded byte count must equal the declaration", decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `truncated raw stream is rejected even if all declared bytes were emitted`() {
|
||||
val payload = ByteArray(4_096) { index -> (index % 31).toByte() }
|
||||
val compressed = rawDeflate(payload)
|
||||
val truncated = compressed.copyOf(compressed.size - 1)
|
||||
|
||||
val decoded = BinaryProtocol.decode(
|
||||
compressedPacket(version = 2u, originalSize = payload.size, compressedData = truncated)
|
||||
)
|
||||
|
||||
assertNull("A stream without its end marker must not be accepted", decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `raw stream with trailing bytes is rejected`() {
|
||||
val payload = ByteArray(4_096) { index -> (index % 19).toByte() }
|
||||
val compressedWithTrailingByte = rawDeflate(payload) + byteArrayOf(0x00)
|
||||
|
||||
val decoded = BinaryProtocol.decode(
|
||||
compressedPacket(
|
||||
version = 2u,
|
||||
originalSize = payload.size,
|
||||
compressedData = compressedWithTrailingByte
|
||||
)
|
||||
)
|
||||
|
||||
assertNull("Trailing bytes after a complete stream must not be accepted", decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decoder rejects a decompressor result shorter than its declaration`() {
|
||||
val raw = compressedPacket(
|
||||
version = 2u,
|
||||
originalSize = 128,
|
||||
compressedData = ByteArray(16) { it.toByte() }
|
||||
)
|
||||
|
||||
val decoded = BinaryProtocol.decodeForTesting(raw) { _, _ -> byteArrayOf(0x01) }
|
||||
|
||||
assertNull("BinaryProtocol must independently enforce the declared expanded size", decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new sender refuses legacy 11 MiB public file transfer before transmission`() {
|
||||
val content = ByteArray(11 * 1024 * 1024) { 0x41 }
|
||||
val filePayload = BitchatFilePacket(
|
||||
fileName = "legacy-11m.bin",
|
||||
fileSize = content.size.toLong(),
|
||||
mimeType = "application/octet-stream",
|
||||
content = content
|
||||
).encode()
|
||||
assertNotNull(filePayload)
|
||||
|
||||
val encoded = BinaryProtocol.encode(
|
||||
BitchatPacket(
|
||||
version = 2u,
|
||||
type = MessageType.FILE_TRANSFER.value,
|
||||
senderID = hexToBytes(senderHex),
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = fixedTimestamp,
|
||||
payload = filePayload!!,
|
||||
ttl = 5u
|
||||
),
|
||||
padding = false
|
||||
)
|
||||
assertNull(
|
||||
"Sender and receiver must enforce the same expanded-payload ceiling",
|
||||
encoded
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compression bomb is rejected
|
||||
*
|
||||
@ -1163,6 +1475,59 @@ class BinaryProtocolTest {
|
||||
return result
|
||||
}
|
||||
|
||||
private fun compressedPacket(
|
||||
version: UByte,
|
||||
originalSize: Int,
|
||||
compressedData: ByteArray,
|
||||
type: UByte = MessageType.MESSAGE.value
|
||||
): ByteArray {
|
||||
val originalSizeFieldBytes = if (version >= 2u.toUByte()) 4 else 2
|
||||
val payloadLength = originalSizeFieldBytes + compressedData.size
|
||||
val headerSize = if (version >= 2u.toUByte()) 16 else 14
|
||||
val buffer = ByteBuffer.allocate(headerSize + 8 + payloadLength).apply {
|
||||
order(ByteOrder.BIG_ENDIAN)
|
||||
put(version.toByte())
|
||||
put(type.toByte())
|
||||
put(5.toByte())
|
||||
putLong(fixedTimestamp.toLong())
|
||||
put(BinaryProtocol.Flags.IS_COMPRESSED.toByte())
|
||||
if (version >= 2u.toUByte()) {
|
||||
putInt(payloadLength)
|
||||
} else {
|
||||
putShort(payloadLength.toShort())
|
||||
}
|
||||
put(hexToBytes(senderHex))
|
||||
if (version >= 2u.toUByte()) {
|
||||
putInt(originalSize)
|
||||
} else {
|
||||
putShort(originalSize.toShort())
|
||||
}
|
||||
put(compressedData)
|
||||
}
|
||||
return buffer.array()
|
||||
}
|
||||
|
||||
private fun rawDeflate(data: ByteArray): ByteArray = deflate(data, nowrap = true)
|
||||
|
||||
private fun zlibDeflate(data: ByteArray): ByteArray = deflate(data, nowrap = false)
|
||||
|
||||
private fun deflate(data: ByteArray, nowrap: Boolean): ByteArray {
|
||||
val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, nowrap)
|
||||
return try {
|
||||
deflater.setInput(data)
|
||||
deflater.finish()
|
||||
val output = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(1_024)
|
||||
while (!deflater.finished()) {
|
||||
val count = deflater.deflate(buffer)
|
||||
output.write(buffer, 0, count)
|
||||
}
|
||||
output.toByteArray()
|
||||
} finally {
|
||||
deflater.end()
|
||||
}
|
||||
}
|
||||
|
||||
private fun makePacket(
|
||||
version: UByte = 1u,
|
||||
type: UByte = MessageType.MESSAGE.value,
|
||||
|
||||
@ -0,0 +1,117 @@
|
||||
package com.bitchat.android.protocol
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class DecompressionResourcePoolTest {
|
||||
@Test
|
||||
fun `small reservations run concurrently and next waits only when budget is full`() {
|
||||
val pool = DecompressionResourcePool(
|
||||
budgetBytes = 2_000,
|
||||
unitBytes = 1_000,
|
||||
waitTimeoutMs = 2_000
|
||||
)
|
||||
val executor = Executors.newFixedThreadPool(3)
|
||||
val entered = CountDownLatch(2)
|
||||
val release = CountDownLatch(1)
|
||||
val thirdEntered = CountDownLatch(1)
|
||||
|
||||
try {
|
||||
repeat(2) {
|
||||
executor.submit {
|
||||
pool.withReservation(1_000) {
|
||||
entered.countDown()
|
||||
release.await()
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(entered.await(1, TimeUnit.SECONDS))
|
||||
|
||||
executor.submit {
|
||||
pool.withReservation(1_000) {
|
||||
thirdEntered.countDown()
|
||||
}
|
||||
}
|
||||
assertFalse("third reservation must wait while budget is full", thirdEntered.await(100, TimeUnit.MILLISECONDS))
|
||||
|
||||
release.countDown()
|
||||
assertTrue("third reservation must proceed after release", thirdEntered.await(1, TimeUnit.SECONDS))
|
||||
} finally {
|
||||
release.countDown()
|
||||
executor.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timed admission drops work instead of waiting indefinitely`() {
|
||||
val pool = DecompressionResourcePool(
|
||||
budgetBytes = 1_000,
|
||||
unitBytes = 1_000,
|
||||
waitTimeoutMs = 50
|
||||
)
|
||||
val entered = CountDownLatch(1)
|
||||
val release = CountDownLatch(1)
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
|
||||
try {
|
||||
executor.submit {
|
||||
pool.withReservation(1_000) {
|
||||
entered.countDown()
|
||||
release.await()
|
||||
}
|
||||
}
|
||||
assertTrue(entered.await(1, TimeUnit.SECONDS))
|
||||
assertNull(pool.withReservation(1_000) { "unexpected" })
|
||||
} finally {
|
||||
release.countDown()
|
||||
executor.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `permits are released when decode throws`() {
|
||||
val pool = DecompressionResourcePool(2_000, 1_000, 50)
|
||||
|
||||
try {
|
||||
pool.withReservation(2_000) { error("boom") }
|
||||
} catch (_: IllegalStateException) {
|
||||
// Expected.
|
||||
}
|
||||
|
||||
assertEquals(2, pool.availablePermits)
|
||||
assertEquals("ok", pool.withReservation(2_000) { "ok" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runtime budget is based on heap memory and always admits one maximum packet`() {
|
||||
val maxPacketResources = 20L * 1024 * 1024
|
||||
|
||||
assertEquals(
|
||||
maxPacketResources,
|
||||
DecompressionResourcePool.recommendedBudgetBytes(
|
||||
maxHeapBytes = 64L * 1024 * 1024,
|
||||
maxPacketResourceBytes = maxPacketResources
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
32L * 1024 * 1024,
|
||||
DecompressionResourcePool.recommendedBudgetBytes(
|
||||
maxHeapBytes = 256L * 1024 * 1024,
|
||||
maxPacketResourceBytes = maxPacketResources
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
64L * 1024 * 1024,
|
||||
DecompressionResourcePool.recommendedBudgetBytes(
|
||||
maxHeapBytes = 2L * 1024 * 1024 * 1024,
|
||||
maxPacketResourceBytes = maxPacketResources
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -237,7 +237,7 @@ class FileTransferTest {
|
||||
|
||||
// Given: Large file size (simulated)
|
||||
val largeFileSize = 100L * 1024 * 1024 // 100MB
|
||||
val maxAllowedSize = 50L * 1024 * 1024 // 50MB
|
||||
val maxAllowedSize = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES
|
||||
|
||||
// When: Checking if file can be transferred
|
||||
val isAllowed = largeFileSize <= maxAllowedSize
|
||||
|
||||
@ -287,6 +287,43 @@ class MediaSendingManagerMigrationTest {
|
||||
assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oversized file failure is posted to the private conversation and nothing is sent`() {
|
||||
val bigFile = kotlin.io.path.createTempFile("oversized-private", ".jpg").toFile()
|
||||
try {
|
||||
bigFile.writeBytes(ByteArray(11 * 1024 * 1024) { 0x42 })
|
||||
|
||||
manager.sendImageNote(peerID, null, bigFile.absolutePath)
|
||||
|
||||
val messages = state.privateChats.value[peerID].orEmpty()
|
||||
assertEquals(1, messages.size)
|
||||
assertTrue(messages.single().content.contains("too large"))
|
||||
assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image })
|
||||
assertTrue(state.getMessagesValue().none { it.content.contains("too large") })
|
||||
verify(mesh, never()).prepareFilePrivate(any(), any(), any(), any())
|
||||
} finally {
|
||||
bigFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oversized file failure is posted to the channel and nothing is sent`() {
|
||||
val bigFile = kotlin.io.path.createTempFile("oversized-channel", ".jpg").toFile()
|
||||
try {
|
||||
bigFile.writeBytes(ByteArray(11 * 1024 * 1024) { 0x42 })
|
||||
|
||||
manager.sendImageNote(null, "#test", bigFile.absolutePath)
|
||||
|
||||
val channelMessages = state.getChannelMessagesValue()["#test"].orEmpty()
|
||||
assertEquals(1, channelMessages.size)
|
||||
assertTrue(channelMessages.single().content.contains("too large"))
|
||||
assertTrue(state.getMessagesValue().none { it.content.contains("too large") })
|
||||
verify(mesh, never()).prepareFilePrivate(any(), any(), any(), any())
|
||||
} finally {
|
||||
bigFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelled consent cannot later send or echo`() {
|
||||
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
|
||||
|
||||
@ -107,6 +107,28 @@ source-route metadata. It does not imply multi-gigabyte mesh transfer support.
|
||||
transport threshold; the data portion is at most 469 bytes and becomes
|
||||
smaller when recipient or source-route overhead is present.
|
||||
|
||||
#### Compressed expansion rollout gate (resolved)
|
||||
|
||||
Android's bounded decoder applies the same 10 MiB expanded-payload ceiling to every outer
|
||||
message type. It also requires a non-empty compressed body, an exact declared output size, and a
|
||||
complete deflate stream. The `FILE_TRANSFER (0x22)` byte cannot safely grant a larger ceiling: it is
|
||||
attacker-controlled before packet signature verification, and the current receive pipeline must
|
||||
inflate before it can perform that verification.
|
||||
|
||||
New Android senders cap files just below 10 MiB (reserving envelope overhead) and refuse to encode
|
||||
any payload above the receiver ceiling; exceeding the cap surfaces a user-visible error in chat.
|
||||
Support for legacy >10 MiB compressed transfers is explicitly ended: legacy Android senders can
|
||||
still produce a highly compressible public file between 10 MiB and their 50 MiB UI limit that the
|
||||
bounded decoder rejects. Grandfathering 50 MiB is not safe after only a type check: inflation
|
||||
allocates the declared payload and
|
||||
`BitchatFilePacket.decode` currently copies the content again, creating a greater than 100 MiB peak
|
||||
for a maximum-size transfer.
|
||||
|
||||
Before enabling that legacy range, receive processing needs an authenticated admission decision
|
||||
made before large allocation plus streaming inflation/TLV parsing into a bounded temporary file (or
|
||||
another ownership-preserving design that avoids the second full-size copy). The sender limit and a
|
||||
wire capability/version transition must then be coordinated so old and new clients fail predictably.
|
||||
|
||||
### 1.3 File Transfer TLV payload (BitchatFilePacket)
|
||||
|
||||
The file payload is a TLV structure with mixed length field sizes to support large contents efficiently.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user