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