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.
This commit is contained in:
heyaim 2026-08-05 23:26:05 -05:00
parent 81ba775063
commit 56327af898
7 changed files with 432 additions and 9 deletions

View File

@ -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)

View File

@ -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

View File

@ -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()}")
}

View File

@ -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) {

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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