mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Merge 56327af8986f1c4a8c3d5ab0942e519434c3766a into c127eb83ab94c069c32d37530d2faecd381cd2a8
This commit is contained in:
commit
b538a1c610
@ -150,7 +150,11 @@ class FragmentManager {
|
||||
timestamp = packet.timestamp,
|
||||
payload = fragmentPayload.encode(),
|
||||
route = packet.route,
|
||||
signature = null // iOS: signature: nil
|
||||
signature = null, // iOS: signature: nil
|
||||
// Fragments carry the original packet's timestamp, so a receiver applying a
|
||||
// freshness window judges each fragment on it. A replayed archive packet
|
||||
// large enough to fragment would be dropped without this.
|
||||
isRSR = packet.isRSR
|
||||
)
|
||||
|
||||
fragments.add(fragmentPacket)
|
||||
|
||||
@ -64,7 +64,8 @@ class WirePayload(
|
||||
* - Type: 1 byte
|
||||
* - TTL: 1 byte
|
||||
* - Timestamp: 8 bytes (UInt64, big-endian)
|
||||
* - Flags: 1 byte (bit 0: hasRecipient, bit 1: hasSignature, bit 2: isCompressed)
|
||||
* - Flags: 1 byte (bit 0: hasRecipient, bit 1: hasSignature, bit 2: isCompressed,
|
||||
* bit 3: hasRoute (v2+), bit 4: isRSR)
|
||||
* - PayloadLength: 2 bytes (v1) / 4 bytes (v2) (big-endian)
|
||||
*
|
||||
* Variable sections:
|
||||
@ -86,7 +87,15 @@ data class BitchatPacket(
|
||||
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
|
||||
@IgnoredOnParcel val wirePayload: WirePayload? = null,
|
||||
// Set when this packet is being sent as a solicited REQUEST_SYNC response. Mutable in
|
||||
// transit like ttl, and deliberately not part of the signing preimage, so an archived
|
||||
// packet can be marked without invalidating the signature it was stored with.
|
||||
//
|
||||
// Also deliberately outside equals/hashCode below, unlike ttl: a packet is the same packet
|
||||
// whether or not it was served as a sync response, which is the view PacketIdUtil takes
|
||||
// when it computes identity from type, sender, timestamp and payload alone.
|
||||
var isRSR: Boolean = false
|
||||
) : Parcelable {
|
||||
|
||||
constructor(
|
||||
@ -112,6 +121,11 @@ data class BitchatPacket(
|
||||
/**
|
||||
* Create binary representation for signing (without signature and TTL fields)
|
||||
* TTL is excluded because it changes during packet relay operations
|
||||
*
|
||||
* isRSR is excluded for the same reason: a packet is marked as a sync response when it is
|
||||
* replayed from the archive, long after it was signed. It is omitted from the constructor
|
||||
* call below and so defaults to false on both the signing and verifying side. Do not pass
|
||||
* it through here; threading it in would invalidate the signature on every replayed packet.
|
||||
*/
|
||||
fun toBinaryDataForSigning(): ByteArray? {
|
||||
// Create a copy without signature and with fixed TTL for signing
|
||||
@ -215,6 +229,12 @@ object BinaryProtocol {
|
||||
const val HAS_SIGNATURE: UByte = 0x02u
|
||||
const val IS_COMPRESSED: UByte = 0x04u
|
||||
const val HAS_ROUTE: UByte = 0x08u
|
||||
|
||||
// Marks a packet as a solicited REQUEST_SYNC response. A peer replaying its archive
|
||||
// sends the original timestamps, so a receiver that applies a freshness window needs
|
||||
// to know the packet was asked for. Like TTL, this changes in transit and is excluded
|
||||
// from the signing preimage (see BitchatPacket.toBinaryDataForSigning).
|
||||
const val IS_RSR: UByte = 0x10u
|
||||
}
|
||||
|
||||
private fun getHeaderSize(version: UByte): Int {
|
||||
@ -293,6 +313,9 @@ object BinaryProtocol {
|
||||
if (!packet.route.isNullOrEmpty() && packet.version >= 2u.toUByte()) {
|
||||
flags = flags or Flags.HAS_ROUTE
|
||||
}
|
||||
if (packet.isRSR) {
|
||||
flags = flags or Flags.IS_RSR
|
||||
}
|
||||
buffer.put(flags.toByte())
|
||||
|
||||
// Payload length (2 or 4 bytes, big-endian) - includes original size if compressed
|
||||
@ -423,6 +446,7 @@ object BinaryProtocol {
|
||||
val isCompressed = (flags and Flags.IS_COMPRESSED) != 0u.toUByte()
|
||||
// HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1
|
||||
val hasRoute = (version >= 2u.toUByte()) && (flags and Flags.HAS_ROUTE) != 0u.toUByte()
|
||||
val isRSR = (flags and Flags.IS_RSR) != 0u.toUByte()
|
||||
|
||||
// Payload length - version-dependent (2 or 4 bytes)
|
||||
val payloadLength = if (version >= 2u.toUByte()) {
|
||||
@ -561,13 +585,14 @@ object BinaryProtocol {
|
||||
signature = signature,
|
||||
ttl = ttl,
|
||||
route = route,
|
||||
isRSR = isRSR,
|
||||
wirePayload = WirePayload(
|
||||
bytes = receivedCompressed ?: payload,
|
||||
compressed = receivedCompressed != null,
|
||||
forPayload = payload
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("BinaryProtocol", "Error decoding packet: ${e.message}")
|
||||
return null
|
||||
|
||||
@ -188,8 +188,13 @@ class GossipSyncManager(
|
||||
val (id, pkt) = pair
|
||||
val idBytes = hexToBytes(id)
|
||||
if (!mightContain(idBytes)) {
|
||||
// Send original packet unchanged to requester only (keep local TTL)
|
||||
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
|
||||
// Send original packet unchanged to requester only (keep local TTL).
|
||||
// Mark it as a solicited response: it carries its original timestamp, which a
|
||||
// receiver applying a freshness window would otherwise reject as stale.
|
||||
val toSend = pkt.copy(
|
||||
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS,
|
||||
isRSR = true
|
||||
)
|
||||
delegate?.sendPacketToPeer(fromPeerID, toSend)
|
||||
Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}")
|
||||
}
|
||||
@ -200,7 +205,10 @@ class GossipSyncManager(
|
||||
for (pkt in toSendMsgs) {
|
||||
val idBytes = PacketIdUtil.computeIdBytes(pkt)
|
||||
if (!mightContain(idBytes)) {
|
||||
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
|
||||
val toSend = pkt.copy(
|
||||
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS,
|
||||
isRSR = true
|
||||
)
|
||||
delegate?.sendPacketToPeer(fromPeerID, toSend)
|
||||
Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}")
|
||||
}
|
||||
|
||||
@ -244,6 +244,58 @@ class FragmentManagerTest {
|
||||
assertEquals(257, plan(low).size)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragments of a solicited sync response stay marked: each carries the original timestamp a
|
||||
* freshness window would judge it on.
|
||||
*/
|
||||
@Test
|
||||
fun `fragments of a sync response stay marked as sync responses`() {
|
||||
val payload = ByteArray(4000) { (it % 251).toByte() }
|
||||
val replayed = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.MESSAGE.value,
|
||||
senderID = hexStringToByteArray(senderID),
|
||||
recipientID = null,
|
||||
timestamp = 1_700_000_000_000uL,
|
||||
payload = payload,
|
||||
signature = ByteArray(64) { 9 },
|
||||
ttl = 0u
|
||||
).apply { isRSR = true }
|
||||
|
||||
val fragments = fragmentManager.createFragments(replayed)
|
||||
|
||||
assertTrue("payload should have been split", fragments.size > 1)
|
||||
assertTrue(
|
||||
"every fragment of a sync response must carry the mark",
|
||||
fragments.all { it.isRSR }
|
||||
)
|
||||
assertTrue(
|
||||
"fragments keep the original timestamp, which is why they need the mark",
|
||||
fragments.all { it.timestamp == replayed.timestamp }
|
||||
)
|
||||
}
|
||||
|
||||
/** A normal, unsolicited packet must not gain the mark by being fragmented. */
|
||||
@Test
|
||||
fun `fragments of an ordinary packet are not marked`() {
|
||||
val payload = ByteArray(4000) { (it % 251).toByte() }
|
||||
val ordinary = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.MESSAGE.value,
|
||||
senderID = hexStringToByteArray(senderID),
|
||||
recipientID = null,
|
||||
timestamp = 1_700_000_000_000uL,
|
||||
payload = payload,
|
||||
signature = ByteArray(64) { 9 },
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
val fragments = fragmentManager.createFragments(ordinary)
|
||||
|
||||
assertTrue("payload should have been split", fragments.size > 1)
|
||||
assertTrue("no fragment may be marked", fragments.none { it.isRSR })
|
||||
}
|
||||
|
||||
private fun hexStringToByteArray(hexString: String): ByteArray {
|
||||
val result = ByteArray(8)
|
||||
for (i in 0 until 8) {
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
package com.bitchat.android.protocol
|
||||
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator
|
||||
import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters
|
||||
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||
import java.security.SecureRandom
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@ -1619,6 +1625,193 @@ class BinaryProtocolTest {
|
||||
return data.copyOfRange(start, data.size).all { (it.toInt() and 0xFF) == paddingLength }
|
||||
}
|
||||
|
||||
// MARK: - RSR flag (0x10)
|
||||
|
||||
/**
|
||||
* A packet marked as a solicited sync response survives the round-trip with the flag set,
|
||||
* and an unmarked packet decodes with it clear.
|
||||
*/
|
||||
@Test
|
||||
fun rsrFlagSurvivesEncodeDecodeRoundTrip() {
|
||||
val payload = "sync replay".toByteArray()
|
||||
|
||||
val flagged = makePacket(payload = payload).apply { isRSR = true }
|
||||
val decodedFlagged = BinaryProtocol.decode(BinaryProtocol.encode(flagged, padding = false)!!)
|
||||
assertNotNull("flagged packet should decode", decodedFlagged)
|
||||
assertTrue("isRSR must survive the round-trip", decodedFlagged!!.isRSR)
|
||||
|
||||
val plain = makePacket(payload = payload)
|
||||
val decodedPlain = BinaryProtocol.decode(BinaryProtocol.encode(plain, padding = false)!!)
|
||||
assertFalse("an unmarked packet must decode with isRSR clear", decodedPlain!!.isRSR)
|
||||
}
|
||||
|
||||
/**
|
||||
* The flag occupies bit 0x10 of the flags byte and nothing else moves.
|
||||
*
|
||||
* Pins the wire position against iOS, which defines the same bit. If this drifts, the two
|
||||
* platforms stop agreeing on what a sync response looks like.
|
||||
*/
|
||||
@Test
|
||||
fun rsrFlagOccupiesBit0x10OnTheWire() {
|
||||
val payload = "wire position".toByteArray()
|
||||
val plain = BinaryProtocol.encode(makePacket(payload = payload), padding = false)!!
|
||||
val flagged = BinaryProtocol.encode(
|
||||
makePacket(payload = payload).apply { isRSR = true },
|
||||
padding = false
|
||||
)!!
|
||||
|
||||
assertEquals("frames must be the same length", plain.size, flagged.size)
|
||||
val flagsIndex = 11 // version(1) + type(1) + ttl(1) + timestamp(8)
|
||||
val diff = (plain[flagsIndex].toInt() and 0xFF) xor (flagged[flagsIndex].toInt() and 0xFF)
|
||||
assertEquals("only bit 0x10 may differ", 0x10, diff)
|
||||
for (i in plain.indices) {
|
||||
if (i == flagsIndex) continue
|
||||
assertEquals("byte $i must be unchanged", plain[i], flagged[i])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marking an already-signed archived packet as a sync response must not invalidate its
|
||||
* signature: the signing preimage excludes the flag, exactly as it excludes TTL.
|
||||
*
|
||||
* This is the case that would make the change worse than the bug it fixes. A replayed
|
||||
* packet is signed once, when first sent, and marked later when served from the archive.
|
||||
* If the flag reached the preimage, every replayed packet would fail verification.
|
||||
*/
|
||||
@Test
|
||||
fun markingAPacketAsRsrDoesNotChangeTheSigningPreimage() {
|
||||
val payload = "archived message".toByteArray()
|
||||
val asSigned = makePacket(payload = payload, ttl = 7u)
|
||||
val asReplayed = makePacket(payload = payload, ttl = 7u).apply { isRSR = true }
|
||||
|
||||
assertArrayEquals(
|
||||
"signing preimage must be identical with and without the RSR flag",
|
||||
asSigned.toBinaryDataForSigning(),
|
||||
asReplayed.toBinaryDataForSigning()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* End to end: a packet signed when it was first sent still verifies after it is marked as a
|
||||
* sync response and put back on the wire.
|
||||
*
|
||||
* The preimage test above proves the bytes match; this walks the path the archive takes,
|
||||
* with a real Ed25519 key: sign, mark, encode, decode, verify.
|
||||
*/
|
||||
@Test
|
||||
fun aSignedPacketStillVerifiesAfterBeingMarkedAndRoundTripped() {
|
||||
val keyPair = Ed25519KeyPairGenerator().apply {
|
||||
init(Ed25519KeyGenerationParameters(SecureRandom()))
|
||||
}.generateKeyPair()
|
||||
val privateKey = keyPair.private as Ed25519PrivateKeyParameters
|
||||
val publicKey = keyPair.public as Ed25519PublicKeyParameters
|
||||
|
||||
// 1. Signed once, when the message is first broadcast (unmarked, real TTL).
|
||||
val outgoing = makePacket(payload = "signed when sent".toByteArray(), ttl = 7u)
|
||||
val signedBytes = outgoing.toBinaryDataForSigning()
|
||||
assertNotNull("signing preimage must exist", signedBytes)
|
||||
val signature = Ed25519Signer().run {
|
||||
init(true, privateKey)
|
||||
update(signedBytes!!, 0, signedBytes.size)
|
||||
generateSignature()
|
||||
}
|
||||
outgoing.signature = signature
|
||||
|
||||
// 2. Later served from the archive: marked, TTL dropped, signature untouched.
|
||||
val replayed = outgoing.copy(ttl = 0u, isRSR = true)
|
||||
|
||||
// 3. Over the wire and back.
|
||||
val decoded = BinaryProtocol.decode(BinaryProtocol.encode(replayed, padding = false)!!)
|
||||
assertNotNull("replayed packet must decode", decoded)
|
||||
assertTrue("the mark must survive the wire", decoded!!.isRSR)
|
||||
|
||||
// 4. Verified by the receiver, from the decoded packet.
|
||||
val verifier = Ed25519Signer().apply { init(false, publicKey) }
|
||||
val preimageAtReceiver = decoded.toBinaryDataForSigning()
|
||||
assertNotNull(preimageAtReceiver)
|
||||
verifier.update(preimageAtReceiver!!, 0, preimageAtReceiver.size)
|
||||
assertTrue(
|
||||
"a marked, replayed packet must still verify against the original signature",
|
||||
verifier.verifySignature(decoded.signature!!)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The archive path after the wire-payload change: a packet that arrived on the wire is
|
||||
* decoded, capturing its original payload bytes, later served as a sync response, and
|
||||
* re-encoded. The re-encoding must reuse the stored bytes instead of recompressing, so
|
||||
* the only bytes that differ between the original frame and the replayed frame are the
|
||||
* TTL byte and the flags byte carrying the mark, and the original signature must still
|
||||
* verify on the far side.
|
||||
*/
|
||||
@Test
|
||||
fun aMarkedReplayOfADecodedPacketReusesTheWireBytesAndStillVerifies() {
|
||||
val keyPair = Ed25519KeyPairGenerator().apply {
|
||||
init(Ed25519KeyGenerationParameters(SecureRandom()))
|
||||
}.generateKeyPair()
|
||||
val privateKey = keyPair.private as Ed25519PrivateKeyParameters
|
||||
val publicKey = keyPair.public as Ed25519PublicKeyParameters
|
||||
|
||||
// 1. A compressible packet, signed when first broadcast (unmarked, real TTL).
|
||||
val outgoing = makePacket(payload = "repeat ".repeat(120).toByteArray(), ttl = 7u)
|
||||
val signedBytes = outgoing.toBinaryDataForSigning()
|
||||
assertNotNull("signing preimage must exist", signedBytes)
|
||||
outgoing.signature = Ed25519Signer().run {
|
||||
init(true, privateKey)
|
||||
update(signedBytes!!, 0, signedBytes.size)
|
||||
generateSignature()
|
||||
}
|
||||
val originalFrame = BinaryProtocol.encode(outgoing, padding = false)!!
|
||||
|
||||
// 2. Received and archived: decode captures the wire payload.
|
||||
val archived = BinaryProtocol.decode(originalFrame)
|
||||
assertNotNull("archived packet must decode", archived)
|
||||
assertNotNull("decode must capture the wire payload", archived!!.wirePayload)
|
||||
|
||||
// 3. Served from the archive: marked, TTL dropped, re-encoded.
|
||||
val replayedFrame =
|
||||
BinaryProtocol.encode(archived.copy(ttl = 0u, isRSR = true), padding = false)!!
|
||||
|
||||
// 4. Byte reuse: the frames differ only at the TTL byte and the flags byte.
|
||||
assertEquals("frame length must not change", originalFrame.size, replayedFrame.size)
|
||||
val changed = originalFrame.indices.filter { originalFrame[it] != replayedFrame[it] }
|
||||
assertEquals("only the TTL byte and the flags byte may change", listOf(2, 11), changed)
|
||||
|
||||
// 5. The receiver still verifies the original signature.
|
||||
val decoded = BinaryProtocol.decode(replayedFrame)
|
||||
assertNotNull(decoded)
|
||||
assertTrue("the mark must survive the replay", decoded!!.isRSR)
|
||||
val preimage = decoded.toBinaryDataForSigning()
|
||||
assertNotNull(preimage)
|
||||
val verifier = Ed25519Signer().apply { init(false, publicKey) }
|
||||
verifier.update(preimage!!, 0, preimage.size)
|
||||
assertTrue(
|
||||
"a replay reusing the wire bytes must verify against the original signature",
|
||||
verifier.verifySignature(decoded.signature!!)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The decoder ignores flag bits it does not recognize instead of failing on them.
|
||||
*
|
||||
* This pins the property older builds rely on when they meet a marked packet, using an
|
||||
* undefined bit because 0x10 is known to this decoder. It is a regression pin for the
|
||||
* property and not evidence about any particular older build.
|
||||
*/
|
||||
@Test
|
||||
fun unrecognizedFlagBitsAreIgnoredOnDecode() {
|
||||
val payload = "compat".toByteArray()
|
||||
val encoded = BinaryProtocol.encode(makePacket(payload = payload), padding = false)!!
|
||||
val tampered = encoded.copyOf()
|
||||
val flagsIndex = 11
|
||||
tampered[flagsIndex] = (tampered[flagsIndex].toInt() or 0x40).toByte() // undefined bit
|
||||
|
||||
val decoded = BinaryProtocol.decode(tampered)
|
||||
assertNotNull("a packet carrying an unknown flag bit must still decode", decoded)
|
||||
assertTrue("payload must be intact", decoded!!.payload.contentEquals(payload))
|
||||
assertFalse("an unrelated bit must not be read as isRSR", decoded.isRSR)
|
||||
}
|
||||
|
||||
private fun assertPacketEquals(expected: BitchatPacket, actual: BitchatPacket) {
|
||||
assertEquals("version", expected.version, actual.version)
|
||||
assertEquals("type", expected.type, actual.type)
|
||||
|
||||
@ -0,0 +1,140 @@
|
||||
package com.bitchat.android.sync
|
||||
|
||||
import com.bitchat.android.model.RequestSyncPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The archive replay path must mark what it serves as a solicited sync response.
|
||||
* BinaryProtocolTest covers the wire format; this pins the replay sites, where the flag could
|
||||
* be dropped while the format still supports it.
|
||||
*/
|
||||
class GossipSyncRsrFlagTest {
|
||||
|
||||
private val sent = mutableListOf<Pair<String, BitchatPacket>>()
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var manager: GossipSyncManager
|
||||
|
||||
private val requester = "aabbccddeeff0011"
|
||||
|
||||
private val delegate = object : GossipSyncManager.Delegate {
|
||||
override fun sendPacket(packet: BitchatPacket) = Unit
|
||||
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
|
||||
sent.add(peerID to packet)
|
||||
}
|
||||
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket = packet
|
||||
}
|
||||
|
||||
private val config = object : GossipSyncManager.ConfigProvider {
|
||||
override fun seenCapacity(): Int = 100
|
||||
override fun gcsMaxBytes(): Int = 400
|
||||
override fun gcsTargetFpr(): Double = 0.01
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
sent.clear()
|
||||
scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob())
|
||||
manager = GossipSyncManager(myPeerID = "1122334455667788", scope = scope, configProvider = config)
|
||||
manager.delegate = delegate
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun broadcastMessage(payload: String, ageMillis: Long): BitchatPacket = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.MESSAGE.value,
|
||||
senderID = ByteArray(8) { 0x11 },
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = (System.currentTimeMillis() - ageMillis).toULong(),
|
||||
payload = payload.toByteArray(),
|
||||
signature = ByteArray(64) { 0x22 },
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
/** A filter the requester builds when it holds nothing: everything we have is missing. */
|
||||
private fun requestForNothingHeld() = RequestSyncPacket(p = 7, m = 1, data = ByteArray(0))
|
||||
|
||||
@Test
|
||||
fun replayedMessagesAreMarkedAsSolicitedSyncResponses() {
|
||||
val original = broadcastMessage("history from an hour ago", ageMillis = 60 * 60 * 1000L)
|
||||
manager.onPublicPacketSeen(original)
|
||||
|
||||
manager.handleRequestSync(requester, requestForNothingHeld())
|
||||
|
||||
assertEquals("one archived message should have been served", 1, sent.size)
|
||||
val (peer, served) = sent.single()
|
||||
assertEquals(requester, peer)
|
||||
assertTrue("a replayed packet must be marked as a sync response", served.isRSR)
|
||||
assertEquals("a sync response must not be relayed onward", 0u.toUByte(), served.ttl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replayPreservesTheOriginalTimestampAndPayload() {
|
||||
val original = broadcastMessage("unchanged", ageMillis = 3 * 60 * 60 * 1000L)
|
||||
manager.onPublicPacketSeen(original)
|
||||
|
||||
manager.handleRequestSync(requester, requestForNothingHeld())
|
||||
|
||||
val served = sent.single().second
|
||||
assertEquals("timestamp must not be rewritten", original.timestamp, served.timestamp)
|
||||
assertTrue("payload must not be rewritten", served.payload.contentEquals(original.payload))
|
||||
assertTrue(
|
||||
"signature must be carried through unchanged",
|
||||
served.signature.contentEquals(original.signature)
|
||||
)
|
||||
}
|
||||
|
||||
private fun announce(ageMillis: Long): BitchatPacket = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
senderID = ByteArray(8) { 0x33 },
|
||||
recipientID = null,
|
||||
timestamp = (System.currentTimeMillis() - ageMillis).toULong(),
|
||||
payload = "nickname".toByteArray(),
|
||||
signature = ByteArray(64) { 0x44 },
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
/**
|
||||
* The announce replay site must mark what it serves too: a dropped announce takes the
|
||||
* author's message history with it. The message-path tests leave the announce archive empty
|
||||
* and would stay green with this site unmarked.
|
||||
*/
|
||||
@Test
|
||||
fun replayedAnnouncesAreMarkedAsSolicitedSyncResponses() {
|
||||
manager.onPublicPacketSeen(announce(ageMillis = 150_000L))
|
||||
|
||||
manager.handleRequestSync(requester, requestForNothingHeld())
|
||||
|
||||
assertEquals("the archived announce should have been served", 1, sent.size)
|
||||
val served = sent.single().second
|
||||
assertEquals(MessageType.ANNOUNCE.value, served.type)
|
||||
assertTrue("a replayed announce must be marked as a sync response", served.isRSR)
|
||||
assertEquals("a sync response must not be relayed onward", 0u.toUByte(), served.ttl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archivingDoesNotMarkThePacketWeStored() {
|
||||
// Serving marks the packet and storing must not. If archiving marked it, a packet would
|
||||
// be flagged on paths that are not sync responses.
|
||||
val original = broadcastMessage("stored", ageMillis = 1000L)
|
||||
manager.onPublicPacketSeen(original)
|
||||
|
||||
assertFalse("the caller's packet must not be mutated", original.isRSR)
|
||||
}
|
||||
}
|
||||
@ -72,7 +72,7 @@ Sender behavior:
|
||||
Receiver behavior:
|
||||
- Decode the REQUEST_SYNC payload and reconstruct the sorted set of mapped values using the provided P, M, and bitstream.
|
||||
- For each locally stored public packet ID:
|
||||
- Compute h64(ID) % M and check if it is in the reconstructed set; if NOT present, send the original packet back with `ttl=0` to the requester only.
|
||||
- Compute h64(ID) % M and check if it is in the reconstructed set; if NOT present, send the original packet back with `ttl=0` and the RSR flag (`0x10` in the packet header) set, to the requester only.
|
||||
- For announcements, send only the latest announcement per (sender peerID).
|
||||
- For broadcast messages, send all missing ones.
|
||||
|
||||
@ -83,7 +83,7 @@ Announcement retention and pruning (consensus):
|
||||
- LEAVE handling: upon receiving a LEAVE message from a peer, immediately remove that peer’s stored announcement from the sync candidate set.
|
||||
- Stale/offline peer handling: when a peer is considered stale/offline (e.g., last announcement older than 60 seconds), immediately remove that peer’s stored announcement from the sync candidate set.
|
||||
|
||||
Important: original packets are sent unmodified to preserve original signatures (e.g., ANNOUNCE). They MUST NOT be relayed beyond immediate neighbors. Implementations SHOULD send these response packets with TTL=0 (local-only) and, when possible, route them only to the requesting peer without altering the original packet contents.
|
||||
Important: original packets are sent unmodified to preserve original signatures (e.g., ANNOUNCE), except for two header fields that are outside the signed bytes: TTL, and the RSR flag (`0x10`), which marks the packet as a solicited sync response so a receiver applying a timestamp freshness window can exempt it. They MUST NOT be relayed beyond immediate neighbors. Implementations SHOULD send these response packets with TTL=0 (local-only) and, when possible, route them only to the requesting peer.
|
||||
|
||||
## Scope and Types Included
|
||||
|
||||
@ -122,6 +122,7 @@ Backed by `DebugPreferenceManager` getters and setters:
|
||||
## Compatibility Notes
|
||||
|
||||
- GCS hashing and TLV structures are fully specified above; other implementations should use the same hashing scheme and payload layout for interoperability.
|
||||
- Responses set the RSR flag (`0x10`) in the packet header. The flag is excluded from the signing preimage on both platforms, like TTL, so an archived packet can be marked when served without invalidating its signature.
|
||||
- REQUEST_SYNC and responses are local-only and MUST NOT be relayed. Implementations SHOULD use TTL=0 to prevent relaying. If an implementation requires TTL>0 for local delivery, it MUST still ensure that REQUEST_SYNC and responses are not relayed beyond direct neighbors (e.g., by special-casing these types in relay logic).
|
||||
|
||||
## Consensus vs. Configurable
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user