From 56327af8986f1c4a8c3d5ab0942e519434c3766a Mon Sep 17 00:00:00 2001 From: heyaim <223061694+heyaim@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:26:05 -0500 Subject: [PATCH] Mark REQUEST_SYNC responses with the RSR flag A peer answering a REQUEST_SYNC replays archived packets with their original timestamps, and the only thing marking a reply is TTL=0, so a receiver applying a freshness window filters the replay out. iOS added flag 0x10 for this in January (#965) and removed its legacy acceptance of unflagged TTL=0 replies eleven days later (#998); this app never set the flag. An iOS user next to Android phones gets two minutes of history instead of what those phones' archives hold. Define the flag, carry it through encode and decode, set it at the two archive replay sites, and propagate it into fragments the way iOS does. It stays out of the signing preimage, like TTL, so a packet signed at first send still verifies when later served from the archive; it stays out of equals/hashCode, as PacketIdUtil computes identity from type, sender, timestamp and payload alone. The REQUEST_SYNC spec in docs/sync.md now names the flag. Send-side only: nothing changes about what this app accepts. Twelve tests pin the wire bit against iOS, the signing preimage, the byte-reuse interaction with #863, both replay sites, and fragment propagation. Gates: 820 tests, 0 failures, 3 pre-existing skips; lint and build pass. Not validated on hardware; the fragment limit and the two narrow cases that now match iOS-to-iOS behavior are in the PR. --- .../bitchat/android/mesh/FragmentManager.kt | 6 +- .../android/protocol/BinaryProtocol.kt | 31 ++- .../bitchat/android/sync/GossipSyncManager.kt | 14 +- .../android/mesh/FragmentManagerTest.kt | 52 +++++ .../android/protocol/BinaryProtocolTest.kt | 193 ++++++++++++++++++ .../android/sync/GossipSyncRsrFlagTest.kt | 140 +++++++++++++ docs/sync.md | 5 +- 7 files changed, 432 insertions(+), 9 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/sync/GossipSyncRsrFlagTest.kt diff --git a/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt b/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt index 3a10f998..769ae1fa 100644 --- a/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/FragmentManager.kt @@ -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) diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 6a0445b0..58294391 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -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? = 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 diff --git a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt index 42a10ceb..6f45e713 100644 --- a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt +++ b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt @@ -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()}") } diff --git a/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt b/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt index bf3e3d01..a8dda436 100644 --- a/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt +++ b/app/src/test/java/com/bitchat/android/mesh/FragmentManagerTest.kt @@ -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) { diff --git a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt index c927257f..e50e74be 100644 --- a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt +++ b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt @@ -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) diff --git a/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncRsrFlagTest.kt b/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncRsrFlagTest.kt new file mode 100644 index 00000000..7aa0742c --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncRsrFlagTest.kt @@ -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>() + 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) + } +} diff --git a/docs/sync.md b/docs/sync.md index 0bbd0735..d9db05b6 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -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