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 9ef9e969..6a0445b0 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -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? = null // Optional source route: ordered list of peerIDs (8 bytes each), not including sender and final recipient + var route: List? = 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) { 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 f6a1042d..c927257f 100644 --- a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt +++ b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt @@ -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 *