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 6b1b5325..7eff07bb 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -183,6 +183,9 @@ object BinaryProtocol { private const val SENDER_ID_SIZE = 8 private const val RECIPIENT_ID_SIZE = 8 private const val SIGNATURE_SIZE = 64 + // Acquire before copying compressed input so queued decodes cannot each retain another + // near-limit payload while waiting for the inflater's single-flight lock. + private val compressedDecodeLock = Any() object Flags { const val HAS_RECIPIENT: UByte = 0x01u @@ -200,6 +203,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 @@ -466,9 +478,6 @@ object BinaryProtocol { Log.w("BinaryProtocol", "Compressed payload has no deflate bytes") return null } - val compressedPayload = ByteArray(compressedSize) - buffer.get(compressedPayload) - // Security check: Compression bomb protection val ratio = originalSize.toDouble() / compressedSize.toDouble() if (ratio > 50_000.0) { @@ -476,8 +485,13 @@ object BinaryProtocol { return null } - // Decompress - val expandedPayload = decompress(compressedPayload, originalSize) ?: return null + // Copy and inflate single-flight. Acquiring before the copy bounds both the + // compressed input copy and expanded output allocation across concurrent decodes. + val expandedPayload = synchronized(compressedDecodeLock) { + val compressedPayload = ByteArray(compressedSize) + buffer.get(compressedPayload) + decompress(compressedPayload, originalSize) + } ?: return null if (expandedPayload.size != originalSize) { Log.w( "BinaryProtocol", diff --git a/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt b/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt index 34e583fb..230b9c7a 100644 --- a/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt +++ b/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt @@ -92,36 +92,41 @@ object CompressionUtil { } return synchronized(decompressionLock) { - // iOS COMPRESSION_ZLIB produces raw deflate format (no headers). - val rawResult = try { - inflateExact(compressedData, originalSize, nowrap = true) - } catch (e: Exception) { - Log.d( - "CompressionUtil", - "Raw deflate decompression failed: ${e.message}" - ) - null - } - - if (rawResult != null) { - rawResult - } else { - // Fallback after either a format error or an incomplete/wrong-sized raw stream: - // accept the zlib-wrapped form used by some older/mixed clients, but only when it - // independently satisfies the same exact-size and complete-stream checks. + if (looksLikeZlib(compressedData)) { + // A structurally valid zlib header selects the legacy wrapped format. Do not + // retry size/completion failures as raw deflate: that only doubles attacker work. try { inflateExact(compressedData, originalSize, nowrap = false) - } catch (fallbackException: Exception) { - Log.e( - "CompressionUtil", - "Both raw deflate and zlib decompression failed: ${fallbackException.message}" - ) + } catch (zlibException: DataFormatException) { + // A raw stream can coincidentally begin with a valid-looking zlib header. + try { + inflateExact(compressedData, originalSize, nowrap = true) + } catch (rawException: DataFormatException) { + Log.d("CompressionUtil", "Invalid zlib/raw deflate stream") + 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. * diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index b7e87f91..d928c71c 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -23,7 +23,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 } // Track in-flight transfer progress: transferId -> messageId and reverse diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index 11df5b08..da69f8f4 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -129,7 +129,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 { 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 fb6e03bb..6ea31a7e 100644 --- a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt +++ b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt @@ -1259,7 +1259,7 @@ class BinaryProtocolTest { } @Test - fun `legacy 11 MiB public file transfer is explicitly rejected by bounded decoder`() { + 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", @@ -1281,15 +1281,9 @@ class BinaryProtocolTest { ), padding = false ) - assertNotNull("A legacy sender can produce the highly-compressible wire packet", encoded) - assertTrue( - "The compressed wire body remains below the normal 10 MiB input cap", - encoded!!.size < com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH - ) - assertNull( - "The uniform bound intentionally rejects this legacy expansion until streaming admission exists", - BinaryProtocol.decode(encoded) + "Sender and receiver must enforce the same expanded-payload ceiling", + encoded ) } diff --git a/app/src/test/kotlin/com/bitchat/FileTransferTest.kt b/app/src/test/kotlin/com/bitchat/FileTransferTest.kt index 31131540..8a136e69 100644 --- a/app/src/test/kotlin/com/bitchat/FileTransferTest.kt +++ b/app/src/test/kotlin/com/bitchat/FileTransferTest.kt @@ -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 diff --git a/docs/file_transfer.md b/docs/file_transfer.md index fc0d8b68..6339e89f 100644 --- a/docs/file_transfer.md +++ b/docs/file_transfer.md @@ -99,10 +99,11 @@ complete deflate stream. The `FILE_TRANSFER (0x22)` byte cannot safely grant a l attacker-controlled before packet signature verification, and the current receive pipeline must inflate before it can perform that verification. -This intentionally means a legacy Android sender can produce a highly compressible public file -between 10 MiB and the UI's 50 MiB send limit that the bounded decoder rejects. The hardening must -therefore remain a rollout HOLD rather than silently ship as backward compatible. Grandfathering -50 MiB also is not safe after only a type check: inflation allocates the declared payload and +New Android senders cap files just below 10 MiB (reserving envelope overhead) and refuse to encode +any payload above the receiver ceiling. +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.