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. 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(