mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-22 07:06:05 +00:00
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:
commit
9167013ac4
@ -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) {
|
||||
|
||||
@ -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
|
||||
*
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user