mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-29 07:16:08 +00:00
* Align compressed payload send and receive bounds * Preserve ambiguous raw deflate compatibility * Pool decompression by memory budget
1576 lines
62 KiB
Kotlin
1576 lines
62 KiB
Kotlin
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
|
|
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 {
|
|
|
|
private val senderHex = "1122334455667788"
|
|
private val recipientHex = "8877665544332211"
|
|
private val fixedTimestamp = 1709600000000uL
|
|
|
|
/**
|
|
* Minimal v1 broadcast packet (no recipient, no signature, no route)
|
|
*
|
|
* Baseline correctness test for the simplest possible packet shape.
|
|
* Encodes a v1 packet with only the mandatory fields (version, type, TTL,
|
|
* timestamp, senderID, payload) and decodes it back, verifying every field
|
|
* survives the round-trip.
|
|
*
|
|
* Catches byte-order bugs, header size miscalculations, and off-by-one
|
|
* errors in the payload length field. The payload is deliberately kept
|
|
* below the 100-byte compression threshold to ensure compression is NOT
|
|
* triggered — isolating pure encode/decode logic from the compression path.
|
|
*/
|
|
@Test
|
|
fun `minimal v1 broadcast packet round-trips correctly`() {
|
|
val original = makePacket(payload = "Hello mesh".toByteArray())
|
|
|
|
val decoded = roundTrip(original)
|
|
|
|
assertPacketEquals(original, decoded)
|
|
assertNull("recipientID should be null", decoded.recipientID)
|
|
assertNull("signature should be null", decoded.signature)
|
|
assertNull("route should be null", decoded.route)
|
|
}
|
|
|
|
/**
|
|
* v1 packet with recipient
|
|
*
|
|
* Adds a recipientID to the packet, which sets the HAS_RECIPIENT flag
|
|
* (bit 0) in the flags' byte. Encodes and decodes, then verifies the
|
|
* recipientID bytes survive the round-trip intact.
|
|
*
|
|
* This validates the conditional recipient field and its flag. If the
|
|
* flag or offset calculation is wrong, every subsequent field in the
|
|
* packet (payload, signature) shifts by 8 bytes, corrupting decode.
|
|
*/
|
|
@Test
|
|
fun `v1 packet with recipient round-trips correctly`() {
|
|
val original = makePacket(
|
|
payload = "Private msg".toByteArray(),
|
|
recipientID = hexToBytes(recipientHex)
|
|
)
|
|
|
|
val decoded = roundTrip(original)
|
|
|
|
assertPacketEquals(original, decoded)
|
|
assertNotNull("recipientID must not be null", decoded.recipientID)
|
|
assertTrue("recipientID bytes must match",
|
|
original.recipientID!!.contentEquals(decoded.recipientID!!))
|
|
}
|
|
|
|
/**
|
|
* v1 packet with signature
|
|
*
|
|
* Adds a 64-byte Ed25519 signature to the packet, which sets the
|
|
* HAS_SIGNATURE flag (bit 1). Encodes and decodes, then verifies
|
|
* the signature bytes survive the round-trip exactly.
|
|
*
|
|
* The signature is appended AFTER the payload in the wire format.
|
|
* If the payload length calculation is off by even one byte, the
|
|
* decoder will read wrong bytes for the signature, silently
|
|
* producing a corrupted value that fails verification downstream.
|
|
*/
|
|
@Test
|
|
fun `v1 packet with signature round-trips correctly`() {
|
|
val signature = ByteArray(64) { (it + 0xA0).toByte() }
|
|
val original = makePacket(
|
|
payload = "Signed msg".toByteArray(),
|
|
signature = signature
|
|
)
|
|
|
|
val decoded = roundTrip(original)
|
|
|
|
assertPacketEquals(original, decoded)
|
|
assertNotNull("signature must not be null", decoded.signature)
|
|
assertTrue("signature bytes must match",
|
|
original.signature!!.contentEquals(decoded.signature!!))
|
|
}
|
|
|
|
/**
|
|
* v1 packet with recipient AND signature
|
|
*
|
|
* The most common real-world packet shape for private signed messages:
|
|
* both optional v1 fields (recipientID + signature) are present
|
|
* simultaneously. Encodes and decodes, verifying all fields.
|
|
*
|
|
* Tests that both conditional sections compose correctly. The wire
|
|
* layout is: header | senderID | recipientID | payload | signature.
|
|
* If either conditional section's size is miscalculated, it shifts
|
|
* everything after it, corrupting the remaining fields.
|
|
*/
|
|
@Test
|
|
fun `v1 packet with recipient and signature round-trips correctly`() {
|
|
val signature = ByteArray(64) { (it + 0xB0).toByte() }
|
|
val original = makePacket(
|
|
payload = "Private signed".toByteArray(),
|
|
recipientID = hexToBytes(recipientHex),
|
|
signature = signature
|
|
)
|
|
|
|
val decoded = roundTrip(original)
|
|
|
|
assertPacketEquals(original, decoded)
|
|
assertNotNull("recipientID must not be null", decoded.recipientID)
|
|
assertNotNull("signature must not be null", decoded.signature)
|
|
assertTrue("recipientID bytes must match",
|
|
original.recipientID!!.contentEquals(decoded.recipientID!!))
|
|
assertTrue("signature bytes must match",
|
|
original.signature!!.contentEquals(decoded.signature!!))
|
|
}
|
|
|
|
/**
|
|
* v2 packet with route
|
|
*
|
|
* Creates a v2 packet with a 3-hop source route, recipientID, and
|
|
* signature — the most complex packet layout the protocol supports.
|
|
* Verifies all route hops survive the round-trip, version stays 2,
|
|
* and the 4-byte payload length field is handled correctly.
|
|
*
|
|
* v2 introduces two key changes: 4-byte (instead of 2-byte) payload
|
|
* length, and a route section (1-byte hop count + N*8-byte peerIDs).
|
|
* Route encoding bugs break source-routed mesh delivery where packets
|
|
* must traverse specific intermediate nodes.
|
|
*/
|
|
@Test
|
|
fun `v2 packet with route round-trips correctly`() {
|
|
val route = listOf(
|
|
hexToBytes("AABBCCDDEEFF0011"),
|
|
hexToBytes("1100FFEEDDCCBBAA"),
|
|
hexToBytes("1234567890ABCDEF")
|
|
)
|
|
val signature = ByteArray(64) { (it + 0xC0).toByte() }
|
|
val original = makePacket(
|
|
version = 2u,
|
|
payload = "Routed msg".toByteArray(),
|
|
recipientID = hexToBytes(recipientHex),
|
|
signature = signature,
|
|
route = route
|
|
)
|
|
|
|
val decoded = roundTrip(original)
|
|
|
|
assertPacketEquals(original, decoded)
|
|
assertNotNull("route must not be null", decoded.route)
|
|
assertEquals("route hop count", 3, decoded.route!!.size)
|
|
for (i in route.indices) {
|
|
assertTrue("route hop $i must match",
|
|
route[i].contentEquals(decoded.route!![i]))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* v2 packet with empty/null route
|
|
*
|
|
* Creates v2 packets with route=emptyList() and route=null, then
|
|
* verifies the HAS_ROUTE flag is NOT set and both decode cleanly
|
|
* with route=null in the result.
|
|
*
|
|
* Edge case: an empty route list should behave identically to null —
|
|
* it should NOT encode a route-count byte of 0, which would waste a
|
|
* byte and could confuse decoders that treat count=0 differently.
|
|
*/
|
|
@Test
|
|
fun `v2 packet with empty route decodes as null route`() {
|
|
val emptyRoute = makePacket(
|
|
version = 2u,
|
|
payload = "No route".toByteArray(),
|
|
route = emptyList()
|
|
)
|
|
val decodedEmpty = roundTrip(emptyRoute)
|
|
assertNull("empty route should decode as null", decodedEmpty.route)
|
|
|
|
val nullRoute = makePacket(
|
|
version = 2u,
|
|
payload = "No route".toByteArray(),
|
|
route = null
|
|
)
|
|
val decodedNull = roundTrip(nullRoute)
|
|
assertNull("null route should decode as null", decodedNull.route)
|
|
}
|
|
|
|
/**
|
|
* All MessageType values round-trip
|
|
*
|
|
* Creates one packet per MessageType (ANNOUNCE, MESSAGE, LEAVE,
|
|
* NOISE_HANDSHAKE, NOISE_ENCRYPTED, FRAGMENT, REQUEST_SYNC,
|
|
* FILE_TRANSFER), encodes and decodes each, then verifies the type
|
|
* field is preserved exactly.
|
|
*
|
|
* MessageType is stored as a single UByte. If any type value gets
|
|
* mangled by signed/unsigned byte conversion (e.g., 0x80+ values
|
|
* treated as negative), that entire message class silently breaks.
|
|
* Also validates MessageType.fromValue() symmetry — every encoded
|
|
* type must map back to its enum constant.
|
|
*/
|
|
@Test
|
|
fun `all MessageType values round-trip correctly`() {
|
|
for (msgType in MessageType.entries) {
|
|
val original = makePacket(
|
|
payload = "type-test".toByteArray(),
|
|
type = msgType.value
|
|
)
|
|
|
|
val encoded = BinaryProtocol.encode(original)
|
|
assertNotNull("Encoding ${msgType.name} must not return null", encoded)
|
|
|
|
val decoded = BinaryProtocol.decode(encoded!!)
|
|
assertNotNull("Decoding ${msgType.name} must not return null", decoded)
|
|
|
|
assertEquals("type must match for ${msgType.name}",
|
|
msgType.value, decoded!!.type)
|
|
assertEquals("fromValue must resolve for ${msgType.name}",
|
|
msgType, MessageType.fromValue(decoded.type))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Large compressible payload triggers compression
|
|
*
|
|
* Creates a packet with a 500-byte repeating-text payload (well above
|
|
* the 100-byte compression threshold and with low entropy). Encodes it,
|
|
* verifies the encoded wire size is smaller than it would be without
|
|
* compression, then decodes and verifies the payload is restored exactly.
|
|
*
|
|
* Validates the full compression pipeline: shouldCompress() → compress()
|
|
* → IS_COMPRESSED flag set → original-size field prepended → decompress().
|
|
* A mismatch in the original-size encoding (2 bytes for v1 vs 4 for v2)
|
|
* would cause decompression to fail or produce wrong-length output.
|
|
*/
|
|
@Test
|
|
fun `large compressible payload is compressed and decompressed correctly`() {
|
|
val repeating = "ABCDEFGH".repeat(63) // 504 bytes, highly compressible
|
|
val payload = repeating.toByteArray()
|
|
val original = makePacket(payload = payload)
|
|
|
|
val encoded = BinaryProtocol.encode(original)
|
|
assertNotNull("Encoding must not return null", encoded)
|
|
|
|
// Verify IS_COMPRESSED flag is set in the encoded output
|
|
val unpadded = MessagePadding.unpad(encoded!!)
|
|
val flags = unpadded[11].toUByte()
|
|
assertTrue("IS_COMPRESSED flag must be set",
|
|
(flags and BinaryProtocol.Flags.IS_COMPRESSED) != 0u.toUByte())
|
|
|
|
// Encoded should be smaller than payload + header overhead, proving compression fired
|
|
// Header(13) + sender(8) + payload(504) + padding = would be 525+ uncompressed
|
|
// With compression the raw data before padding should be much smaller
|
|
val decoded = roundTrip(original)
|
|
assertTrue("payload must survive compression round-trip",
|
|
original.payload.contentEquals(decoded.payload))
|
|
assertEquals("payload length must match after decompression",
|
|
original.payload.size, decoded.payload.size)
|
|
}
|
|
|
|
/**
|
|
* Small payload skips compression
|
|
*
|
|
* Creates a packet with a 50-byte payload, below the 100-byte
|
|
* compression threshold. Verifies the IS_COMPRESSED flag (bit 2)
|
|
* is NOT set in the encoded output.
|
|
*
|
|
* Compressing small payloads wastes bytes — the zlib header overhead
|
|
* can make the compressed output larger than the input. The protocol
|
|
* must skip compression for payloads under the threshold.
|
|
*/
|
|
@Test
|
|
fun `small payload skips compression`() {
|
|
val payload = ByteArray(50) { 0x41 } // 50 bytes of 'A', below threshold
|
|
val original = makePacket(payload = payload)
|
|
|
|
val encoded = BinaryProtocol.encode(original)
|
|
assertNotNull("Encoding must not return null", encoded)
|
|
|
|
// Unpad to inspect raw flags byte
|
|
val unpadded = MessagePadding.unpad(encoded!!)
|
|
// Flags byte is at offset 11 in v1 (version=1, type=1, ttl=1, timestamp=8)
|
|
val flags = unpadded[11].toUByte()
|
|
assertEquals("IS_COMPRESSED flag must not be set", 0u.toUByte(),
|
|
flags and BinaryProtocol.Flags.IS_COMPRESSED)
|
|
|
|
// Verify payload still round-trips
|
|
val decoded = BinaryProtocol.decode(encoded)
|
|
assertNotNull(decoded)
|
|
assertTrue("payload must match", payload.contentEquals(decoded!!.payload))
|
|
}
|
|
|
|
/**
|
|
* High-entropy payload skips compression
|
|
*
|
|
* Creates a 200-byte payload of random bytes (high entropy, likely
|
|
* >90% unique byte values). Verifies the encoder either skips
|
|
* compression entirely or doesn't inflate the payload size.
|
|
*
|
|
* Tests the shouldCompress() entropy check. Compressing encrypted
|
|
* or random data typically inflates it — the protocol must detect
|
|
* high-entropy payloads and skip compression to avoid wasting bytes
|
|
* and breaking the payload-length budget.
|
|
*/
|
|
@Test
|
|
fun `high-entropy payload skips compression`() {
|
|
val random = Random(42) // fixed seed for reproducibility
|
|
val payload = ByteArray(200)
|
|
random.nextBytes(payload)
|
|
|
|
val original = makePacket(payload = payload)
|
|
val decoded = roundTrip(original)
|
|
|
|
assertTrue("high-entropy payload must survive round-trip",
|
|
original.payload.contentEquals(decoded.payload))
|
|
}
|
|
|
|
/**
|
|
* Padding round-trip at each block size
|
|
*
|
|
* Creates packets with payloads sized to land in each PKCS#7 padding
|
|
* bucket (256, 512, 1024, 2048 bytes). For each, verifies the encoded
|
|
* output size matches the expected block size, then decodes and confirms
|
|
* padding is stripped and the original packet is recovered.
|
|
*
|
|
* Padding is critical for traffic analysis resistance (all packets
|
|
* appear as standard sizes on the wire) and iOS interoperability.
|
|
* If the PKCS#7 pad-byte value or validation is wrong, unpadding
|
|
* either strips too many bytes (truncating the packet) or too few
|
|
* (leaving garbage appended to the signature).
|
|
*/
|
|
@Test
|
|
fun `padding produces correct block sizes and round-trips`() {
|
|
// Test that encoded output lands on one of the standard block sizes (256, 512, 1024, 2048)
|
|
// and that decode recovers the original payload.
|
|
val validBlockSizes = setOf(256, 512, 1024, 2048)
|
|
|
|
// Small payload: header(13) + sender(8) + payload(10) = 31 raw → pads to 256
|
|
val smallPayload = ByteArray(10) { (it + 1).toByte() }
|
|
val smallPacket = makePacket(payload = smallPayload)
|
|
val smallEncoded = BinaryProtocol.encode(smallPacket)!!
|
|
assertTrue("Small packet must pad to a standard block size (got ${smallEncoded.size})",
|
|
smallEncoded.size in validBlockSizes)
|
|
assertEquals("Small packet should pad to 256", 256, smallEncoded.size)
|
|
val smallDecoded = BinaryProtocol.decode(smallEncoded)!!
|
|
assertTrue("Small payload must survive padding round-trip",
|
|
smallPayload.contentEquals(smallDecoded.payload))
|
|
|
|
// Medium payload: header(13) + sender(8) + payload(80) = 101 raw → pads to 256
|
|
val medPayload = ByteArray(80) { (it + 1).toByte() }
|
|
val medPacket = makePacket(payload = medPayload)
|
|
val medEncoded = BinaryProtocol.encode(medPacket)!!
|
|
assertTrue("Medium packet must pad to a standard block size (got ${medEncoded.size})",
|
|
medEncoded.size in validBlockSizes)
|
|
val medDecoded = BinaryProtocol.decode(medEncoded)!!
|
|
assertTrue("Medium payload must survive padding round-trip",
|
|
medPayload.contentEquals(medDecoded.payload))
|
|
}
|
|
|
|
@Test
|
|
fun `encoding with padding false returns exact unpadded frame`() {
|
|
val payload = "ios-compatible-unpadded".toByteArray()
|
|
val packet = makePacket(payload = payload)
|
|
|
|
val unpadded = BinaryProtocol.encode(packet, padding = false)!!
|
|
val padded = BinaryProtocol.encode(packet, padding = true)!!
|
|
|
|
assertEquals(
|
|
"v1 unpadded frame size must be header + sender + payload",
|
|
14 + 8 + payload.size,
|
|
unpadded.size
|
|
)
|
|
assertEquals("small padded frame should pad to 256 bytes", 256, padded.size)
|
|
assertFalse(
|
|
"padding=false must not append PKCS#7 bytes",
|
|
hasPkcs7PaddingTail(unpadded)
|
|
)
|
|
assertArrayEquals(
|
|
"padded frame must unpad to the exact raw frame",
|
|
unpadded,
|
|
MessagePadding.unpad(padded)
|
|
)
|
|
assertPacketEquals(packet, BinaryProtocol.decode(unpadded)!!)
|
|
assertPacketEquals(packet, BinaryProtocol.decode(padded)!!)
|
|
}
|
|
|
|
@Test
|
|
fun `BitchatPacket toBinaryData propagates padding flag`() {
|
|
val packet = makePacket(payload = "packet-helper-padding-flag".toByteArray())
|
|
|
|
val defaultEncoded = packet.toBinaryData()!!
|
|
val explicitlyPadded = packet.toBinaryData(padding = true)!!
|
|
val unpadded = packet.toBinaryData(padding = false)!!
|
|
|
|
assertArrayEquals(
|
|
"default helper must remain padded for backward compatibility",
|
|
explicitlyPadded,
|
|
defaultEncoded
|
|
)
|
|
assertTrue("default helper should produce a padded frame", defaultEncoded.size > unpadded.size)
|
|
assertArrayEquals(
|
|
"helper padding=false must match BinaryProtocol padding=false",
|
|
BinaryProtocol.encode(packet, padding = false)!!,
|
|
unpadded
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Oversized packet bypasses padding
|
|
*
|
|
* Creates a packet whose encoded form exceeds 2048 bytes (the largest
|
|
* PKCS#7 block). Verifies it still encodes and decodes correctly even
|
|
* though padding is skipped.
|
|
*
|
|
* PKCS#7 uses a single byte for the padding length (max 255). When
|
|
* the gap between raw size and the next block exceeds 255 bytes, or
|
|
* the raw size already exceeds the largest block, padding cannot be
|
|
* applied. The protocol must still encode/decode these packets — they
|
|
* will typically be fragmented before transmission anyway.
|
|
*/
|
|
@Test
|
|
fun `oversized packet bypasses padding and still round-trips`() {
|
|
// 2100-byte payload + 21 header = 2121 raw, exceeds 2048 block
|
|
val payload = ByteArray(2100) { (it % 13).toByte() }
|
|
val original = makePacket(payload = payload)
|
|
|
|
val encoded = BinaryProtocol.encode(original)
|
|
assertNotNull("Encoding oversized packet must not return null", encoded)
|
|
|
|
val decoded = BinaryProtocol.decode(encoded!!)
|
|
assertNotNull("Decoding oversized packet must not return null", decoded)
|
|
assertTrue("Oversized payload must survive round-trip",
|
|
original.payload.contentEquals(decoded!!.payload))
|
|
}
|
|
|
|
/**
|
|
* v1 header uses 2-byte payload length, v2 uses 4-byte
|
|
*
|
|
* Encodes a v1 packet, unpads it, then reads bytes 11-12 as a
|
|
* big-endian unsigned short — verifies it matches the payload length.
|
|
* Does the same for v2, reading bytes 11-14 as a big-endian int.
|
|
*
|
|
* This is the core v1/v2 wire format distinction. v1 uses a 2-byte
|
|
* (UShort) payload length supporting up to 65535 bytes; v2 uses a
|
|
* 4-byte (UInt) length for larger payloads. If the encoder writes
|
|
* the wrong width, v1 clients can't read v2 packets and vice versa,
|
|
* silently corrupting all subsequent fields.
|
|
*/
|
|
@Test
|
|
fun `v1 uses 2-byte and v2 uses 4-byte payload length`() {
|
|
val payload = "length test".toByteArray()
|
|
|
|
// v1 header: version(1) + type(1) + ttl(1) + timestamp(8) + flags(1) = 12, then payloadLen at 12-13
|
|
val v1 = makePacket(payload = payload)
|
|
val v1Encoded = MessagePadding.unpad(BinaryProtocol.encode(v1)!!)
|
|
val v1Len = ByteBuffer.wrap(v1Encoded, 12, 2)
|
|
.order(ByteOrder.BIG_ENDIAN).short.toUShort().toInt()
|
|
assertEquals("v1 payload length field must match", payload.size, v1Len)
|
|
|
|
// v2 header: same 12 bytes, then payloadLen at 12-15 (4 bytes)
|
|
val v2 = makePacket(version = 2u, payload = payload)
|
|
val v2Encoded = MessagePadding.unpad(BinaryProtocol.encode(v2)!!)
|
|
val v2Len = ByteBuffer.wrap(v2Encoded, 12, 4)
|
|
.order(ByteOrder.BIG_ENDIAN).int
|
|
assertEquals("v2 payload length field must match", payload.size, v2Len)
|
|
}
|
|
|
|
/**
|
|
* v2 route flag is ignored when decoding v1
|
|
*
|
|
* Constructs raw v1 bytes and manually sets the HAS_ROUTE flag (bit 3)
|
|
* in the flags' byte. Verifies the decoder ignores the route flag for
|
|
* v1 packets, per the spec: "HAS_ROUTE is only valid for v2+ packets."
|
|
*
|
|
* Ensures forward compatibility — if an old v1 packet somehow has
|
|
* unexpected flags set (e.g., from a future protocol extension or
|
|
* corruption), the decoder must not misinterpret trailing bytes as
|
|
* a route section, which would corrupt payload parsing.
|
|
*/
|
|
@Test
|
|
fun `v1 decoder ignores HAS_ROUTE flag`() {
|
|
val payload = "route-flag-test".toByteArray()
|
|
val original = makePacket(payload = payload)
|
|
|
|
val encoded = BinaryProtocol.encode(original)!!
|
|
val unpadded = MessagePadding.unpad(encoded)
|
|
|
|
// Manually set HAS_ROUTE flag (bit 3) on the v1 packet
|
|
val tampered = unpadded.copyOf()
|
|
tampered[11] = (tampered[11].toInt() or 0x08).toByte()
|
|
|
|
// Re-pad so decode can handle it
|
|
val repadded = MessagePadding.pad(tampered, MessagePadding.optimalBlockSize(tampered.size))
|
|
val decoded = BinaryProtocol.decode(repadded)
|
|
|
|
assertNotNull("v1 with HAS_ROUTE flag must still decode", decoded)
|
|
assertNull("v1 packet must have null route regardless of flag", decoded!!.route)
|
|
assertTrue("payload must still be correct",
|
|
payload.contentEquals(decoded.payload))
|
|
}
|
|
|
|
/**
|
|
* Empty payload
|
|
*
|
|
* Encodes a packet with payload = ByteArray(0), then decodes it and
|
|
* verifies the empty payload survives the round-trip.
|
|
*
|
|
* ANNOUNCE and LEAVE packets can have minimal or empty payloads.
|
|
* A zero-length payload must not break the payload-length field
|
|
* calculation, cause an off-by-one in buffer positioning, or
|
|
* trigger compression (which would fail on empty input).
|
|
*/
|
|
@Test
|
|
fun `empty payload round-trips correctly`() {
|
|
val original = makePacket(
|
|
payload = ByteArray(0),
|
|
type = MessageType.LEAVE.value
|
|
)
|
|
|
|
val decoded = roundTrip(original)
|
|
|
|
assertEquals("payload must be empty", 0, decoded.payload.size)
|
|
assertEquals("type must match", MessageType.LEAVE.value, decoded.type)
|
|
}
|
|
|
|
/**
|
|
* Maximum v1 payload (65535 bytes)
|
|
*
|
|
* Encodes a v1 packet at the 2-byte payload length limit (65535 bytes,
|
|
* the maximum value of a UShort). Decodes and verifies the payload
|
|
* content and length survive.
|
|
*
|
|
* v1 uses an unsigned 16-bit integer for payload length. At the boundary
|
|
* of 65535, signed/unsigned conversion bugs surface — Java's short is
|
|
* signed (-32768 to 32767), so the encoder must handle the cast to
|
|
* UShort correctly. If it wraps to negative, the decoder reads a bogus
|
|
* payload length and fails.
|
|
*/
|
|
@Test
|
|
fun `maximum v1 payload length round-trips correctly`() {
|
|
val payload = ByteArray(65535) { (it % 251).toByte() }
|
|
val original = makePacket(payload = payload)
|
|
|
|
val encoded = BinaryProtocol.encode(original)
|
|
assertNotNull("Encoding max-size payload must not return null", encoded)
|
|
|
|
val decoded = BinaryProtocol.decode(encoded!!)
|
|
assertNotNull("Decoding max-size payload must not return null", decoded)
|
|
assertEquals("payload length must match", 65535, decoded!!.payload.size)
|
|
// Spot-check a few bytes rather than full compare (compression may or may not fire)
|
|
// The full content check is what matters
|
|
assertTrue("payload content must match",
|
|
original.payload.contentEquals(decoded.payload))
|
|
}
|
|
|
|
/**
|
|
* Truncated data returns null
|
|
*
|
|
* Passes progressively truncated byte arrays to decode(): first just
|
|
* 1 byte, then the minimum header size minus one, then a valid header
|
|
* with a truncated payload. Verifies decode returns null for each
|
|
* without throwing an exception.
|
|
*
|
|
* Malformed BLE packets are common — the radio can drop trailing bytes,
|
|
* fragment reassembly can fail, or a peer can send garbage. The decoder
|
|
* must fail gracefully with null, never throw BufferUnderflowException
|
|
* or ArrayIndexOutOfBoundsException, as uncaught exceptions crash the
|
|
* mesh service and disconnect all peers.
|
|
*/
|
|
@Test
|
|
fun `truncated data returns null without crashing`() {
|
|
// Work with unpadded bytes so truncation produces genuinely incomplete packets.
|
|
// Padded data can accidentally contain valid-looking sub-packets.
|
|
val valid = makePacket(payload = "truncation test".toByteArray())
|
|
val encoded = BinaryProtocol.encode(valid)!!
|
|
val raw = MessagePadding.unpad(encoded)
|
|
// raw is the real packet without PKCS#7 padding
|
|
|
|
// Truncation points that are definitely too short for the declared payload:
|
|
// v1 min = HEADER(13) + SENDER(8) = 21, payload declared as 15, so need 36 total
|
|
val truncationPoints = listOf(0, 1, 5, 12, 20, 25, 30)
|
|
for (len in truncationPoints) {
|
|
if (len > raw.size) continue
|
|
val truncated = raw.copyOfRange(0, len)
|
|
val result = BinaryProtocol.decode(truncated)
|
|
assertNull("Truncated to $len bytes must return null", result)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Invalid version returns null
|
|
*
|
|
* Passes byte arrays with version=0, version=3, and version=255 to
|
|
* the decoder. Verifies each returns null.
|
|
*
|
|
* Only v1 and v2 are supported by the protocol. Unknown versions must
|
|
* be rejected immediately — attempting to parse them would use wrong
|
|
* header sizes, payload length widths, and feature flags, leading to
|
|
* garbage output or buffer overflows.
|
|
*/
|
|
@Test
|
|
fun `invalid version returns null`() {
|
|
val valid = makePacket(payload = "version test".toByteArray())
|
|
val encoded = MessagePadding.unpad(BinaryProtocol.encode(valid)!!)
|
|
|
|
for (badVersion in listOf(0, 3, 255)) {
|
|
val tampered = encoded.copyOf()
|
|
tampered[0] = badVersion.toByte()
|
|
val repadded = MessagePadding.pad(tampered, MessagePadding.optimalBlockSize(tampered.size))
|
|
val result = BinaryProtocol.decode(repadded)
|
|
assertNull("Version $badVersion must return null", result)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Garbage data returns null
|
|
*
|
|
* Passes random byte arrays of various sizes (10, 100, 512, 2048) to
|
|
* the decoder. Verifies it returns null (or at most a valid-looking
|
|
* packet if the random bytes happen to parse) without ever throwing
|
|
* an exception.
|
|
*
|
|
* A fuzz-like robustness test. In a mesh network, any device can send
|
|
* any bytes. The decoder must never crash, leak memory, or enter an
|
|
* infinite loop on arbitrary input — it should simply return null for
|
|
* anything it can't parse as a valid BitchatPacket.
|
|
*/
|
|
@Test
|
|
fun `garbage data returns null without crashing`() {
|
|
val random = Random(12345)
|
|
for (size in listOf(10, 100, 512, 2048)) {
|
|
val garbage = ByteArray(size)
|
|
random.nextBytes(garbage)
|
|
// Just verify no exception is thrown; null is the expected result
|
|
// but random data COULD accidentally form a valid packet structure
|
|
try {
|
|
BinaryProtocol.decode(garbage)
|
|
} catch (e: Exception) {
|
|
throw AssertionError("Garbage data of size $size must not throw, got: ${e.message}", e)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sender ID truncation and padding
|
|
*
|
|
* Tests senderID shorter than 8 bytes (should be zero-padded to 8)
|
|
* and longer than 8 bytes (should be truncated to 8). Verifies
|
|
* exactly 8 bytes appear in the decoded senderID.
|
|
*
|
|
* PeerIDs are always exactly 8 bytes in the wire format. If a short
|
|
* senderID isn't padded, the encoder writes fewer bytes, shifting all
|
|
* subsequent fields. If a long senderID isn't truncated, the encoder
|
|
* writes extra bytes into the recipientID or payload region, silently
|
|
* corrupting the packet.
|
|
*/
|
|
@Test
|
|
fun `sender ID is padded or truncated to exactly 8 bytes`() {
|
|
// Short sender (4 bytes) — should be zero-padded to 8
|
|
val shortSender = byteArrayOf(0x11, 0x22, 0x33, 0x44)
|
|
val shortPacket = BitchatPacket(
|
|
version = 1u,
|
|
type = MessageType.ANNOUNCE.value,
|
|
senderID = shortSender,
|
|
timestamp = fixedTimestamp,
|
|
payload = "short-sender".toByteArray(),
|
|
ttl = 3u
|
|
)
|
|
val decodedShort = roundTrip(shortPacket)
|
|
assertEquals("decoded senderID must be 8 bytes", 8, decodedShort.senderID.size)
|
|
// First 4 bytes must match, rest should be zeros
|
|
for (i in 0 until 4) {
|
|
assertEquals("senderID byte $i must match", shortSender[i], decodedShort.senderID[i])
|
|
}
|
|
for (i in 4 until 8) {
|
|
assertEquals("senderID byte $i must be zero-padded", 0.toByte(), decodedShort.senderID[i])
|
|
}
|
|
|
|
// Long sender (12 bytes) — should be truncated to 8
|
|
val longSender = ByteArray(12) { (it + 0x50).toByte() }
|
|
val longPacket = BitchatPacket(
|
|
version = 1u,
|
|
type = MessageType.ANNOUNCE.value,
|
|
senderID = longSender,
|
|
timestamp = fixedTimestamp,
|
|
payload = "long-sender".toByteArray(),
|
|
ttl = 3u
|
|
)
|
|
val decodedLong = roundTrip(longPacket)
|
|
assertEquals("decoded senderID must be 8 bytes", 8, decodedLong.senderID.size)
|
|
for (i in 0 until 8) {
|
|
assertEquals("senderID byte $i must match first 8", longSender[i], decodedLong.senderID[i])
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Signing data excludes signature and uses fixed TTL
|
|
*
|
|
* Creates a packet with a 64-byte signature and TTL=7, then calls
|
|
* toBinaryDataForSigning(). Decodes the result and verifies:
|
|
* (1) the signature is null (stripped for signing), and (2) the TTL
|
|
* is 0 (SYNC_TTL_HOPS), not the original 7.
|
|
*
|
|
* Packet signatures must be deterministic regardless of how many
|
|
* hops the packet has traversed (TTL decrements at each relay).
|
|
* If TTL leaks into the signed data, a packet relayed even once
|
|
* would fail signature verification at the recipient because the
|
|
* TTL it was signed with differs from the TTL it arrives with.
|
|
* The signature itself must also be excluded from the data being
|
|
* signed (you can't sign data that includes its own signature).
|
|
*/
|
|
@Test
|
|
fun `toBinaryDataForSigning excludes signature and fixes TTL`() {
|
|
val signature = ByteArray(64) { (it + 0xD0).toByte() }
|
|
val original = makePacket(
|
|
payload = "sign-me".toByteArray(),
|
|
recipientID = hexToBytes(recipientHex),
|
|
signature = signature,
|
|
ttl = 7u
|
|
)
|
|
|
|
val signingData = original.toBinaryDataForSigning()
|
|
assertNotNull("Signing data must not be null", signingData)
|
|
|
|
val decoded = BinaryProtocol.decode(signingData!!)
|
|
assertNotNull("Signing data must decode successfully", decoded)
|
|
|
|
assertNull("Signature must be stripped for signing", decoded!!.signature)
|
|
assertEquals("TTL must be fixed to SYNC_TTL_HOPS (0) for signing",
|
|
0u.toUByte(), decoded.ttl)
|
|
// Other fields must be preserved
|
|
assertEquals("type must match", original.type, decoded.type)
|
|
assertEquals("timestamp must match", original.timestamp, decoded.timestamp)
|
|
assertTrue("senderID must match",
|
|
original.senderID.contentEquals(decoded.senderID))
|
|
assertTrue("recipientID must match",
|
|
original.recipientID!!.contentEquals(decoded.recipientID!!))
|
|
assertTrue("payload must match",
|
|
original.payload.contentEquals(decoded.payload))
|
|
}
|
|
|
|
/**
|
|
* v2 packet without route round-trips correctly
|
|
*
|
|
* Creates a v2 packet with recipientID and signature but NO route —
|
|
* the most common real-world v2 shape for private signed messages.
|
|
* Verifies all fields survive the round-trip and route is null.
|
|
*
|
|
* This exercises the 4-byte payload length field without the route
|
|
* section. If the v2 header size (15 bytes) or payload length width
|
|
* is wrong, all subsequent fields shift, corrupting decode.
|
|
* The v2-with-route tests could pass by accident if route parsing
|
|
* compensates for a header bug — this test isolates v2 header logic.
|
|
*/
|
|
@Test
|
|
fun `v2 packet without route round-trips correctly`() {
|
|
val signature = ByteArray(64) { (it + 0xE0).toByte() }
|
|
val original = makePacket(
|
|
version = 2u,
|
|
payload = "v2 no route".toByteArray(),
|
|
recipientID = hexToBytes(recipientHex),
|
|
signature = signature,
|
|
route = null
|
|
)
|
|
|
|
val decoded = roundTrip(original)
|
|
|
|
assertPacketEquals(original, decoded)
|
|
assertEquals("version must be 2", 2u.toUByte(), decoded.version)
|
|
assertNull("route must be null", decoded.route)
|
|
assertNotNull("recipientID must not be null", decoded.recipientID)
|
|
assertNotNull("signature must not be null", decoded.signature)
|
|
assertTrue("recipientID bytes must match",
|
|
original.recipientID!!.contentEquals(decoded.recipientID!!))
|
|
assertTrue("signature bytes must match",
|
|
original.signature!!.contentEquals(decoded.signature!!))
|
|
}
|
|
|
|
/**
|
|
* v2 compressed payload round-trips correctly
|
|
*
|
|
* Creates a v2 packet with a large, compressible payload that triggers
|
|
* compression. Verifies the IS_COMPRESSED flag is set, the v2 4-byte
|
|
* original-size field is written/read correctly, and the payload
|
|
* survives the round-trip intact.
|
|
*
|
|
* Covers encode lines 220, 290-292 (v2 compressed size field) and
|
|
* decode lines 420-424 (v2 compressed size read).
|
|
*/
|
|
@Test
|
|
fun `v2 compressed payload round-trips correctly`() {
|
|
val repeating = "ABCDEFGH".repeat(63) // 504 bytes, highly compressible
|
|
val payload = repeating.toByteArray()
|
|
val original = makePacket(version = 2u, payload = payload)
|
|
|
|
val encoded = BinaryProtocol.encode(original)
|
|
assertNotNull("Encoding must not return null", encoded)
|
|
|
|
// Verify IS_COMPRESSED flag is set
|
|
val unpadded = MessagePadding.unpad(encoded!!)
|
|
val flags = unpadded[11].toUByte()
|
|
assertTrue("IS_COMPRESSED flag must be set",
|
|
(flags and BinaryProtocol.Flags.IS_COMPRESSED) != 0u.toUByte())
|
|
|
|
// Verify version is 2
|
|
assertEquals("version byte must be 2", 2.toByte(), unpadded[0])
|
|
|
|
val decoded = roundTrip(original)
|
|
assertEquals("version must be 2", 2u.toUByte(), decoded.version)
|
|
assertTrue("payload must survive v2 compression round-trip",
|
|
original.payload.contentEquals(decoded.payload))
|
|
assertEquals("payload length must match after decompression",
|
|
original.payload.size, decoded.payload.size)
|
|
}
|
|
|
|
/**
|
|
* Short recipientID is zero-padded to 8 bytes
|
|
*
|
|
* Creates a packet with a 4-byte recipientID and verifies that after
|
|
* encoding and decoding, the recipientID is exactly 8 bytes with
|
|
* trailing zeros.
|
|
*
|
|
* Covers encode lines 272-273 (recipientID padding).
|
|
*/
|
|
@Test
|
|
fun `short recipientID is zero-padded to 8 bytes`() {
|
|
val shortRecipient = byteArrayOf(0x11, 0x22, 0x33, 0x44)
|
|
val original = makePacket(
|
|
payload = "short-recipient".toByteArray(),
|
|
recipientID = shortRecipient
|
|
)
|
|
|
|
val decoded = roundTrip(original)
|
|
|
|
assertNotNull("recipientID must not be null", decoded.recipientID)
|
|
assertEquals("recipientID must be 8 bytes", 8, decoded.recipientID!!.size)
|
|
for (i in 0 until 4) {
|
|
assertEquals("recipientID byte $i must match",
|
|
shortRecipient[i], decoded.recipientID[i])
|
|
}
|
|
for (i in 4 until 8) {
|
|
assertEquals("recipientID byte $i must be zero-padded",
|
|
0.toByte(), decoded.recipientID[i])
|
|
}
|
|
}
|
|
|
|
/**
|
|
* v2 packet with route but no recipient round-trips correctly
|
|
*
|
|
* Creates a v2 packet with route hops but recipientID=null. Verifies
|
|
* the route survives the round-trip and recipientID remains null.
|
|
*
|
|
* Covers encode lines 278-280, 248, 222 (route encode without recipient)
|
|
* and decode line 378 (hasRecipient=false branch in route offset calc).
|
|
*/
|
|
@Test
|
|
fun `v2 packet with route but no recipient round-trips correctly`() {
|
|
val route = listOf(
|
|
hexToBytes("AABBCCDDEEFF0011"),
|
|
hexToBytes("1100FFEEDDCCBBAA")
|
|
)
|
|
val original = makePacket(
|
|
version = 2u,
|
|
payload = "routed broadcast".toByteArray(),
|
|
recipientID = null,
|
|
route = route
|
|
)
|
|
|
|
val decoded = roundTrip(original)
|
|
|
|
assertPacketEquals(original, decoded)
|
|
assertNull("recipientID must be null", decoded.recipientID)
|
|
assertNotNull("route must not be null", decoded.route)
|
|
assertEquals("route hop count", 2, decoded.route!!.size)
|
|
for (i in route.indices) {
|
|
assertTrue("route hop $i must match",
|
|
route[i].contentEquals(decoded.route!![i]))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* v2 packet with HAS_ROUTE flag and count zero decodes route as null
|
|
*
|
|
* Manually crafts raw v2 bytes with the HAS_ROUTE flag set but the
|
|
* route count byte = 0. Verifies the decoder treats this as null
|
|
* (canonical representation).
|
|
*
|
|
* Covers decode lines 405-406 (count==0 → null).
|
|
*/
|
|
@Test
|
|
fun `v2 packet with HAS_ROUTE flag and count zero decodes route as null`() {
|
|
val payload = "route-zero".toByteArray()
|
|
|
|
val buffer = ByteBuffer.allocate(256).apply { order(ByteOrder.BIG_ENDIAN) }
|
|
|
|
// v2 header
|
|
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)
|
|
|
|
// Flags: HAS_ROUTE set
|
|
buffer.put(BinaryProtocol.Flags.HAS_ROUTE.toByte())
|
|
|
|
// Payload length (4 bytes for v2)
|
|
buffer.putInt(payload.size)
|
|
|
|
// SenderID (8 bytes)
|
|
buffer.put(hexToBytes(senderHex))
|
|
|
|
// Route: count = 0 (1 byte)
|
|
buffer.put(0.toByte())
|
|
|
|
// Payload
|
|
buffer.put(payload)
|
|
|
|
val raw = ByteArray(buffer.position())
|
|
buffer.rewind()
|
|
buffer.get(raw)
|
|
|
|
val padded = MessagePadding.pad(raw, MessagePadding.optimalBlockSize(raw.size))
|
|
val decoded = BinaryProtocol.decode(padded)
|
|
|
|
assertNotNull("Packet with route count=0 must decode", decoded)
|
|
assertNull("Route with count=0 must decode as null", decoded!!.route)
|
|
assertTrue("Payload must match", payload.contentEquals(decoded.payload))
|
|
}
|
|
|
|
/**
|
|
* v2 compression bomb is rejected
|
|
*
|
|
* Same concept as the v1 compression bomb test but with version=2,
|
|
* which uses a 4-byte original-size field instead of 2-byte.
|
|
*
|
|
* Covers decode lines 435-439 via the v2 path (4-byte size field).
|
|
*/
|
|
@Test
|
|
fun `v2 compression bomb is rejected`() {
|
|
// Valid raw deflate final empty stored block (1 byte).
|
|
// Claim a huge original size to exceed the 50,000:1 ratio guard.
|
|
// ratio = 10,000,000 / 1 = 10,000,000:1
|
|
val compressedData = byteArrayOf(0x03)
|
|
val declaredOriginalSize = 10_000_000
|
|
|
|
val buffer = ByteBuffer.allocate(256).apply { order(ByteOrder.BIG_ENDIAN) }
|
|
|
|
// v2 header
|
|
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)
|
|
|
|
// Flags: IS_COMPRESSED set
|
|
buffer.put(BinaryProtocol.Flags.IS_COMPRESSED.toByte())
|
|
|
|
// Payload length (4 bytes for v2): original-size field (4 bytes) + compressed data
|
|
val payloadFieldSize = 4 + compressedData.size
|
|
buffer.putInt(payloadFieldSize)
|
|
|
|
// SenderID (8 bytes)
|
|
buffer.put(hexToBytes(senderHex))
|
|
|
|
// Compressed payload section: original size (4 bytes for v2) + compressed data
|
|
buffer.putInt(declaredOriginalSize)
|
|
buffer.put(compressedData)
|
|
|
|
val raw = ByteArray(buffer.position())
|
|
buffer.rewind()
|
|
buffer.get(raw)
|
|
|
|
val padded = MessagePadding.pad(raw, MessagePadding.optimalBlockSize(raw.size))
|
|
val result = BinaryProtocol.decode(padded)
|
|
|
|
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
|
|
*
|
|
* Crafts a packet where the declared original size in the compressed
|
|
* payload section is absurdly large relative to the compressed data,
|
|
* exceeding the 50,000:1 ratio guard in decodeCore(). Verifies the
|
|
* decoder returns null instead of attempting decompression.
|
|
*
|
|
* Without this check, an attacker could send a tiny packet claiming
|
|
* to decompress into gigabytes, causing an OOM crash that kills the
|
|
* mesh service and disconnects all peers. The 50,000:1 threshold
|
|
* blocks this while still allowing legitimate compression ratios
|
|
* (typical text compresses ~3:1 to ~10:1).
|
|
*/
|
|
@Test
|
|
fun `compression bomb is rejected`() {
|
|
// Valid raw deflate final empty stored block (1 byte).
|
|
// v1 uses a 2-byte (UShort) original-size field, so declared size
|
|
// must fit in 0..65535. ratio = 60,000 / 1 = 60,000:1 > 50,000:1
|
|
val tinyCompressed = byteArrayOf(0x03)
|
|
val declaredOriginalSize = 60_000
|
|
|
|
val buffer = ByteBuffer.allocate(256).apply { order(ByteOrder.BIG_ENDIAN) }
|
|
|
|
// Header (13 bytes for v1)
|
|
buffer.put(1.toByte()) // version = 1
|
|
buffer.put(MessageType.MESSAGE.value.toByte()) // type
|
|
buffer.put(5.toByte()) // ttl
|
|
buffer.putLong(fixedTimestamp.toLong()) // timestamp (8 bytes)
|
|
|
|
// Flags: IS_COMPRESSED set
|
|
buffer.put(BinaryProtocol.Flags.IS_COMPRESSED.toByte())
|
|
|
|
// Payload length: original-size field (2 bytes) + compressed data (1 byte) = 3
|
|
val payloadFieldSize = 2 + tinyCompressed.size
|
|
buffer.putShort(payloadFieldSize.toShort())
|
|
|
|
// SenderID (8 bytes)
|
|
buffer.put(hexToBytes(senderHex))
|
|
|
|
// Compressed payload section: original size (2 bytes) + compressed data
|
|
buffer.putShort(declaredOriginalSize.toShort())
|
|
buffer.put(tinyCompressed)
|
|
|
|
val raw = ByteArray(buffer.position())
|
|
buffer.rewind()
|
|
buffer.get(raw)
|
|
|
|
// Pad to standard block size so decode() can process it
|
|
val padded = MessagePadding.pad(raw, MessagePadding.optimalBlockSize(raw.size))
|
|
val result = BinaryProtocol.decode(padded)
|
|
|
|
assertNull("Compression bomb (ratio > 50,000:1) must be rejected", result)
|
|
}
|
|
|
|
/**
|
|
* v1 packet with route silently drops route
|
|
*
|
|
* Creates a v1 packet with route hops set, encodes and decodes it.
|
|
* Since routes are only supported in v2+, the encoder must silently
|
|
* drop the route for v1, producing a packet with route=null.
|
|
*
|
|
* Covers encode lines 222, 248 (false branch: route non-empty but
|
|
* version < 2) and decode line 279-280 (route not written for v1).
|
|
*/
|
|
@Test
|
|
fun `v1 packet with route silently drops route`() {
|
|
val route = listOf(
|
|
hexToBytes("AABBCCDDEEFF0011"),
|
|
hexToBytes("1100FFEEDDCCBBAA")
|
|
)
|
|
val original = makePacket(
|
|
version = 1u,
|
|
payload = "v1 route drop".toByteArray(),
|
|
route = route
|
|
)
|
|
|
|
val decoded = roundTrip(original)
|
|
|
|
assertEquals("version must be 1", 1u.toUByte(), decoded.version)
|
|
assertNull("route must be null for v1 packet", decoded.route)
|
|
assertTrue("payload must survive round-trip",
|
|
original.payload.contentEquals(decoded.payload))
|
|
}
|
|
|
|
/**
|
|
* v2 truncated packet with HAS_ROUTE flag returns null
|
|
*
|
|
* Manually crafts a v2 packet with the HAS_ROUTE flag set but
|
|
* truncates the data before the route count byte. The decoder must
|
|
* return null because raw.size < routeOffset + 1.
|
|
*
|
|
* Covers decode line 382 false branch (raw too short for route count).
|
|
*/
|
|
@Test
|
|
fun `v2 truncated packet with HAS_ROUTE flag returns null`() {
|
|
val payload = "truncated-route".toByteArray()
|
|
|
|
val buffer = ByteBuffer.allocate(256).apply { order(ByteOrder.BIG_ENDIAN) }
|
|
|
|
// v2 header
|
|
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)
|
|
|
|
// Flags: HAS_ROUTE set
|
|
buffer.put(BinaryProtocol.Flags.HAS_ROUTE.toByte())
|
|
|
|
// Payload length (4 bytes for v2)
|
|
buffer.putInt(payload.size)
|
|
|
|
// SenderID (8 bytes) — after this, the route count byte should follow
|
|
// but we truncate here, so raw.size < routeOffset + 1
|
|
buffer.put(hexToBytes(senderHex))
|
|
|
|
// Do NOT write route count or payload — truncate right after senderID
|
|
val raw = ByteArray(buffer.position())
|
|
buffer.rewind()
|
|
buffer.get(raw)
|
|
|
|
// Don't pad — pass raw bytes directly so truncation is effective
|
|
val result = BinaryProtocol.decode(raw)
|
|
|
|
assertNull("Truncated v2 packet with HAS_ROUTE must return null", result)
|
|
}
|
|
|
|
/**
|
|
* v2 compressed packet with payloadLength less than size field returns null
|
|
*
|
|
* Manually crafts v2 bytes with IS_COMPRESSED flag and a payloadLength
|
|
* of 2, which is less than the 4-byte original-size field required for
|
|
* v2 compressed payloads. The decoder must return null.
|
|
*
|
|
* Covers decode line 421 true branch (payloadLength < lengthFieldBytes).
|
|
*/
|
|
@Test
|
|
fun `v2 compressed packet with payloadLength less than size field returns null`() {
|
|
val buffer = ByteBuffer.allocate(256).apply { order(ByteOrder.BIG_ENDIAN) }
|
|
|
|
// v2 header
|
|
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)
|
|
|
|
// Flags: IS_COMPRESSED set
|
|
buffer.put(BinaryProtocol.Flags.IS_COMPRESSED.toByte())
|
|
|
|
// Payload length = 2 (less than 4-byte size field for v2)
|
|
buffer.putInt(2)
|
|
|
|
// SenderID (8 bytes)
|
|
buffer.put(hexToBytes(senderHex))
|
|
|
|
// Write 2 bytes of fake payload data (matching declared payloadLength)
|
|
buffer.put(byteArrayOf(0x01, 0x02))
|
|
|
|
val raw = ByteArray(buffer.position())
|
|
buffer.rewind()
|
|
buffer.get(raw)
|
|
|
|
val padded = MessagePadding.pad(raw, MessagePadding.optimalBlockSize(raw.size))
|
|
val result = BinaryProtocol.decode(padded)
|
|
|
|
assertNull("v2 compressed with payloadLength < 4 must return null", result)
|
|
}
|
|
|
|
private fun hexToBytes(hex: String): ByteArray {
|
|
val result = ByteArray(hex.length / 2)
|
|
for (i in result.indices) {
|
|
result[i] = hex.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
|
}
|
|
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,
|
|
payload: ByteArray,
|
|
recipientID: ByteArray? = null,
|
|
signature: ByteArray? = null,
|
|
ttl: UByte = 5u,
|
|
route: List<ByteArray>? = null
|
|
) = BitchatPacket(
|
|
version = version,
|
|
type = type,
|
|
senderID = hexToBytes(senderHex),
|
|
recipientID = recipientID,
|
|
timestamp = fixedTimestamp,
|
|
payload = payload,
|
|
signature = signature,
|
|
ttl = ttl,
|
|
route = route
|
|
)
|
|
|
|
private fun roundTrip(packet: BitchatPacket): BitchatPacket {
|
|
val encoded = BinaryProtocol.encode(packet)
|
|
assertNotNull("Encoding must not return null", encoded)
|
|
val decoded = BinaryProtocol.decode(encoded!!)
|
|
assertNotNull("Decoding must not return null", decoded)
|
|
return decoded!!
|
|
}
|
|
|
|
private fun hasPkcs7PaddingTail(data: ByteArray): Boolean {
|
|
if (data.isEmpty()) return false
|
|
val paddingLength = data.last().toInt() and 0xFF
|
|
if (paddingLength <= 0 || paddingLength > data.size) return false
|
|
val start = data.size - paddingLength
|
|
return data.copyOfRange(start, data.size).all { (it.toInt() and 0xFF) == paddingLength }
|
|
}
|
|
|
|
private fun assertPacketEquals(expected: BitchatPacket, actual: BitchatPacket) {
|
|
assertEquals("version", expected.version, actual.version)
|
|
assertEquals("type", expected.type, actual.type)
|
|
assertEquals("ttl", expected.ttl, actual.ttl)
|
|
assertEquals("timestamp", expected.timestamp, actual.timestamp)
|
|
assertTrue("senderID", expected.senderID.contentEquals(actual.senderID))
|
|
assertTrue("payload", expected.payload.contentEquals(actual.payload))
|
|
}
|
|
}
|