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..026c4a43 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,30 @@ 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. 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 = 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/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..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,6 +171,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } ) + com.bitchat.android.service.MeshServiceHolder.registerLivenessProbe("BLE") { peerID -> + peerManager.getPeerInfo(peerID) != null + } com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) { packet -> signPacketBeforeBroadcast(packet) } @@ -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 6fab9f03..531a8ee3 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -303,13 +303,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 } @@ -470,4 +473,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..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,6 +20,14 @@ 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, @@ -35,6 +44,22 @@ object MeshServiceHolder { } } + 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() @@ -55,6 +80,8 @@ object MeshServiceHolder { private class TransportGossipDelegate( private val signer: (BitchatPacket) -> BitchatPacket ) : GossipSyncManager.Delegate { + override fun hasLivePeer(peerID: String): Boolean = MeshServiceHolder.hasLivePeer(peerID) + override fun sendPacket(packet: BitchatPacket) { TransportBridgeService.broadcastFromLocal(RoutedPacket(packet)) } @@ -140,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/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/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/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..a4046f9a --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/SecureAuthenticatedPeerStateStoreTest.kt @@ -0,0 +1,66 @@ +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)) + } + + @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))) + } +} 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 e43e11b5..c894f26d 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/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)) + } +} 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) + } +}