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 89b833b1..dfe146e5 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 3f684679..f392277e 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.