Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down