From 5e4afae4cd0c62380c2d91218704431d2ff9a96c Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 16 Aug 2026 08:30:56 +0530 Subject: [PATCH 1/2] Reject future-dated packets, not just future-dated LEAVE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validatePacket bounds the timestamp of exactly one packet type. LEAVE gets a symmetric five-minute window; ANNOUNCE gets one in AnnouncementIdentityValidator. Everything else — MESSAGE, FRAGMENT, FILE_TRANSFER, VOICE_FRAME — is admitted with any timestamp its sender signs, because a signature authenticates the sender's choice without sanity-checking it. That timestamp then decides ordering downstream. GossipSyncManager builds its advertised sync filter with sortByDescending { it.timestamp } and takes the top N, so broadcasts stamped far in the future occupy every slot and keep real traffic out of gossip sync — not just locally, but for every neighbor that syncs against us. The public timeline orders on the same field. Bound the future direction for every type at the allowance that already admits the peer's announcement. A device skewed further than that cannot get an ANNOUNCE verified, so it has no working session today and nothing that currently works starts failing. LEAVE keeps its tighter symmetric window. The past deliberately stays unbounded: store-and-forward replays packets cached for up to twelve hours, and bounding it would drop them silently. --- .../mesh/AnnouncementIdentityValidator.kt | 6 ++++- .../bitchat/android/mesh/SecurityManager.kt | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/bitchat/android/mesh/AnnouncementIdentityValidator.kt b/app/src/main/java/com/bitchat/android/mesh/AnnouncementIdentityValidator.kt index 89b833b14..dfe146e5e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/AnnouncementIdentityValidator.kt +++ b/app/src/main/java/com/bitchat/android/mesh/AnnouncementIdentityValidator.kt @@ -8,7 +8,11 @@ import com.bitchat.android.util.toHexString /** Canonical, side-effect-free preflight for a self-signed mesh announcement. */ object AnnouncementIdentityValidator { - private const val MAX_CLOCK_SKEW_MS = 10 * 60 * 1_000L + /** + * Clock skew tolerated on a signed announcement. Shared with [SecurityManager] so no packet + * type is held to a stricter future bound than the announcement that admits the peer. + */ + internal const val MAX_CLOCK_SKEW_MS = 10 * 60 * 1_000L fun verify( packet: BitchatPacket, diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt index 3f6846792..f392277e9 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -57,6 +57,29 @@ class SecurityManager(private val encryptionService: EncryptionService, private val currentTime = System.currentTimeMillis() val messageType = MessageType.fromValue(packet.type) + // A signed packet's timestamp is authenticated as the sender's choice, not sanity-checked, + // and it survives into every downstream ordering decision. GossipSyncManager builds its + // sync filter from `sortByDescending { it.timestamp }`, so future-dated broadcasts take + // every advertised slot and crowd real traffic out of gossip sync across the mesh; the + // public timeline orders on the same field. Only ANNOUNCE was bounded, in + // AnnouncementIdentityValidator. + // + // Bound the future direction for every type, at the same allowance that admits the peer's + // announcement — a device skewed further than this already cannot be verified, so nothing + // that works today starts failing. The past stays open: store-and-forward replays packets + // cached for up to twelve hours. + val nowForSkew = currentTime.coerceAtLeast(0).toULong() + if (packet.timestamp > nowForSkew && + packet.timestamp - nowForSkew > + AnnouncementIdentityValidator.MAX_CLOCK_SKEW_MS.toULong() + ) { + Log.w( + TAG, + "Dropping future-dated ${messageType?.name ?: packet.type} from $peerID" + ) + return false + } + // LEAVE mutates presence immediately and cannot be safely replayed after the in-memory // duplicate cache expires (or after an app restart). Bound it to the same five-minute // security window used for duplicate retention while tolerating symmetric clock skew. From 105aa64a9eb13b6a8383621d47a0761d9344f331 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 16 Aug 2026 08:30:56 +0530 Subject: [PATCH 2/2] Cover the future-dating bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing LEAVE replay-window test to the relayed types. Pins that the bound is future-only — a six-hour-old message still delivers so store-and-forward keeps working — and that it is no tighter than the announcement allowance. Removing the check fails the two rejection tests and leaves the three admission tests green. --- .../android/mesh/SecurityManagerTest.kt | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt index 2a56e708b..324fcfd0c 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt @@ -276,6 +276,81 @@ class SecurityManagerTest { assertFalse("Future-dated LEAVE must not extend its replay lifetime", securityManager.validatePacket(future, otherPeerID)) } + private fun signedMessage(offsetMs: Long): BitchatPacket = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 7u, + senderID = MeshPacketUtils.hexStringToByteArray(otherPeerID), + timestamp = (System.currentTimeMillis() + offsetMs).toULong(), + payload = dummyPayload + ).also { it.signature = validSignature } + + @Test + fun `validatePacket rejects future-dated broadcast messages`() { + setupKnownPeer(otherPeerID, otherSigningKey) + + // GossipSyncManager advertises the newest packets first, so a future-dated broadcast + // takes a sync slot permanently and crowds real traffic out across the mesh. + val future = signedMessage(AnnouncementIdentityValidator.MAX_CLOCK_SKEW_MS + 60_000L) + + assertFalse( + "A signed but future-dated MESSAGE must not be admitted", + securityManager.validatePacket(future, otherPeerID) + ) + } + + @Test + fun `validatePacket admits messages inside the announcement clock-skew allowance`() { + setupKnownPeer(otherPeerID, otherSigningKey) + + // A peer skewed further than this cannot get its ANNOUNCE accepted either, so the bound + // must not be tighter than the one that admits the peer in the first place. + val skewed = signedMessage(AnnouncementIdentityValidator.MAX_CLOCK_SKEW_MS - 60_000L) + + assertTrue( + "A tolerable forward clock skew must still deliver", + securityManager.validatePacket(skewed, otherPeerID) + ) + } + + @Test + fun `validatePacket still admits old messages for store and forward`() { + setupKnownPeer(otherPeerID, otherSigningKey) + + // StoreForward caches for 12 hours; bounding the past would silently drop that replay. + val old = signedMessage(-6 * 60 * 60 * 1_000L) + + assertTrue( + "Store-and-forward replay must keep working", + securityManager.validatePacket(old, otherPeerID) + ) + } + + @Test + fun `validatePacket rejects future-dated packets of every relayed type`() { + setupKnownPeer(otherPeerID, otherSigningKey) + + val futureOffset = AnnouncementIdentityValidator.MAX_CLOCK_SKEW_MS + 60_000L + listOf( + MessageType.MESSAGE, + MessageType.FILE_TRANSFER, + MessageType.VOICE_FRAME, + MessageType.FRAGMENT + ).forEach { type -> + val packet = BitchatPacket( + type = type.value, + ttl = 7u, + senderID = MeshPacketUtils.hexStringToByteArray(otherPeerID), + timestamp = (System.currentTimeMillis() + futureOffset).toULong(), + payload = dummyPayload + ).also { it.signature = validSignature } + + assertFalse( + "${type.name} must not be admitted with a future timestamp", + securityManager.validatePacket(packet, otherPeerID) + ) + } + } + @Test fun `validatePacket - accepts ANNOUNCE packet from unknown peer (extracts key)`() { val announcement = IdentityAnnouncement(