Merge pull request #863 from areebahmeddd/fix/preserve-wire-payload

feat: preserve original bytes during re-encoding of packets with foreign encoders
This commit is contained in:
callebtc 2026-08-17 19:08:01 +02:00 committed by GitHub
commit 9167013ac4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 104 additions and 4 deletions

View File

@ -1,6 +1,7 @@
package com.bitchat.android.protocol
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import java.nio.ByteBuffer
import java.nio.ByteOrder
@ -34,6 +35,27 @@ object SpecialRecipients {
val BROADCAST = ByteArray(8) { 0xFF.toByte() } // All 0xFF = broadcast
}
/**
* Payload as it arrived on the wire, set by [BinaryProtocol.decode].
*
* Signatures cover a re-encoding of the packet, and verification re-encodes too.
* DEFLATE output is not canonical and clients use different encoders
* (java.util.zip.Deflater here, Apple's compression_encode_buffer on iOS), so
* re-compressing can change the preimage and reject a valid packet. Reusing these
* bytes also stops a relay, which re-encodes on TTL decrement, from substituting
* its own encoding.
*
* [forPayload] ties the bytes to the payload they decode to: replace the payload
* and the encoder compresses instead.
*
* Not a data class: generated equals/hashCode over ByteArray compares references.
*/
class WirePayload(
val bytes: ByteArray,
val compressed: Boolean,
val forPayload: ByteArray
)
/**
* Binary packet format - 100% backward compatible with iOS version
*
@ -61,7 +83,10 @@ data class BitchatPacket(
val payload: ByteArray,
var signature: ByteArray? = null, // Changed from val to var for packet signing
var ttl: UByte,
var route: List<ByteArray>? = null // Optional source route: ordered list of peerIDs (8 bytes each), not including sender and final recipient
var route: List<ByteArray>? = null, // Optional source route: ordered list of peerIDs (8 bytes each), not including sender and final recipient
// Set by BinaryProtocol.decode. Not part of packet identity, so it stays out of
// the parcel, equals and hashCode. Losing it only costs a re-compression.
@IgnoredOnParcel val wirePayload: WirePayload? = null
) : Parcelable {
constructor(
@ -100,7 +125,8 @@ data class BitchatPacket(
payload = payload,
signature = null, // Remove signature for signing
route = route,
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // Use fixed TTL=0 for signing to ensure relay compatibility
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS, // Use fixed TTL=0 for signing to ensure relay compatibility
wirePayload = wirePayload // preimage must use the originator's bytes
)
return BinaryProtocol.encode(unsignedPacket)
}
@ -214,7 +240,17 @@ object BinaryProtocol {
var originalPayloadSize: Int? = null
var isCompressed = false
if (CompressionUtil.shouldCompress(payload)) {
// Re-encode of a decoded packet: reuse the originator's bytes (see WirePayload).
val wire = packet.wirePayload
if (wire != null && wire.forPayload.contentEquals(packet.payload)) {
if (wire.compressed) {
payload = wire.bytes
originalPayloadSize = packet.payload.size
isCompressed = true
}
// Uncompressed on the wire: keep it that way. shouldCompress agrees
// across clients, but the "only if smaller" check need not.
} else if (CompressionUtil.shouldCompress(payload)) {
CompressionUtil.compress(payload)?.let { compressedPayload ->
originalPayloadSize = payload.size
payload = compressedPayload
@ -450,6 +486,8 @@ object BinaryProtocol {
} else null
// Payload
// Kept so the packet can be re-encoded byte-identically (see WirePayload).
var receivedCompressed: ByteArray? = null
val payload = if (isCompressed) {
val lengthFieldBytes = if (version >= 2u.toUByte()) 4 else 2
if (payloadLength.toInt() < lengthFieldBytes) return null
@ -488,6 +526,8 @@ object BinaryProtocol {
val expandedPayload = CompressionUtil.withDecompressionResources(resourceBytes) {
val compressedPayload = ByteArray(compressedSize)
buffer.get(compressedPayload)
// Captured here so the allocation stays inside the reservation.
receivedCompressed = compressedPayload
decompress(compressedPayload, originalSize)
} ?: return null
if (expandedPayload.size != originalSize) {
@ -520,7 +560,12 @@ object BinaryProtocol {
payload = payload,
signature = signature,
ttl = ttl,
route = route
route = route,
wirePayload = WirePayload(
bytes = receivedCompressed ?: payload,
compressed = receivedCompressed != null,
forPayload = payload
)
)
} catch (e: Exception) {

View File

@ -942,6 +942,61 @@ class BinaryProtocolTest {
assertTrue("Payload must match", payload.contentEquals(decoded.payload))
}
/**
* Re-encoding preserves a payload compressed by a different DEFLATE encoder
*
* Verification re-encodes the packet, so a re-encode must reproduce the
* originator's bytes. iOS compresses with Apple's compression_encode_buffer,
* which need not match java.util.zip.Deflater byte for byte.
*
* The payload is wrapped in a raw-DEFLATE "stored" block (BTYPE=00): valid, it
* inflates correctly, and no compressor would emit it, so it stands in for a
* foreign encoder without a second compression library. Compared unpadded so
* the result does not depend on how padding is generated.
*/
@Test
fun `re-encoding preserves a foreign encoder's compressed payload`() {
val payload = "the mesh is up near the north gate. relay running all evening. "
.repeat(4)
.toByteArray()
// Raw DEFLATE stored block: BFINAL=1 BTYPE=00, then LEN and NLEN little-endian.
val stored = ByteArray(5 + payload.size)
stored[0] = 0x01
stored[1] = (payload.size and 0xFF).toByte()
stored[2] = ((payload.size shr 8) and 0xFF).toByte()
stored[3] = (payload.size.inv() and 0xFF).toByte()
stored[4] = ((payload.size.inv() shr 8) and 0xFF).toByte()
payload.copyInto(stored, 5)
val buffer = ByteBuffer.allocate(1024).apply { order(ByteOrder.BIG_ENDIAN) }
buffer.put(2.toByte()) // version = 2
buffer.put(MessageType.MESSAGE.value.toByte()) // type
buffer.put(5.toByte()) // ttl
buffer.putLong(fixedTimestamp.toLong()) // timestamp (8 bytes)
buffer.put(BinaryProtocol.Flags.IS_COMPRESSED.toByte()) // flags
buffer.putInt(stored.size + 4) // payloadLength incl. originalSize
buffer.put(hexToBytes(senderHex)) // senderID (8 bytes)
buffer.putInt(payload.size) // originalSize
buffer.put(stored) // compressed payload
val raw = ByteArray(buffer.position())
buffer.rewind()
buffer.get(raw)
val decoded = BinaryProtocol.decode(raw)
assertNotNull("Foreign-encoded frame must decode", decoded)
assertArrayEquals("Payload must inflate to the original", payload, decoded!!.payload)
val reEncoded = BinaryProtocol.encode(decoded, padding = false)
assertNotNull("Re-encode must succeed", reEncoded)
assertArrayEquals(
"Re-encode must reproduce the originator's bytes, or a valid signature stops verifying",
raw,
reEncoded
)
}
/**
* v2 compression bomb is rejected
*