From b9bf303d9abfa8d6cde901de1f9e7cbec494ca90 Mon Sep 17 00:00:00 2001 From: heyaim <223061694+heyaim@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:52:06 -0500 Subject: [PATCH 1/3] Verify replayed messages from peers that have left the mesh A REQUEST_SYNC reply can carry a message from a peer that has left, on LEAVE or after three minutes of silence. Normally the neighbor re-serves that peer's announcement first, which restores the signing key. When the key is not restored, the message arrives with no key on file and the signature check rejects it. That happens in two cases. An iOS neighbor that relaunched serves messages restored from disk, and that archive holds no announcements. An Android neighbor that missed the LEAVE keeps serving the peer for about three minutes while the receiving device rejects the re-served announcement as a duplicate. iOS keeps a message from a sender that has left when it has verified that sender before, by falling back to its persisted identity. Android persists the signing key for every peer that completed the authenticated peer-state exchange, and the message path did not read it. This addresses the first bullet of #622. The signature check falls back to the persisted signing key when the live registry has none, found by peer ID directly since a peer ID is the prefix of its own fingerprint. The broadcast handler delivers a message from a sender that has left under the cached nickname or the peer ID. A message from a sender that has left is not archived, and nothing the LEAVE purge removed re-enters the archive. Messages from present peers and the receiving device's own broadcasts are archived as before. A live key is always preferred, a sender with no key on file is rejected as before, and a registry entry with an unverified nickname is dropped as before. Receive-side only. Seventeen tests across the security manager, the message handler, the coordinator, the secure store and the sync archive. --- .../identity/SecureIdentityStateManager.kt | 13 ++ .../mesh/AuthenticatedPeerStateCoordinator.kt | 38 ++++- .../android/mesh/BluetoothMeshService.kt | 11 +- .../java/com/bitchat/android/mesh/MeshCore.kt | 10 +- .../bitchat/android/mesh/MessageHandler.kt | 24 +++- .../bitchat/android/mesh/SecurityManager.kt | 11 +- .../android/service/MeshServiceHolder.kt | 8 +- .../bitchat/android/sync/GossipSyncManager.kt | 12 ++ .../AuthenticatedPeerStateCoordinatorTest.kt | 66 +++++++++ .../android/mesh/MessageHandlerTest.kt | 134 ++++++++++++++++++ .../SecureAuthenticatedPeerStateStoreTest.kt | 55 +++++++ .../android/mesh/SecurityManagerTest.kt | 60 ++++++++ .../sync/GossipSyncArchiveGuardTest.kt | 119 ++++++++++++++++ 13 files changed, 546 insertions(+), 15 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/SecureAuthenticatedPeerStateStoreTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/sync/GossipSyncArchiveGuardTest.kt diff --git a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt index 1efa87a2..844a5daa 100644 --- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt +++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt @@ -390,6 +390,19 @@ class SecureIdentityStateManager { fun getAuthenticatedSigningKey(fingerprint: String): ByteArray? = getAuthenticatedPeerState(fingerprint)?.signingPublicKey?.copyOf() + + /** + * Fingerprint of the persisted authenticated peer state for a peer ID. A peer ID is the + * first sixteen hex characters of the fingerprint of its Noise static key, so a record + * whose fingerprint starts with the peer ID is that peer's; the record must still parse. + */ + fun findAuthenticatedFingerprintByPeerID(peerID: String): String? { + val prefix = peerID.lowercase() + if (prefix.length != 16 || !prefix.all { it in '0'..'9' || it in 'a'..'f' }) return null + val records = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet()) ?: return null + val fingerprint = records.firstOrNull { it.startsWith(prefix) }?.substringBefore(':') ?: return null + return fingerprint.takeIf { isValidFingerprint(it) && getAuthenticatedPeerState(it) != null } + } // MARK: - Peer ID Rotation Management (removed) // Android now derives peer ID from the persisted Noise identity fingerprint. diff --git a/app/src/main/java/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinator.kt b/app/src/main/java/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinator.kt index 90ef4176..8dd3e80a 100644 --- a/app/src/main/java/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinator.kt @@ -20,10 +20,16 @@ internal interface AuthenticatedPeerStateStore { onCommitted: () -> Unit ): Boolean fun isPrivateMediaPinned(fingerprint: String): Boolean + /** Fingerprint of the persisted authenticated state whose peer ID this is; survives the live registry. */ + fun persistedFingerprintFor(peerID: String): String? = null + /** Nickname persisted for a fingerprint; only meaningful next to a persisted signing key. */ + fun persistedNickname(fingerprint: String): String? = null } -internal class SecureAuthenticatedPeerStateStore(context: Context) : AuthenticatedPeerStateStore { - private val identityState = SecureIdentityStateManager(context.applicationContext) +internal class SecureAuthenticatedPeerStateStore( + context: Context, + private val identityState: SecureIdentityStateManager = SecureIdentityStateManager(context.applicationContext) +) : AuthenticatedPeerStateStore { override fun load(fingerprint: String): AuthenticatedPeerState? = identityState.getAuthenticatedPeerState(fingerprint) @@ -36,6 +42,12 @@ internal class SecureAuthenticatedPeerStateStore(context: Context) : Authenticat override fun isPrivateMediaPinned(fingerprint: String): Boolean = identityState.isPrivateMediaCapable(fingerprint) + + override fun persistedFingerprintFor(peerID: String): String? = + identityState.findAuthenticatedFingerprintByPeerID(peerID) + + override fun persistedNickname(fingerprint: String): String? = + identityState.getCachedFingerprintNickname(fingerprint) } internal sealed interface AuthenticatedPeerStateStatus { @@ -219,6 +231,28 @@ internal class AuthenticatedPeerStateCoordinator( return store.load(fingerprint(noisePublicKey))?.signingPublicKey?.copyOf() } + /** + * Signing key persisted for a peer ID this device authenticated before. The live registry + * forgets a peer on LEAVE or timeout; the persisted identity does not, so a packet the peer + * signed while present can still be verified after it has gone. A peer ID is the prefix + * of its own fingerprint, so no separate map is needed. + */ + fun persistedSigningKeyFor(peerID: String): ByteArray? { + val fingerprint = store.persistedFingerprintFor(peerID) ?: return null + return store.load(fingerprint)?.signingPublicKey?.copyOf() + } + + /** + * Display name for a peer ID whose persisted signing key vouches for it: the nickname + * cached while the peer was present, else the peer ID itself, as the registry does. Null + * when no signing key is persisted, so a cached name alone never names a sender. + */ + fun persistedNicknameFor(peerID: String): String? { + val fingerprint = store.persistedFingerprintFor(peerID) ?: return null + if (store.load(fingerprint)?.signingPublicKey == null) return null + return store.persistedNickname(fingerprint)?.takeIf { it.isNotBlank() } ?: peerID + } + fun isPrivateMediaPinned(peerID: String): Boolean { val authenticatedSession = authenticatedSessionProvider(peerID) ?: return false return store.isPrivateMediaPinned(fingerprint(authenticatedSession.remoteStaticKey)) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 22f17867..47d627c7 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -171,7 +171,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } ) - com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) { packet -> + com.bitchat.android.service.MeshServiceHolder.setGossipManager( + gossipSyncManager, + hasLivePeer = { peerID -> peerManager.getPeerInfo(peerID) != null } + ) { packet -> signPacketBeforeBroadcast(packet) } if (isBleTransportEnabled()) { @@ -323,6 +326,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = authenticatedPeerState.persistedSigningKeyFor(noisePublicKey) + + override fun getPersistedSigningKey(peerID: String): ByteArray? = + authenticatedPeerState.persistedSigningKeyFor(peerID) } // StoreForwardManager delegates @@ -419,6 +425,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = authenticatedPeerState.persistedSigningKeyFor(noisePublicKey) + + override fun getPersistedPeerNickname(peerID: String): String? = + authenticatedPeerState.persistedNicknameFor(peerID) // Noise protocol operations override fun hasNoiseSession(peerID: String): Boolean { diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index 28cd3d02..f24997b5 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -143,7 +143,9 @@ class MeshCore( TransportBridgeService.sendToPeer(transport.id, peerID, packet) } - override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { + override fun hasLivePeer(peerID: String): Boolean = peerManager.getPeerInfo(peerID) != null + + override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { return signPacketBeforeBroadcast(packet) } } @@ -267,6 +269,9 @@ class MeshCore( override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = authenticatedPeerState.persistedSigningKeyFor(noisePublicKey) + + override fun getPersistedSigningKey(peerID: String): ByteArray? = + authenticatedPeerState.persistedSigningKeyFor(peerID) } storeForwardManager.delegate = object : StoreForwardManagerDelegate { @@ -365,6 +370,9 @@ class MeshCore( override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = authenticatedPeerState.persistedSigningKeyFor(noisePublicKey) + override fun getPersistedPeerNickname(peerID: String): String? = + authenticatedPeerState.persistedNicknameFor(peerID) + override fun hasNoiseSession(peerID: String): Boolean { return encryptionService.hasEstablishedSession(peerID) } diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index 9434fc6e..81a9a97c 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -454,11 +454,21 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro val packet = routed.packet val peerID = routed.peerID ?: "unknown" - // Enforce: only accept public messages from verified peers we know + // Only accept public messages from senders whose identity is established: a verified + // peer in the live registry, or a sender the registry has forgotten whose signing key + // this device persisted. A peer still in the registry with an unverified nickname is + // dropped as before; the persisted name must not override that registry decision. val peerInfo = delegate?.getPeerInfo(peerID) - if (peerInfo == null || !peerInfo.isVerifiedNickname) { - Log.w(TAG, "Dropping public message from unverified peer ${peerID.take(8)}") - return + val senderNickname = when { + peerInfo == null -> delegate?.getPersistedPeerNickname(peerID) ?: run { + Log.w(TAG, "Dropping public message from unknown peer ${peerID.take(8)}") + return + } + peerInfo.isVerifiedNickname -> delegate?.getPeerNickname(peerID) ?: "unknown" + else -> { + Log.w(TAG, "Dropping public message from unverified peer ${peerID.take(8)}") + return + } } try { @@ -470,7 +480,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file) val message = BitchatMessage( id = PacketIdUtil.computeIdHex(packet).uppercase(), - sender = delegate?.getPeerNickname(peerID) ?: "unknown", + sender = senderNickname, content = savedPath, type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType), senderPeerID = peerID, @@ -487,7 +497,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro // Fallback: plain text val message = BitchatMessage( id = PacketIdUtil.computeIdHex(packet).uppercase(), - sender = delegate?.getPeerNickname(peerID) ?: "unknown", + sender = senderNickname, content = String(packet.payload, Charsets.UTF_8), senderPeerID = peerID, timestamp = Date(packet.timestamp.toLong()) @@ -716,6 +726,8 @@ interface MessageHandlerDelegate { ): com.bitchat.android.noise.NoiseDecryptionResult? fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = null + /** Nickname persisted for a peer this device authenticated before, for senders no longer in the live registry. */ + fun getPersistedPeerNickname(peerID: String): String? = null // Noise protocol operations fun hasNoiseSession(peerID: String): Boolean 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..20e6657d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -314,13 +314,16 @@ class SecurityManager(private val encryptionService: EncryptionService, private return false } - // 2. Get Signing Public Key + // 2. Get Signing Public Key: the live registry first, then the identity persisted + // for a peer this device authenticated before. A sync replay from a peer that has + // since left carries that peer's signature, and the live registry has forgotten it. val peerInfo = delegate?.getPeerInfo(peerID) val signingPublicKey = peerInfo?.signingPublicKey + ?: delegate?.getPersistedSigningKey(peerID) if (signingPublicKey == null) { - // If we don't have a key (and it's not an announce), we can't verify. - // For security, we must reject packets from unknown peers unless it's an announce. + // No live and no persisted key: nothing can verify this sender. Reject, as + // for any unknown peer; only an ANNOUNCE may introduce a key. Log.w(TAG, "Signature check for $peerID: NO_SIGNING_KEY_AVAILABLE (packet type ${packet.type})") return false } @@ -481,4 +484,6 @@ interface SecurityManagerDelegate { fun sendHandshakeResponse(peerID: String, response: ByteArray) fun getPeerInfo(peerID: String): PeerInfo? // NEW: For signature verification fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = null + /** Signing key persisted for a peer this device authenticated before, for senders no longer in the live registry. */ + fun getPersistedSigningKey(peerID: String): ByteArray? = null } diff --git a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt index aea8fce6..a03089ab 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt @@ -22,6 +22,7 @@ object MeshServiceHolder { @Synchronized fun setGossipManager( mgr: GossipSyncManager, + hasLivePeer: (String) -> Boolean = { true }, signer: (BitchatPacket) -> BitchatPacket ) { val previous = sharedGossipSyncManager @@ -29,7 +30,7 @@ object MeshServiceHolder { try { previous?.stop() } catch (_: Exception) { } } sharedGossipSyncManager = mgr - mgr.delegate = TransportGossipDelegate(signer) + mgr.delegate = TransportGossipDelegate(signer, hasLivePeer) if (activeGossipOwners.isNotEmpty()) { mgr.start() } @@ -53,8 +54,11 @@ object MeshServiceHolder { } private class TransportGossipDelegate( - private val signer: (BitchatPacket) -> BitchatPacket + private val signer: (BitchatPacket) -> BitchatPacket, + private val livePeer: (String) -> Boolean ) : GossipSyncManager.Delegate { + override fun hasLivePeer(peerID: String): Boolean = livePeer(peerID) + override fun sendPacket(packet: BitchatPacket) { TransportBridgeService.broadcastFromLocal(RoutedPacket(packet)) } diff --git a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt index 42a10ceb..59ea7ef0 100644 --- a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt +++ b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt @@ -20,6 +20,8 @@ class GossipSyncManager( private val configProvider: ConfigProvider ) { interface Delegate { + /** True when the live peer registry holds this sender; the default keeps today's archiving for every implementor. */ + fun hasLivePeer(peerID: String): Boolean = true fun sendPacket(packet: BitchatPacket) fun sendPacketToPeer(peerID: String, packet: BitchatPacket) fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket @@ -113,6 +115,16 @@ class GossipSyncManager( val id = idBytes.joinToString("") { b -> "%02x".format(b) } if (isBroadcastMessage) { + // A message from a sender the live registry no longer holds is never archived: the + // LEAVE and stale purges remove that sender's announcement and messages together, and + // a message accepted on a persisted key alone must not re-enter the archive behind + // that purge, or it would be re-served with nothing left to prune it. Present peers + // and this device's own broadcasts are archived exactly as before. + val sender = packet.senderID.joinToString("") { b -> "%02x".format(b) } + if (sender != myPeerID && delegate?.hasLivePeer(sender) == false) { + Log.d(TAG, "Not archiving message from ${sender.take(8)}: sender not in the live registry") + return + } synchronized(messages) { messages[id] = packet // Enforce capacity (remove oldest when exceeded) diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinatorTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinatorTest.kt index 7598a96b..774fea24 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinatorTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedPeerStateCoordinatorTest.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -22,7 +23,12 @@ class AuthenticatedPeerStateCoordinatorTest { val states = mutableMapOf() val pins = mutableSetOf() + val nicknames = mutableMapOf() + override fun load(fingerprint: String): AuthenticatedPeerState? = states[fingerprint] + override fun persistedFingerprintFor(peerID: String): String? = + states.keys.firstOrNull { it.startsWith(peerID.lowercase()) } + override fun persistedNickname(fingerprint: String): String? = nicknames[fingerprint] override fun persist( fingerprint: String, state: AuthenticatedPeerState, @@ -296,4 +302,64 @@ class AuthenticatedPeerStateCoordinatorTest { private fun fingerprint(key: ByteArray): String = MessageDigest.getInstance("SHA-256").digest(key).joinToString("") { "%02x".format(it) } + + @Test + fun `a departed peer's signing key and nickname resolve by peer ID through the fingerprint prefix`() { + val store = MemoryStore() + val fingerprint = sha256Hex(remoteStatic) + store.states[fingerprint] = remoteState + store.nicknames[fingerprint] = "relay-a" + val coordinator = coordinatorOver(store) + + assertArrayEquals(remoteState.signingPublicKey, coordinator.persistedSigningKeyFor(peerID)) + assertEquals("relay-a", coordinator.persistedNicknameFor(peerID)) + assertNull(coordinator.persistedSigningKeyFor("0000000000000000")) + assertNull(coordinator.persistedNicknameFor("0000000000000000")) + } + + @Test + fun `a persisted key with no cached nickname names the peer by its ID`() { + val store = MemoryStore() + store.states[sha256Hex(remoteStatic)] = remoteState + val coordinator = coordinatorOver(store) + + assertEquals(peerID, coordinator.persistedNicknameFor(peerID)) + } + + @Test + fun `a cached nickname without a persisted signing key names nobody`() { + val store = MemoryStore() + store.nicknames[sha256Hex(remoteStatic)] = "relay-b" + val coordinator = coordinatorOver(store) + + assertNull(coordinator.persistedSigningKeyFor(peerID)) + assertNull("a nickname alone must not vouch for a sender", coordinator.persistedNicknameFor(peerID)) + } + + @Test + fun `an accepted proof is resolvable by peer ID afterwards`() { + val store = MemoryStore() + val coordinator = coordinatorOver(store) + + coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken) + assertTrue(coordinator.receive(peerID, remoteState, firstSession)) + + assertArrayEquals(remoteState.signingPublicKey, coordinator.persistedSigningKeyFor(peerID)) + } + + private fun coordinatorOver(store: MemoryStore): AuthenticatedPeerStateCoordinator = + AuthenticatedPeerStateCoordinator( + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), + authenticatedSessionProvider = { firstSession }, + withAuthenticatedSession = { _, _, action -> action() }, + store = store, + localStateProvider = { localState }, + applyAuthenticatedState = { _, _, _ -> }, + sendState = { _, _, _ -> true }, + onResolution = {}, + proofTimeoutMs = 5_000 + ) + + private fun sha256Hex(bytes: ByteArray): String = + java.security.MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } } diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt index 79bc4c2d..e82de9a4 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt @@ -21,6 +21,9 @@ import org.junit.After import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before +import org.junit.Assert.assertEquals +import org.mockito.kotlin.argumentCaptor +import com.bitchat.android.model.BitchatMessage import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any @@ -483,4 +486,135 @@ class MessageHandlerTest { isVerifiedNickname = true, lastSeen = System.currentTimeMillis() ) + + @Test + fun `a broadcast from a departed peer with a persisted identity is delivered under its persisted nickname`() = runBlocking { + whenever(delegate.getBroadcastRecipient()).thenReturn(SpecialRecipients.BROADCAST) + whenever(delegate.getPeerInfo(peerID)).thenReturn(null) + whenever(delegate.getPeerNickname(peerID)).thenReturn(null) + whenever(delegate.getPersistedPeerNickname(peerID)).thenReturn("relay-a") + val packet = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = peerID.hexToBytes(), + recipientID = null, + timestamp = System.currentTimeMillis().toULong(), + payload = "hello from earlier".toByteArray(), + signature = ByteArray(64) { 1 }, + ttl = 0u + ) + + handler.handleMessage(RoutedPacket(packet, peerID, "direct-link")) + + val captor = argumentCaptor() + verify(delegate).onMessageReceived(captor.capture()) + assertEquals("relay-a", captor.firstValue.sender) + assertEquals(peerID, captor.firstValue.senderPeerID) + } + + @Test + fun `a broadcast from an unknown peer with no persisted identity is still dropped`() = runBlocking { + whenever(delegate.getBroadcastRecipient()).thenReturn(SpecialRecipients.BROADCAST) + whenever(delegate.getPeerInfo(peerID)).thenReturn(null) + whenever(delegate.getPersistedPeerNickname(peerID)).thenReturn(null) + val packet = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = peerID.hexToBytes(), + recipientID = null, + timestamp = System.currentTimeMillis().toULong(), + payload = "hello".toByteArray(), + signature = ByteArray(64) { 1 }, + ttl = 0u + ) + + handler.handleMessage(RoutedPacket(packet, peerID, "direct-link")) + + verify(delegate, never()).onMessageReceived(any()) + } + + @Test + fun `a broadcast file from a departed peer with a persisted identity is delivered under its persisted nickname`() = runBlocking { + whenever(delegate.getBroadcastRecipient()).thenReturn(SpecialRecipients.BROADCAST) + whenever(delegate.getPeerInfo(peerID)).thenReturn(null) + whenever(delegate.getPeerNickname(peerID)).thenReturn(null) + whenever(delegate.getPersistedPeerNickname(peerID)).thenReturn("relay-a") + val file = BitchatFilePacket( + fileName = "earlier.jpg", + fileSize = 3, + mimeType = "image/jpeg", + content = byteArrayOf(1, 2, 3) + ) + val packet = BitchatPacket( + version = 2u, + type = MessageType.FILE_TRANSFER.value, + senderID = peerID.hexToBytes(), + recipientID = null, + timestamp = System.currentTimeMillis().toULong(), + payload = file.encode()!!, + signature = ByteArray(64) { 1 }, + ttl = 0u + ) + + handler.handleMessage(RoutedPacket(packet, peerID, "direct-link")) + + val captor = argumentCaptor() + verify(delegate).onMessageReceived(captor.capture()) + assertEquals("relay-a", captor.firstValue.sender) + } + + @Test + fun `a broadcast from a live peer whose nickname is unverified is still dropped despite a persisted nickname`() = runBlocking { + whenever(delegate.getBroadcastRecipient()).thenReturn(SpecialRecipients.BROADCAST) + whenever(delegate.getPeerInfo(peerID)).thenReturn( + PeerInfo( + id = peerID, nickname = peerID, isConnected = true, isDirectConnection = true, + noisePublicKey = noiseKey, signingPublicKey = signingKey, isVerifiedNickname = false, + lastSeen = System.currentTimeMillis() + ) + ) + whenever(delegate.getPersistedPeerNickname(peerID)).thenReturn("relay-a") + val packet = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = peerID.hexToBytes(), + recipientID = null, + timestamp = System.currentTimeMillis().toULong(), + payload = "hello".toByteArray(), + signature = ByteArray(64) { 1 }, + ttl = 0u + ) + + handler.handleMessage(RoutedPacket(packet, peerID, "direct-link")) + + verify(delegate, never()).onMessageReceived(any()) + } + + @Test + fun `a re-served announce past the skew window restores no key but the message still lands on a persisted identity`() = runBlocking { + whenever(delegate.getBroadcastRecipient()).thenReturn(SpecialRecipients.BROADCAST) + whenever(delegate.getPeerInfo(peerID)).thenReturn(null) + whenever(delegate.getPeerNickname(peerID)).thenReturn(null) + whenever(delegate.getPersistedPeerNickname(peerID)).thenReturn("relay-a") + + val staleAnnounce = announcePacket(ageMs = announceClockSkewToleranceMs + 60_000) + assertFalse(handler.handleAnnounce(RoutedPacket(staleAnnounce, peerID, "direct-link"))) + verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(any(), any(), any(), any(), any(), anyOrNull()) + + val message = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = peerID.hexToBytes(), + recipientID = null, + timestamp = (System.currentTimeMillis() - announceClockSkewToleranceMs - 60_000).toULong(), + payload = "from before they left".toByteArray(), + signature = ByteArray(64) { 1 }, + ttl = 0u + ) + handler.handleMessage(RoutedPacket(message, peerID, "direct-link")) + + val captor = argumentCaptor() + verify(delegate).onMessageReceived(captor.capture()) + assertEquals("relay-a", captor.firstValue.sender) + } } diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/SecureAuthenticatedPeerStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/SecureAuthenticatedPeerStateStoreTest.kt new file mode 100644 index 00000000..b065342b --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/SecureAuthenticatedPeerStateStoreTest.kt @@ -0,0 +1,55 @@ +package com.bitchat.android.mesh + +import android.content.Context +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.PeerCapabilities +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.util.UUID + +/** + * Pins the persisted lookups the message path relies on for senders the live + * registry has forgotten: the store must resolve a peer ID to its persisted + * fingerprint, and a fingerprint to its persisted nickname, through the same + * records the app already keeps. + */ +@RunWith(RobolectricTestRunner::class) +class SecureAuthenticatedPeerStateStoreTest { + + private val fingerprint = "ab".repeat(32) + private val peerID = fingerprint.take(16) + private val signingKey = ByteArray(32) { 0x5A } + + private fun freshStore(): Pair { + val context = RuntimeEnvironment.getApplication() + val prefs = context.getSharedPreferences("store-test-${UUID.randomUUID()}", Context.MODE_PRIVATE) + val identity = SecureIdentityStateManager(prefs, testOnly = true) + return SecureAuthenticatedPeerStateStore(context, identity) to identity + } + + @Test + fun `a departed peer resolves by peer ID to its persisted state and nickname`() { + val (store, identity) = freshStore() + identity.cacheFingerprintNickname(fingerprint, "relay-a") + identity.storeAuthenticatedPeerState(fingerprint, AuthenticatedPeerState(PeerCapabilities.NONE, signingKey)) + + assertEquals(fingerprint, store.persistedFingerprintFor(peerID)) + assertEquals("relay-a", store.persistedNickname(fingerprint)) + assertArrayEquals(signingKey, store.load(fingerprint)?.signingPublicKey) + } + + @Test + fun `a peer this device never persisted resolves to nothing`() { + val (store, identity) = freshStore() + identity.cacheFingerprintNickname(fingerprint, "relay-a") + + assertNull("a cached nickname is not a persisted identity", store.persistedFingerprintFor(peerID)) + assertNull(store.load(fingerprint)) + } +} 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 2a56e708..ffb6ed04 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt @@ -11,6 +11,7 @@ import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import kotlinx.coroutines.runBlocking import org.junit.After +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before @@ -202,6 +203,65 @@ class SecurityManagerTest { assertFalse("Packet from unknown peer should be rejected (cannot verify signature)", result) } + @Test + fun `validatePacket accepts a signed MESSAGE from a departed peer whose identity is persisted`() { + whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null) + whenever(mockDelegate.getPersistedSigningKey(unknownPeerID)).thenReturn(otherSigningKey) + + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 0u, + senderID = unknownPeerID, + payload = dummyPayload + ) + packet.signature = validSignature + + val result = securityManager.validatePacket(packet, unknownPeerID) + + assertTrue("a persisted signing key must verify a sender no longer in the live registry", result) + assertArrayEquals(otherSigningKey, fakeEncryptionService.lastVerifyKey) + } + + @Test + fun `validatePacket prefers the live registry key over a persisted one`() { + val liveKey = ByteArray(32) { 0x1C } + whenever(mockDelegate.getPeerInfo(otherPeerID)).thenReturn( + PeerInfo( + id = otherPeerID, nickname = "live", isConnected = true, isDirectConnection = true, + noisePublicKey = otherNoiseKey, signingPublicKey = liveKey, isVerifiedNickname = true, + lastSeen = System.currentTimeMillis() + ) + ) + whenever(mockDelegate.getPersistedSigningKey(otherPeerID)).thenReturn(otherSigningKey) + + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 0u, + senderID = otherPeerID, + payload = dummyPayload + ) + packet.signature = validSignature + + assertTrue(securityManager.validatePacket(packet, otherPeerID)) + assertArrayEquals("a present peer is verified against its live key, never the persisted one", liveKey, fakeEncryptionService.lastVerifyKey) + } + + @Test + fun `validatePacket still rejects a MESSAGE with neither a live nor a persisted key`() { + whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null) + whenever(mockDelegate.getPersistedSigningKey(unknownPeerID)).thenReturn(null) + + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 0u, + senderID = unknownPeerID, + payload = dummyPayload + ) + packet.signature = validSignature + + assertFalse("an unknown sender with nothing persisted stays rejected", securityManager.validatePacket(packet, unknownPeerID)) + } + @Test fun `validatePacket - accepts packet with valid signature from known peer`() { setupKnownPeer(otherPeerID, otherSigningKey) diff --git a/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncArchiveGuardTest.kt b/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncArchiveGuardTest.kt new file mode 100644 index 00000000..7cc53d8a --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncArchiveGuardTest.kt @@ -0,0 +1,119 @@ +package com.bitchat.android.sync + +import com.bitchat.android.model.RequestSyncPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.protocol.SpecialRecipients +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * The LEAVE and stale purges remove a sender's announcement and messages together. A + * message accepted on a persisted key alone, from a sender the live registry no longer + * holds, must not re-enter the archive behind that purge: nothing would prune it and every + * requester would be served it again. Present peers and this device's own broadcasts are + * archived exactly as before. + */ +class GossipSyncArchiveGuardTest { + + private val sent = mutableListOf>() + private lateinit var scope: CoroutineScope + private lateinit var manager: GossipSyncManager + + private val requester = "aabbccddeeff0011" + private val sender = ByteArray(8) { 0x11 } + private val senderID = sender.joinToString("") { "%02x".format(it) } + + private var livePeers = setOf() + + private val delegate = object : GossipSyncManager.Delegate { + override fun hasLivePeer(peerID: String): Boolean = peerID in livePeers + override fun sendPacket(packet: BitchatPacket) = Unit + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + sent += peerID to packet + } + override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket = packet + } + + private val config = object : GossipSyncManager.ConfigProvider { + override fun seenCapacity(): Int = 100 + override fun gcsMaxBytes(): Int = 400 + override fun gcsTargetFpr(): Double = 0.01 + } + + @Before + fun setUp() { + sent.clear() + livePeers = emptySet() + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + manager = GossipSyncManager(myPeerID = "1122334455667788", scope = scope, configProvider = config) + manager.delegate = delegate + } + + @After + fun tearDown() { + scope.cancel() + } + + private fun broadcastMessage(ageMillis: Long): BitchatPacket = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = sender, + recipientID = SpecialRecipients.BROADCAST, + timestamp = (System.currentTimeMillis() - ageMillis).toULong(), + payload = "from before they left".toByteArray(), + signature = ByteArray(64) { 0x22 }, + ttl = 0u + ) + + private fun announce(ageMillis: Long): BitchatPacket = BitchatPacket( + version = 1u, + type = MessageType.ANNOUNCE.value, + senderID = sender, + recipientID = null, + timestamp = (System.currentTimeMillis() - ageMillis).toULong(), + payload = "nickname".toByteArray(), + signature = ByteArray(64) { 0x44 }, + ttl = 7u + ) + + /** A filter the requester builds when it holds nothing: everything we have is missing. */ + private fun requestForNothingHeld() = RequestSyncPacket(p = 7, m = 1, data = ByteArray(0)) + + @Test + fun `a message from a sender the live registry has forgotten is not archived or served`() { + manager.onPublicPacketSeen(broadcastMessage(ageMillis = 60_000L)) + + manager.handleRequestSync(requester, requestForNothingHeld()) + + assertTrue("nothing must be served for a sender the registry has forgotten", sent.isEmpty()) + } + + @Test + fun `a message from a present peer is archived and served even with no announcement stored`() { + livePeers = setOf(senderID) + manager.onPublicPacketSeen(broadcastMessage(ageMillis = 60_000L)) + + manager.handleRequestSync(requester, requestForNothingHeld()) + + assertEquals("a present peer's message is archived as before", 1, sent.size) + assertEquals(MessageType.MESSAGE.value, sent.single().second.type) + } + + @Test + fun `this device's own broadcasts are archived regardless of the registry`() { + val ownSender = "1122334455667788".chunked(2).map { it.toInt(16).toByte() }.toByteArray() + manager.onPublicPacketSeen(broadcastMessage(ageMillis = 1_000L).copy(senderID = ownSender)) + + manager.handleRequestSync(requester, requestForNothingHeld()) + + assertEquals("own broadcasts must keep being served", 1, sent.size) + } +} From e1da7f8960bcd1c34e59ab57f87b3fc6b7ca8deb Mon Sep 17 00:00:00 2001 From: heyaim <223061694+heyaim@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:23:39 -0500 Subject: [PATCH 2/3] Ask every transport's registry before the archive guard drops a sender The archive guard in the first commit checked only the Bluetooth peer registry. The Wi-Fi Aware transport feeds the same archive from a registry of its own. With Wi-Fi Aware on, a sender known only there was never archived for sync, and with Bluetooth switched off nothing was. Codex flagged this on the PR. The holder now keeps one liveness probe per transport, registered the way it already tracks gossip owners, and the shared manager's delegate asks all of them. The probes live in the holder because the Bluetooth service has no reference to the Wi-Fi service. Three tests pin the holder, including a Wi-Fi-only sender's broadcast being archived and served. The Wi-Fi Aware register and unregister lines have no unit test, since that service has none. --- .../android/mesh/BluetoothMeshService.kt | 8 +- .../android/service/MeshServiceHolder.kt | 34 +++++- .../wifi-aware/WifiAwareMeshService.kt | 5 + .../service/MeshServiceHolderLivenessTest.kt | 115 ++++++++++++++++++ 4 files changed, 153 insertions(+), 9 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/service/MeshServiceHolderLivenessTest.kt diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 47d627c7..d11bf729 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -171,10 +171,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } ) - com.bitchat.android.service.MeshServiceHolder.setGossipManager( - gossipSyncManager, - hasLivePeer = { peerID -> peerManager.getPeerInfo(peerID) != null } - ) { packet -> + com.bitchat.android.service.MeshServiceHolder.registerLivenessProbe("BLE") { peerID -> + peerManager.getPeerInfo(peerID) != null + } + com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) { packet -> signPacketBeforeBroadcast(packet) } if (isBleTransportEnabled()) { diff --git a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt index a03089ab..32504a1e 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt @@ -6,6 +6,7 @@ import com.bitchat.android.mesh.UnifiedMeshService import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.sync.GossipSyncManager +import java.util.concurrent.ConcurrentHashMap /** * Process-wide holder to share a single BluetoothMeshService instance @@ -19,10 +20,17 @@ object MeshServiceHolder { private val activeGossipOwners = mutableSetOf() + // One liveness probe per transport, keyed like the gossip owners above. The shared gossip + // manager archives a broadcast only when its sender is in a live peer registry, and every + // transport keeps its own registry: Bluetooth registers its lookup, Wi-Fi Aware registers + // its own while it runs. A sender known to any transport is live. The Bluetooth service + // registers before any delegate exists, so the empty map is never consulted in practice; + // if it were, the answer is true, which archives as the manager did before the guard. + private val livenessProbes = ConcurrentHashMap Boolean>() + @Synchronized fun setGossipManager( mgr: GossipSyncManager, - hasLivePeer: (String) -> Boolean = { true }, signer: (BitchatPacket) -> BitchatPacket ) { val previous = sharedGossipSyncManager @@ -30,12 +38,28 @@ object MeshServiceHolder { try { previous?.stop() } catch (_: Exception) { } } sharedGossipSyncManager = mgr - mgr.delegate = TransportGossipDelegate(signer, hasLivePeer) + mgr.delegate = TransportGossipDelegate(signer) if (activeGossipOwners.isNotEmpty()) { mgr.start() } } + fun registerLivenessProbe(owner: String, probe: (String) -> Boolean) { + livenessProbes[owner] = probe + } + + fun unregisterLivenessProbe(owner: String) { + livenessProbes.remove(owner) + } + + /** True when any transport's live peer registry holds this peer. */ + fun hasLivePeer(peerID: String): Boolean { + if (livenessProbes.isEmpty()) return true + return livenessProbes.values.any { probe -> + try { probe(peerID) } catch (_: Exception) { false } + } + } + @Synchronized fun startSharedGossip(owner: String) { val wasIdle = activeGossipOwners.isEmpty() @@ -54,10 +78,9 @@ object MeshServiceHolder { } private class TransportGossipDelegate( - private val signer: (BitchatPacket) -> BitchatPacket, - private val livePeer: (String) -> Boolean + private val signer: (BitchatPacket) -> BitchatPacket ) : GossipSyncManager.Delegate { - override fun hasLivePeer(peerID: String): Boolean = livePeer(peerID) + override fun hasLivePeer(peerID: String): Boolean = MeshServiceHolder.hasLivePeer(peerID) override fun sendPacket(packet: BitchatPacket) { TransportBridgeService.broadcastFromLocal(RoutedPacket(packet)) @@ -144,6 +167,7 @@ object MeshServiceHolder { try { sharedGossipSyncManager?.stop() } catch (_: Exception) { } sharedGossipSyncManager = null activeGossipOwners.clear() + livenessProbes.clear() meshService = null unifiedMeshService = null } diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index 02bfb2d3..c539fc29 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -515,6 +515,9 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor TransportBridgeService.register("WIFI", this) meshCore.startCore() + com.bitchat.android.service.MeshServiceHolder.registerLivenessProbe("WIFI") { peerID -> + meshCore.getPeerInfo(peerID) != null + } com.bitchat.android.service.MeshServiceHolder.startSharedGossip("WIFI") startPeriodicConnectionMaintenance() connectionTracker.start() @@ -532,6 +535,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor // Unregister from bridge TransportBridgeService.unregister("WIFI") com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("WIFI") + com.bitchat.android.service.MeshServiceHolder.unregisterLivenessProbe("WIFI") try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("WIFI") } catch (_: Exception) { } @@ -578,6 +582,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor isActive = false TransportBridgeService.unregister("WIFI") com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("WIFI") + com.bitchat.android.service.MeshServiceHolder.unregisterLivenessProbe("WIFI") try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("WIFI") } catch (_: Exception) { } val oldPublishSession = publishSession diff --git a/app/src/test/kotlin/com/bitchat/android/service/MeshServiceHolderLivenessTest.kt b/app/src/test/kotlin/com/bitchat/android/service/MeshServiceHolderLivenessTest.kt new file mode 100644 index 00000000..7d70b505 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/service/MeshServiceHolderLivenessTest.kt @@ -0,0 +1,115 @@ +package com.bitchat.android.service + +import com.bitchat.android.model.RequestSyncPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.protocol.SpecialRecipients +import com.bitchat.android.sync.GossipSyncManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * The shared gossip manager archives a broadcast only when its sender is in a live peer + * registry, and every transport keeps its own registry. A sender known only to the Wi-Fi + * Aware registry must count as live, or its broadcasts would never be archived for sync. + */ +class MeshServiceHolderLivenessTest { + + private lateinit var scope: CoroutineScope + private lateinit var manager: GossipSyncManager + + private val blePeer = "aaaaaaaaaaaaaaaa" + private val wifiSender = ByteArray(8) { 0x22 } + private val wifiPeer = wifiSender.joinToString("") { "%02x".format(it) } + private val stranger = "cccccccccccccccc" + private val requester = "aabbccddeeff0011" + + private val config = object : GossipSyncManager.ConfigProvider { + override fun seenCapacity(): Int = 100 + override fun gcsMaxBytes(): Int = 400 + override fun gcsTargetFpr(): Double = 0.01 + } + + @Before + fun setUp() { + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + manager = GossipSyncManager(myPeerID = "1122334455667788", scope = scope, configProvider = config) + MeshServiceHolder.unregisterLivenessProbe("WIFI") + MeshServiceHolder.registerLivenessProbe("BLE") { it == blePeer } + MeshServiceHolder.setGossipManager(manager) { it } + } + + @After + fun tearDown() { + MeshServiceHolder.unregisterLivenessProbe("WIFI") + MeshServiceHolder.unregisterLivenessProbe("BLE") + scope.cancel() + } + + private fun broadcastFromWifiSender(): BitchatPacket = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = wifiSender, + recipientID = SpecialRecipients.BROADCAST, + timestamp = (System.currentTimeMillis() - 1000L).toULong(), + payload = "over wifi".toByteArray(), + signature = ByteArray(64) { 0x33 }, + ttl = 7u + ) + + /** A filter the requester builds when it holds nothing: everything we have is missing. */ + private fun requestForNothingHeld() = RequestSyncPacket(p = 7, m = 1, data = ByteArray(0)) + + @Test + fun `a peer known only to the Wi-Fi Aware registry is live once its probe is registered`() { + val delegate = manager.delegate!! + assertFalse("before the Wi-Fi probe exists only Bluetooth answers", delegate.hasLivePeer(wifiPeer)) + + MeshServiceHolder.registerLivenessProbe("WIFI") { it == wifiPeer } + + assertTrue(delegate.hasLivePeer(wifiPeer)) + assertTrue("the Bluetooth registry still counts", delegate.hasLivePeer(blePeer)) + assertFalse("a peer in no registry is absent", delegate.hasLivePeer(stranger)) + } + + @Test + fun `removing a transport's probe makes its peers absent again`() { + val delegate = manager.delegate!! + MeshServiceHolder.registerLivenessProbe("WIFI") { it == wifiPeer } + assertTrue(delegate.hasLivePeer(wifiPeer)) + + MeshServiceHolder.unregisterLivenessProbe("WIFI") + + assertFalse(delegate.hasLivePeer(wifiPeer)) + assertTrue(delegate.hasLivePeer(blePeer)) + } + + @Test + fun `a broadcast from a Wi-Fi-only sender is archived and served`() { + MeshServiceHolder.registerLivenessProbe("WIFI") { it == wifiPeer } + val original = broadcastFromWifiSender() + + manager.onPublicPacketSeen(original) + + val sent = mutableListOf() + manager.delegate = object : GossipSyncManager.Delegate { + override fun sendPacket(packet: BitchatPacket) = Unit + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + sent += packet + } + override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket = packet + } + manager.handleRequestSync(requester, requestForNothingHeld()) + + assertEquals("the Wi-Fi-only sender's message must be served", 1, sent.size) + assertTrue(sent[0].payload.contentEquals(original.payload)) + } +} From 605958b405047949d598384c8c6006316ab90840 Mon Sep 17 00:00:00 2001 From: heyaim <223061694+heyaim@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:27:22 -0500 Subject: [PATCH 3/3] Match the persisted peer state on its fingerprint field findAuthenticatedFingerprintByPeerID took the first stored record that began with the peer ID. If that record's first field was not a fingerprint, the search stopped there with nothing found. The lookup now reads the fingerprint field of each record and takes the one that is 64 hex characters starting with the peer ID, skipping any record whose field is not a fingerprint. Records this app writes behave the same as before. One test puts a record whose first field is not a fingerprint ahead of the real one and checks that the real one is still found. --- .../identity/SecureIdentityStateManager.kt | 19 +++++++++++++++---- .../SecureAuthenticatedPeerStateStoreTest.kt | 11 +++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt index 844a5daa..026c4a43 100644 --- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt +++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt @@ -393,16 +393,27 @@ class SecureIdentityStateManager { /** * Fingerprint of the persisted authenticated peer state for a peer ID. A peer ID is the - * first sixteen hex characters of the fingerprint of its Noise static key, so a record - * whose fingerprint starts with the peer ID is that peer's; the record must still parse. + * first sixteen hex characters of the fingerprint of its Noise static key. Each record is + * matched on its fingerprint field, 64 hex characters starting with the peer ID; a record + * whose field does not parse is skipped, and the matched record must still load. */ fun findAuthenticatedFingerprintByPeerID(peerID: String): String? { val prefix = peerID.lowercase() if (prefix.length != 16 || !prefix.all { it in '0'..'9' || it in 'a'..'f' }) return null val records = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet()) ?: return null - val fingerprint = records.firstOrNull { it.startsWith(prefix) }?.substringBefore(':') ?: return null - return fingerprint.takeIf { isValidFingerprint(it) && getAuthenticatedPeerState(it) != null } + val fingerprint = fingerprintFieldFor(prefix, records) ?: return null + return fingerprint.takeIf { getAuthenticatedPeerState(it) != null } } + + /** + * The fingerprint field of the first record, in the order given, that parses to 64 hex + * characters starting with [prefix]. A record whose field does not parse is skipped, not + * taken for a match on the raw string. + */ + internal fun fingerprintFieldFor(prefix: String, records: Iterable): String? = + records.asSequence() + .map { it.substringBefore(':').lowercase() } + .firstOrNull { isValidFingerprint(it) && it.startsWith(prefix) } // MARK: - Peer ID Rotation Management (removed) // Android now derives peer ID from the persisted Noise identity fingerprint. diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/SecureAuthenticatedPeerStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/SecureAuthenticatedPeerStateStoreTest.kt index b065342b..a4046f9a 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/SecureAuthenticatedPeerStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/SecureAuthenticatedPeerStateStoreTest.kt @@ -52,4 +52,15 @@ class SecureAuthenticatedPeerStateStoreTest { assertNull("a cached nickname is not a persisted identity", store.persistedFingerprintFor(peerID)) assertNull(store.load(fingerprint)) } + + @Test + fun `a record whose fingerprint field is not 64 hex is ignored`() { + val (_, identity) = freshStore() + // The raw string starts with the peer ID but the fingerprint field is only 16 characters. + // Examined first, it must be skipped and the real record still found. + val malformed = "$peerID:0:${"00".repeat(32)}" + val real = "$fingerprint:0:${"5a".repeat(32)}" + + assertEquals(fingerprint, identity.fingerprintFieldFor(peerID, listOf(malformed, real))) + } }