Reject future-dated packets, not just future-dated LEAVE

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.
This commit is contained in:
Taksh 2026-08-16 08:30:56 +05:30
parent 5156f7de89
commit 5e4afae4cd
2 changed files with 28 additions and 1 deletions

View File

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

View File

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