diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index 3a75188c..c584455e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -67,8 +67,8 @@ class BluetoothConnectionManager( delegate?.onDeviceConnected(device) } - override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?) { - delegate?.onDeviceDisconnected(device, linkID) + override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?) { + delegate?.onDeviceDisconnected(device, linkID, peerID) } override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { @@ -512,6 +512,6 @@ interface BluetoothConnectionManagerDelegate { ingressLinkID: String ) fun onDeviceConnected(device: BluetoothDevice) - fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?) + fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?) fun onRSSIUpdated(deviceAddress: String, rssi: Int) } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt index 66971ea4..cc13d5df 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -527,10 +527,12 @@ class BluetoothGattClientManager( } else { Log.i(TAG, "Disconnected from $deviceAddress (client)") } + // Capture the observed peer before cleanup drops the address mapping. + val disconnectedPeerID = connectionTracker.addressPeerMap[deviceAddress] connectionTracker.cleanupDeviceConnectionIfCurrent(deviceAddress, linkID) // Notify higher layers about device disconnection to update direct flags - delegate?.onDeviceDisconnected(gatt.device, linkID) + delegate?.onDeviceDisconnected(gatt.device, linkID, disconnectedPeerID) connectionScope.launch { delay(500) // CLEANUP_DELAY diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt index 53ae88ef..7a15b963 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -203,11 +203,13 @@ class BluetoothGattServerManager( BluetoothProfile.STATE_DISCONNECTED -> { Log.i(TAG, "Disconnected from ${device.address} (server)") val linkID = serverLinkIDs.remove(device.address) + // Capture the observed peer before cleanup drops the address mapping. + val disconnectedPeerID = connectionTracker.addressPeerMap[device.address] if (linkID != null) { connectionTracker.cleanupDeviceConnectionIfCurrent(device.address, linkID) } // Notify delegate about device disconnection so higher layers can update direct flags - delegate?.onDeviceDisconnected(device, linkID) + delegate?.onDeviceDisconnected(device, linkID, disconnectedPeerID) } } } 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 2e0507ab..37bcfffd 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -43,6 +43,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic companion object { private const val TAG = "BluetoothMeshService" private val MAX_TTL: UByte = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS + private const val PEER_DISCONNECT_GRACE_MS = com.bitchat.android.util.AppConstants.Mesh.PEER_DISCONNECT_GRACE_MS } // Core components - each handling specific responsibilities @@ -410,6 +411,14 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic override fun hasNoiseSession(peerID: String): Boolean { return encryptionService.hasEstablishedSession(peerID) } + + override fun removeNoiseSession(peerID: String) { + try { + encryptionService.removePeer(peerID) + } catch (e: Exception) { + Log.w(TAG, "Failed to remove Noise session for $peerID: ${e.message}") + } + } override fun initiateNoiseHandshake(peerID: String) { try { @@ -543,8 +552,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic return runBlocking { securityManager.handleNoiseHandshake(routed) } } - override fun handleNoiseEncrypted(routed: RoutedPacket) { - serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) } + override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean { + return runBlocking { messageHandler.handleNoiseEncrypted(routed) } } override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { @@ -669,16 +678,41 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic override fun onDeviceDisconnected( device: android.bluetooth.BluetoothDevice, - linkID: String? + linkID: String?, + peerID: String? ) { - Log.i(TAG, "Device disconnected: ${device.address}") + Log.i(TAG, "Device disconnected: ${device.address} (peerID: $peerID)") - // refresh peer list on disconnect. + // refresh peer list on disconnect. try { peerManager.refreshPeerList() } catch (_: Exception) { } // ConnectionTracker already removes an observed mapping only when this exact // link is still current. Do not remove by reusable address here: this may be a late // disconnect callback from a replaced GATT connection. + + // If the peer that used this link does not come back within a short grace + // period (no other link, no traffic), tear down their Noise session instead of + // waiting for the 3-minute stale-peer sweep. + if (peerID != null) { + val deviceAddress = device.address + val disconnectedAt = System.currentTimeMillis() + serviceScope.launch { + delay(PEER_DISCONNECT_GRACE_MS) + try { + val linkBack = + connectionManager.addressPeerMap.containsKey(deviceAddress) || + connectionManager.addressPeerMap.containsValue(peerID) + val lastSeen = peerManager.getPeerInfo(peerID)?.lastSeen ?: 0L + val seenAfterDisconnect = lastSeen > disconnectedAt + if (!linkBack && !seenAfterDisconnect) { + Log.i(TAG, "Peer $peerID did not return after disconnect; removing peer and Noise session") + peerManager.removePeer(peerID) + } + } catch (e: Exception) { + Log.w(TAG, "Disconnect grace check failed for $peerID: ${e.message}") + } + } + } } override fun onRSSIUpdated(deviceAddress: String, rssi: Int) { 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 2387e939..6fbddacc 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -364,6 +364,14 @@ class MeshCore( return encryptionService.hasEstablishedSession(peerID) } + override fun removeNoiseSession(peerID: String) { + try { + encryptionService.removePeer(peerID) + } catch (e: Exception) { + Log.w("MeshCore", "Failed to remove Noise session for $peerID: ${e.message}") + } + } + override fun initiateNoiseHandshake(peerID: String) { this@MeshCore.initiateNoiseHandshake(peerID) } @@ -439,8 +447,8 @@ class MeshCore( return runBlocking { securityManager.handleNoiseHandshake(routed) } } - override fun handleNoiseEncrypted(routed: RoutedPacket) { - scope.launch { messageHandler.handleNoiseEncrypted(routed) } + override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean { + return runBlocking { messageHandler.handleNoiseEncrypted(routed) } } override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { 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 b0f75a94..05a5bf00 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -27,52 +27,62 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro companion object { private const val TAG = "MessageHandler" private const val ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS = 10 * 60 * 1000L + private const val MAX_CONSECUTIVE_DECRYPT_FAILURES = 3 } - + // Delegate for callbacks var delegate: MessageHandlerDelegate? = null - + // Reference to PacketProcessor for recursive packet handling var packetProcessor: PacketProcessor? = null - + // Coroutines private val handlerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - + + // Consecutive decrypt failures per peer; only signature-verified packets reach this path, + // so repeated failures mean the established session is stale (peer re-handshaked elsewhere). + private val consecutiveDecryptFailures = java.util.concurrent.ConcurrentHashMap() + /** * Handle Noise encrypted transport message - SIMPLIFIED iOS-compatible version * Uses NoisePayloadType system exactly like iOS SimplifiedBluetoothService + * + * Returns false when the payload could not be decrypted, so callers can treat the + * packet as not liveness-proving (no lastSeen refresh, no relay). */ - suspend fun handleNoiseEncrypted(routed: RoutedPacket) { + suspend fun handleNoiseEncrypted(routed: RoutedPacket): Boolean { val packet = routed.packet val peerID = routed.peerID ?: "unknown" - + // Skip our own messages - if (peerID == myPeerID) return - + if (peerID == myPeerID) return true + // Check if this message is for us val recipientID = packet.recipientID?.toHexString() if (recipientID != myPeerID) { - return + return true } - + try { // Decrypt the message using the Noise service val decryption = delegate?.decryptFromPeer(packet.payload, peerID) if (decryption == null) { Log.w(TAG, "Failed to decrypt Noise message from $peerID - may need handshake") - return + registerDecryptFailure(peerID) + return false } + consecutiveDecryptFailures.remove(peerID) val decryptedData = decryption.plaintext - + if (decryptedData.isEmpty()) { Log.w(TAG, "Decrypted data is empty from $peerID") - return + return true } - + val noisePayload = com.bitchat.android.model.NoisePayload.decode(decryptedData) if (noisePayload == null) { Log.w(TAG, "Failed to parse NoisePayload from $peerID") - return + return true } when (noisePayload.type) { @@ -86,7 +96,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro handleFavoriteNotificationFromMesh(pmContent, peerID) // Acknowledge delivery for UX parity sendDeliveryAck(privateMessage.messageID, peerID) - return + return true } // Create BitchatMessage - preserve source packet timestamp @@ -180,6 +190,31 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro } catch (e: Exception) { Log.e(TAG, "Error processing Noise encrypted message from $peerID: ${e.message}") } + return true + } + + /** + * Count consecutive decrypt failures from a signature-verified peer that we still hold an + * established session for. After repeated failures the session is stale (the peer completed + * a new handshake elsewhere), so destroy it and start a fresh handshake; the peer's side + * will finish via the responder-candidate path and evict its own stale session. + */ + private fun registerDecryptFailure(peerID: String) { + if (peerID == "unknown" || peerID == myPeerID) return + if (delegate?.hasNoiseSession(peerID) != true) return + val failures = (consecutiveDecryptFailures[peerID] ?: 0) + 1 + if (failures >= MAX_CONSECUTIVE_DECRYPT_FAILURES) { + consecutiveDecryptFailures.remove(peerID) + Log.w(TAG, "Noise session with $peerID stale after $failures decrypt failures; resetting and re-handshaking") + try { delegate?.removeNoiseSession(peerID) } catch (e: Exception) { + Log.w(TAG, "Failed to reset Noise session for $peerID: ${e.message}") + } + try { delegate?.initiateNoiseHandshake(peerID) } catch (e: Exception) { + Log.w(TAG, "Failed to re-initiate handshake with $peerID: ${e.message}") + } + } else { + consecutiveDecryptFailures[peerID] = failures + } } /** @@ -646,6 +681,7 @@ interface MessageHandlerDelegate { // Noise protocol operations fun hasNoiseSession(peerID: String): Boolean fun initiateNoiseHandshake(peerID: String) + fun removeNoiseSession(peerID: String) {} fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? fun onAuthenticatedPeerStateReceived( peerID: String, diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt index a5c59901..b00c6a31 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -144,7 +144,7 @@ class PacketProcessor(private val myPeerID: String) { if (packetRelayManager.isPacketAddressedToMe(packet)) { when (messageType) { MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed) - MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed) + MessageType.NOISE_ENCRYPTED -> validPacket = handleNoiseEncrypted(routed) MessageType.FILE_TRANSFER -> handleMessage(routed) else -> { validPacket = false @@ -175,9 +175,10 @@ class PacketProcessor(private val myPeerID: String) { /** * Handle Noise encrypted transport message + * Returns false when decryption fails so undecryptable packets do not prove liveness. */ - private suspend fun handleNoiseEncrypted(routed: RoutedPacket) { - delegate?.handleNoiseEncrypted(routed) + private suspend fun handleNoiseEncrypted(routed: RoutedPacket): Boolean { + return delegate?.handleNoiseEncrypted(routed) ?: false } /** @@ -292,7 +293,7 @@ interface PacketProcessorDelegate { // Message type handlers fun handleNoiseHandshake(routed: RoutedPacket): Boolean - fun handleNoiseEncrypted(routed: RoutedPacket) + fun handleNoiseEncrypted(routed: RoutedPacket): Boolean suspend fun handleAnnounce(routed: RoutedPacket): Boolean fun handleMessage(routed: RoutedPacket) fun handleLeave(routed: RoutedPacket) 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 66698049..e3c0388a 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -25,12 +25,14 @@ class SecurityManager(private val encryptionService: EncryptionService, private private const val CLEANUP_INTERVAL = com.bitchat.android.util.AppConstants.Security.CLEANUP_INTERVAL_MS // 5 minutes private const val MAX_PROCESSED_MESSAGES = com.bitchat.android.util.AppConstants.Security.MAX_PROCESSED_MESSAGES private const val MAX_PROCESSED_KEY_EXCHANGES = com.bitchat.android.util.AppConstants.Security.MAX_PROCESSED_KEY_EXCHANGES + private const val KEY_EXCHANGE_DEDUP_TIMEOUT = com.bitchat.android.util.AppConstants.Security.KEY_EXCHANGE_DEDUP_TIMEOUT_MS } // Security tracking private val processedMessages = Collections.synchronizedSet(mutableSetOf()) private val processedKeyExchanges = Collections.synchronizedSet(mutableSetOf()) private val messageTimestamps = Collections.synchronizedMap(mutableMapOf()) + private val keyExchangeTimestamps = Collections.synchronizedMap(mutableMapOf()) // Delegate for callbacks var delegate: SecurityManagerDelegate? = null @@ -134,6 +136,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private // from ambient session state: a rejected replacement may leave the old session active. val result = encryptionService.processHandshakeMessageWithResult(packet.payload, peerID) processedKeyExchanges.add(exchangeKey) + keyExchangeTimestamps[exchangeKey] = System.currentTimeMillis() if (result.response != null) { // Send handshake response through delegate @@ -393,8 +396,8 @@ class SecurityManager(private val encryptionService: EncryptionService, private /** * Clean up old processed messages and timestamps */ - private fun cleanupOldData() { - val cutoffTime = System.currentTimeMillis() - MESSAGE_TIMEOUT + internal fun cleanupOldData(nowMs: Long = System.currentTimeMillis()) { + val cutoffTime = nowMs - MESSAGE_TIMEOUT // Clean up old message timestamps and corresponding processed messages val messagesToRemove = messageTimestamps.entries.filter { (_, timestamp) -> @@ -413,12 +416,25 @@ class SecurityManager(private val encryptionService: EncryptionService, private processedMessages.removeAll(toRemove.toSet()) removeFromMessageTimestamps(toRemove) } - + + // Expire handshake dedup entries by time so a delayed same-ephemeral delivery + // (e.g. a re-handshake retry after a failed attempt) is not blocked forever. + val keyExchangeCutoff = nowMs - KEY_EXCHANGE_DEDUP_TIMEOUT + val keyExchangesToRemove = keyExchangeTimestamps.entries.filter { (_, timestamp) -> + timestamp < keyExchangeCutoff + }.map { it.key } + + keyExchangesToRemove.forEach { exchangeKey -> + keyExchangeTimestamps.remove(exchangeKey) + processedKeyExchanges.remove(exchangeKey) + } + // Limit the size of processed key exchanges set if (processedKeyExchanges.size > MAX_PROCESSED_KEY_EXCHANGES) { val excess = processedKeyExchanges.size - MAX_PROCESSED_KEY_EXCHANGES val toRemove = processedKeyExchanges.take(excess) processedKeyExchanges.removeAll(toRemove.toSet()) + toRemove.forEach { keyExchangeTimestamps.remove(it) } } } @@ -438,6 +454,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private processedMessages.clear() processedKeyExchanges.clear() messageTimestamps.clear() + keyExchangeTimestamps.clear() } /** diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt index c089255f..dd39eb91 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt @@ -2,6 +2,8 @@ package com.bitchat.android.noise import android.util.Log import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit data class NoiseHandshakeProcessingResult( val response: ByteArray?, @@ -50,15 +52,30 @@ class NoiseSessionManager( companion object { private const val TAG = "NoiseSessionManager" - private const val HANDSHAKE_TIMEOUT_MS = 20_000L + private const val HANDSHAKE_TIMEOUT_MS = 10_000L + private const val HANDSHAKE_SWEEP_INTERVAL_MS = 2_000L private const val HANDSHAKE_MESSAGE_1_SIZE = 32 private const val SESSION_TOKEN_SIZE = 32 } - + private val sessions = ConcurrentHashMap() // An inbound replacement handshake must prove its authenticated static-key binding before it // can evict a working transport session. Keep responder candidates outside the active map. private val responderCandidates = ConcurrentHashMap() + + private val sweepScheduler = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "NoiseHandshakeSweeper").apply { isDaemon = true } + } + + init { + sweepScheduler.scheduleWithFixedDelay({ + try { + cleanupStaleHandshakes(System.currentTimeMillis()) + } catch (e: Exception) { + Log.w(TAG, "Handshake sweep failed: ${e.message}") + } + }, HANDSHAKE_SWEEP_INTERVAL_MS, HANDSHAKE_SWEEP_INTERVAL_MS, TimeUnit.MILLISECONDS) + } // Callbacks var onSessionEstablished: ((String, ByteArray) -> Unit)? = null @@ -289,6 +306,31 @@ class NoiseSessionManager( if (lastActivity == null) return false return (nowMs - lastActivity) > HANDSHAKE_TIMEOUT_MS } + + /** + * Actively expire handshakes that stopped progressing (lost response, abandoned + * responder candidates). Established sessions are never touched here. + */ + @Synchronized + fun cleanupStaleHandshakes(nowMs: Long) { + sessions.entries.toList().forEach { (peerID, session) -> + if (session.isHandshaking() && isHandshakeStale(session, nowMs)) { + Log.d(TAG, "Expiring stale handshake with $peerID") + if (sessions.remove(peerID, session)) { + session.destroy() + runCatching { onSessionFailed?.invoke(peerID, NoiseSessionError.HandshakeTimeout) } + } + } + } + responderCandidates.entries.toList().forEach { (peerID, session) -> + if (session.isHandshaking() && isHandshakeStale(session, nowMs)) { + Log.d(TAG, "Expiring stale responder candidate for $peerID") + if (responderCandidates.remove(peerID, session)) { + session.destroy() + } + } + } + } /** * SIMPLIFIED: Encrypt data @@ -427,6 +469,7 @@ class NoiseSessionManager( */ @Synchronized fun shutdown() { + sweepScheduler.shutdownNow() sessions.values.forEach { it.destroy() } responderCandidates.values.forEach { it.destroy() } sessions.clear() @@ -443,6 +486,7 @@ sealed class NoiseSessionError(message: String, cause: Throwable? = null) : Exce object SessionNotEstablished : NoiseSessionError("Session not established") object InvalidState : NoiseSessionError("Session in invalid state") object HandshakeFailed : NoiseSessionError("Handshake failed") + object HandshakeTimeout : NoiseSessionError("Handshake timed out") object AlreadyEstablished : NoiseSessionError("Session already established") object SessionGenerationChanged : NoiseSessionError("Noise session generation changed") class PeerIdentityMismatch(claimedPeerID: String, derivedPeerID: String?) : NoiseSessionError( diff --git a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt index 5301df6d..b3ffc429 100644 --- a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt +++ b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt @@ -26,6 +26,10 @@ object ContactDirectory { @Volatile private var meshProvider: (() -> MeshService?)? = null + @Volatile + internal var identityManagerProvider: (Context) -> SecureIdentityStateManager = + { SecureIdentityStateManager(it) } + fun initialize(context: Context, meshProvider: () -> MeshService?) { appContext = context.applicationContext this.meshProvider = meshProvider @@ -79,7 +83,8 @@ object ContactDirectory { noisePublicKey = noiseKey ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.noisePublicKey }, nostrPubkey = favorite?.peerNostrPublicKey, displayName = favorite?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } - ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname }, + ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname } + ?: contactFingerprint?.let { cachedFingerprintNickname(it) }, isMutualFavorite = favorite?.isMutual == true ) } @@ -132,7 +137,7 @@ object ContactDirectory { private fun cachedNoiseKey(peerID: String): ByteArray? { val context = appContext ?: return null return try { - SecureIdentityStateManager(context) + identityManagerProvider(context) .getCachedNoiseKey(peerID) ?.let { ContactIdentityResolver.bytesFromHex(it) } } catch (_: Exception) { @@ -140,6 +145,17 @@ object ContactDirectory { } } + private fun cachedFingerprintNickname(fingerprint: String): String? { + val context = appContext ?: return null + return try { + identityManagerProvider(context) + .getCachedFingerprintNickname(fingerprint) + ?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } + } catch (_: Exception) { + null + } + } + private fun favoriteForMeshPeerID(peerID: String): FavoriteRelationship? = try { FavoritesPersistenceService.shared.getFavoriteStatus(peerID) diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt index 28c051bc..8003508e 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt @@ -8,6 +8,7 @@ import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.noise.NoiseSession import com.bitchat.android.nostr.GeohashAliasRegistry +import com.bitchat.android.services.ContactIdentityResolver import com.bitchat.android.services.VerificationService import com.bitchat.android.util.dataFromHexString import com.bitchat.android.util.hexEncodedString @@ -185,6 +186,9 @@ class VerificationHandler( val hexRegex = Regex("^[0-9a-fA-F]+$") return try { when { + ContactIdentityResolver.isContactConversationId(peerID) -> { + ContactIdentityResolver.fingerprintFromContactConversationId(peerID) + } peerID.length == 64 && peerID.matches(hexRegex) -> { identityManager.getCachedNoiseFingerprint(peerID)?.let { return it } fingerprintFromNoiseHex(peerID)?.also { identityManager.cacheNoiseFingerprint(peerID, it) } diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index 4905c927..4dec775f 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -14,6 +14,7 @@ object AppConstants { // Peer lifecycle const val STALE_PEER_TIMEOUT_MS: Long = 180_000L // 3 minutes const val PEER_CLEANUP_INTERVAL_MS: Long = 60_000L + const val PEER_DISCONNECT_GRACE_MS: Long = 10_000L // BLE connection tracking const val CONNECTION_RETRY_DELAY_MS: Long = 5_000L @@ -52,6 +53,7 @@ object AppConstants { const val CLEANUP_INTERVAL_MS: Long = 300_000L const val MAX_PROCESSED_MESSAGES: Int = 10_000 const val MAX_PROCESSED_KEY_EXCHANGES: Int = 1_000 + const val KEY_EXCHANGE_DEDUP_TIMEOUT_MS: Long = 60_000L } object Noise { 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 945155fd..79bc4c2d 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt @@ -374,6 +374,77 @@ class MessageHandlerTest { Unit } + @Test + fun `repeated decrypt failures reset stale session and re-handshake`() = runBlocking { + whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(null) + whenever(delegate.hasNoiseSession(peerID)).thenReturn(true) + val packet = encryptedPacket() + + repeat(2) { + assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link"))) + } + verify(delegate, never()).removeNoiseSession(any()) + verify(delegate, never()).initiateNoiseHandshake(any()) + + assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link"))) + verify(delegate).removeNoiseSession(peerID) + verify(delegate).initiateNoiseHandshake(peerID) + } + + @Test + fun `successful decrypt resets the failure counter`() = runBlocking { + whenever(delegate.hasNoiseSession(peerID)).thenReturn(true) + val plaintext = NoisePayload(NoisePayloadType.DELIVERED, "id-1".toByteArray()).encode() + whenever(delegate.decryptFromPeer(any(), eq(peerID))) + .thenReturn(null) + .thenReturn(null) + .thenReturn(NoiseDecryptionResult(plaintext, authenticatedSession)) + .thenReturn(null) + .thenReturn(null) + val packet = encryptedPacket() + + repeat(5) { + handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link")) + } + + verify(delegate, never()).removeNoiseSession(any()) + verify(delegate, never()).initiateNoiseHandshake(any()) + } + + @Test + fun `decrypt failures without an established session never reset`() = runBlocking { + whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(null) + whenever(delegate.hasNoiseSession(peerID)).thenReturn(false) + val packet = encryptedPacket() + + repeat(4) { + assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link"))) + } + + verify(delegate, never()).removeNoiseSession(any()) + verify(delegate, never()).initiateNoiseHandshake(any()) + } + + @Test + fun `successful decrypt reports the packet as valid`() = runBlocking { + val plaintext = NoisePayload(NoisePayloadType.DELIVERED, "id-2".toByteArray()).encode() + whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn( + NoiseDecryptionResult(plaintext, authenticatedSession) + ) + + assertTrue(handler.handleNoiseEncrypted(RoutedPacket(encryptedPacket(), peerID, "direct-link"))) + } + + private fun encryptedPacket(): BitchatPacket = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = peerID.hexToBytes(), + recipientID = myPeerID.hexToBytes(), + timestamp = System.currentTimeMillis().toULong(), + payload = byteArrayOf(0x41, 0x42, 0x43), + ttl = 7u + ) + private fun announcePacket( ageMs: Long, ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte(), diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt index df0344cf..3a676382 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt @@ -116,7 +116,7 @@ class PacketProcessorAnnounceSideEffectTest { handshakeHandled.complete(Unit) return acceptHandshake } - override fun handleNoiseEncrypted(routed: RoutedPacket) = Unit + override fun handleNoiseEncrypted(routed: RoutedPacket) = true override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { handled.complete(Unit) return acceptAnnounce 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 17156e00..2a56e708 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt @@ -542,6 +542,28 @@ class SecurityManagerTest { ) } + @Test + fun `handshake dedup entries expire by time and allow delayed retry`() = runBlocking { + val payload = byteArrayOf(0x71, 0x72, 0x73) + val routed = handshakePacket(payload) + + assertTrue(securityManager.handleNoiseHandshake(routed)) + assertTrue(fakeEncryptionService.handshakeCalls == 1) + + assertFalse("Identical frame inside the dedup window must be dropped", + securityManager.handleNoiseHandshake(routed)) + assertTrue(fakeEncryptionService.handshakeCalls == 1) + + securityManager.cleanupOldData( + System.currentTimeMillis() + + com.bitchat.android.util.AppConstants.Security.KEY_EXCHANGE_DEDUP_TIMEOUT_MS + 1_000 + ) + + assertTrue("Expired dedup entries must not block a delayed retry", + securityManager.handleNoiseHandshake(routed)) + assertTrue(fakeEncryptionService.handshakeCalls == 2) + } + private fun setupKnownPeer(peerID: String, signingKey: ByteArray) { val info = PeerInfo( id = peerID, diff --git a/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerHandshakeTimeoutTest.kt b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerHandshakeTimeoutTest.kt new file mode 100644 index 00000000..f180ea8e --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerHandshakeTimeoutTest.kt @@ -0,0 +1,153 @@ +package com.bitchat.android.noise + +import com.bitchat.android.noise.southernstorm.protocol.Noise +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.atomic.AtomicReference + +class NoiseSessionManagerHandshakeTimeoutTest { + private data class TestIdentity( + val privateKey: ByteArray, + val publicKey: ByteArray, + val peerID: String + ) + + private val managers = mutableListOf() + + @After + fun tearDown() { + managers.forEach(NoiseSessionManager::shutdown) + } + + @Test + fun `stale initiator handshake is expired and reported as timeout`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val failure = AtomicReference() + aliceManager.onSessionFailed = { peerID, error -> + if (peerID == bob.peerID) failure.set(error) + } + + assertNotNull(aliceManager.initiateHandshake(bob.peerID)) + assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking()) + + aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000) + + assertNull(aliceManager.getSession(bob.peerID)) + assertTrue(failure.get() is NoiseSessionError.HandshakeTimeout) + } + + @Test + fun `fresh handshake is not expired`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + + assertNotNull(aliceManager.initiateHandshake(bob.peerID)) + + aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 9_000) + + assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking()) + } + + @Test + fun `expired handshake can be re-initiated immediately`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + + assertNotNull(aliceManager.initiateHandshake(bob.peerID)) + aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000) + + assertNotNull("Handshake must restart after expiry", aliceManager.initiateHandshake(bob.peerID)) + assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking()) + } + + @Test + fun `established session is never expired by the sweep`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID) + val aliceSession = aliceManager.getSession(bob.peerID) + val bobSession = bobManager.getSession(alice.peerID) + + aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 60_000) + bobManager.cleanupStaleHandshakes(System.currentTimeMillis() + 60_000) + + assertSame(aliceSession, aliceManager.getSession(bob.peerID)) + assertSame(bobSession, bobManager.getSession(alice.peerID)) + assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) + assertTrue(bobManager.hasEstablishedSession(alice.peerID)) + + val plaintext = "still alive".toByteArray() + val ciphertext = aliceManager.encrypt(plaintext, bob.peerID) + assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID)) + } + + @Test + fun `stale responder candidate expires while established session survives`() { + val alice = identity() + val bob = identity() + val aliceManager = manager(alice) + val bobManager = manager(bob) + completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID) + val established = aliceManager.getSession(bob.peerID) + + // Bob comes back and starts a replacement handshake: alice keeps the established + // session and parks the new handshake as a responder candidate. + val replacementManager = manager(bob) + val message1 = replacementManager.initiateHandshake(alice.peerID)!! + assertNotNull(aliceManager.processHandshakeMessage(bob.peerID, message1)) + assertSame(established, aliceManager.getSession(bob.peerID)) + + // Bob never finishes (message 2 lost): the candidate must expire without touching + // the working session. + aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000) + + assertSame(established, aliceManager.getSession(bob.peerID)) + assertTrue(aliceManager.hasEstablishedSession(bob.peerID)) + val plaintext = "unharmed".toByteArray() + val ciphertext = aliceManager.encrypt(plaintext, bob.peerID) + assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID)) + } + + private fun completeHandshake( + initiator: NoiseSessionManager, + initiatorPeerID: String, + responder: NoiseSessionManager, + responderPeerID: String + ) { + val message1 = initiator.initiateHandshake(responderPeerID)!! + val message2 = responder.processHandshakeMessage(initiatorPeerID, message1)!! + val message3 = initiator.processHandshakeMessage(responderPeerID, message2)!! + assertNull(responder.processHandshakeMessage(initiatorPeerID, message3)) + } + + private fun manager(identity: TestIdentity): NoiseSessionManager = NoiseSessionManager( + localStaticPrivateKey = identity.privateKey, + localStaticPublicKey = identity.publicKey, + localPeerID = identity.peerID + ).also { managers += it } + + private fun identity(): TestIdentity { + val dh = Noise.createDH("25519") + return try { + dh.generateKeyPair() + val privateKey = ByteArray(32) + val publicKey = ByteArray(32) + dh.getPrivateKey(privateKey, 0) + dh.getPublicKey(publicKey, 0) + TestIdentity(privateKey, publicKey, NoisePeerIdentity.derivePeerID(publicKey)!!) + } finally { + dh.destroy() + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt new file mode 100644 index 00000000..e16996f1 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt @@ -0,0 +1,59 @@ +package com.bitchat.android.services + +import android.content.Context +import android.os.Build +import com.bitchat.android.identity.SecureIdentityStateManager +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class ContactDirectoryTest { + + private lateinit var identityManager: SecureIdentityStateManager + + @Before + fun setup() { + val context = RuntimeEnvironment.getApplication() + val prefs = context.getSharedPreferences( + "contact-directory-test-${UUID.randomUUID()}", + Context.MODE_PRIVATE + ) + identityManager = SecureIdentityStateManager(prefs, testOnly = true) + ContactDirectory.initialize(context) { null } + ContactDirectory.identityManagerProvider = { identityManager } + } + + @After + fun tearDown() { + ContactDirectory.identityManagerProvider = { SecureIdentityStateManager(it) } + } + + @Test + fun `offline contact resolves display name from cached fingerprint nickname`() { + val fingerprint = "ab".repeat(32) + identityManager.cacheFingerprintNickname(fingerprint, "Alice") + + val resolution = ContactDirectory.resolve("contact_$fingerprint") + + assertEquals("Alice", resolution.displayName) + assertNull(resolution.meshPeerID) + } + + @Test + fun `offline contact without cached nickname has no display name`() { + val fingerprint = "cd".repeat(32) + + val resolution = ContactDirectory.resolve("contact_$fingerprint") + + assertNull(resolution.displayName) + } +}