From 00cc9b3e75f066f1e0eae5148930b76b66f326c3 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 4 Jan 2026 14:17:13 +0700 Subject: [PATCH] Refactor QR verification: Extract VerificationHandler and fix concurrency issues --- .../identity/SecureIdentityStateManager.kt | 59 +-- .../com/bitchat/android/ui/ChatViewModel.kt | 358 ++--------------- .../bitchat/android/ui/VerificationHandler.kt | 365 ++++++++++++++++++ 3 files changed, 425 insertions(+), 357 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/VerificationHandler.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 643170be..012ea3c4 100644 --- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt +++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt @@ -35,6 +35,7 @@ class SecureIdentityStateManager(private val context: Context) { } private val prefs: SharedPreferences + private val lock = Any() init { // Create master key for encryption @@ -199,17 +200,23 @@ class SecureIdentityStateManager(private val context: Context) { fun setVerifiedFingerprint(fingerprint: String, verified: Boolean) { if (!isValidFingerprint(fingerprint)) return - val current = prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf() - if (verified) { - current.add(fingerprint) - } else { - current.remove(fingerprint) + synchronized(lock) { + val current = prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf() + if (verified) { + current.add(fingerprint) + } else { + current.remove(fingerprint) + } + prefs.edit { putStringSet(KEY_VERIFIED_FINGERPRINTS, current) } } - prefs.edit { putStringSet(KEY_VERIFIED_FINGERPRINTS, current) } } fun getCachedPeerFingerprint(peerID: String): String? { val pid = peerID.lowercase() + // Reading is safe without lock for SharedPreferences, but synchronizing ensures memory visibility + // if we are paranoid, but SharedPreferences is generally thread-safe for reads. + // However, to ensure we don't read a partial update (unlikely with SP), we can leave it. + // The critical part is the write. val entries = prefs.getStringSet(KEY_CACHED_PEER_FINGERPRINTS, emptySet()) ?: return null val entry = entries.firstOrNull { it.startsWith("$pid:") } ?: return null return entry.substringAfter(':').takeIf { isValidFingerprint(it) } @@ -218,10 +225,12 @@ class SecureIdentityStateManager(private val context: Context) { fun cachePeerFingerprint(peerID: String, fingerprint: String) { if (!isValidFingerprint(fingerprint)) return val pid = peerID.lowercase() - val current = prefs.getStringSet(KEY_CACHED_PEER_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf() - current.removeAll { it.startsWith("$pid:") } - current.add("$pid:$fingerprint") - prefs.edit { putStringSet(KEY_CACHED_PEER_FINGERPRINTS, current) } + synchronized(lock) { + val current = prefs.getStringSet(KEY_CACHED_PEER_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf() + current.removeAll { it.startsWith("$pid:") } + current.add("$pid:$fingerprint") + prefs.edit { putStringSet(KEY_CACHED_PEER_FINGERPRINTS, current) } + } } fun getCachedNoiseKey(peerID: String): String? { @@ -234,10 +243,12 @@ class SecureIdentityStateManager(private val context: Context) { fun cachePeerNoiseKey(peerID: String, noiseKeyHex: String) { if (!noiseKeyHex.matches(Regex("^[a-fA-F0-9]{64}$"))) return val pid = peerID.lowercase() - val current = prefs.getStringSet(KEY_CACHED_PEER_NOISE_KEYS, emptySet())?.toMutableSet() ?: mutableSetOf() - current.removeAll { it.startsWith("$pid=") } - current.add("$pid=${noiseKeyHex.lowercase()}") - prefs.edit { putStringSet(KEY_CACHED_PEER_NOISE_KEYS, current) } + synchronized(lock) { + val current = prefs.getStringSet(KEY_CACHED_PEER_NOISE_KEYS, emptySet())?.toMutableSet() ?: mutableSetOf() + current.removeAll { it.startsWith("$pid=") } + current.add("$pid=${noiseKeyHex.lowercase()}") + prefs.edit { putStringSet(KEY_CACHED_PEER_NOISE_KEYS, current) } + } } fun getCachedNoiseFingerprint(noiseKeyHex: String): String? { @@ -251,10 +262,12 @@ class SecureIdentityStateManager(private val context: Context) { if (!isValidFingerprint(fingerprint)) return if (!noiseKeyHex.matches(Regex("^[a-fA-F0-9]{64}$"))) return val key = noiseKeyHex.lowercase() - val current = prefs.getStringSet(KEY_CACHED_NOISE_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf() - current.removeAll { it.startsWith("$key=") } - current.add("$key=$fingerprint") - prefs.edit { putStringSet(KEY_CACHED_NOISE_FINGERPRINTS, current) } + synchronized(lock) { + val current = prefs.getStringSet(KEY_CACHED_NOISE_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf() + current.removeAll { it.startsWith("$key=") } + current.add("$key=$fingerprint") + prefs.edit { putStringSet(KEY_CACHED_NOISE_FINGERPRINTS, current) } + } } fun getCachedFingerprintNickname(fingerprint: String): String? { @@ -273,10 +286,12 @@ class SecureIdentityStateManager(private val context: Context) { if (!isValidFingerprint(fingerprint)) return val key = fingerprint.lowercase() val encoded = Base64.encodeToString(nickname.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) - val current = prefs.getStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, emptySet())?.toMutableSet() ?: mutableSetOf() - current.removeAll { it.startsWith("$key=") } - current.add("$key=$encoded") - prefs.edit { putStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, current) } + synchronized(lock) { + val current = prefs.getStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, emptySet())?.toMutableSet() ?: mutableSetOf() + current.removeAll { it.startsWith("$key=") } + current.add("$key=$encoded") + prefs.edit { putStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, current) } + } } // MARK: - Peer ID Rotation Management (removed) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 2a2b4b3a..7599f1b8 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -101,13 +101,15 @@ class ChatViewModel( NotificationIntervalManager() ) - // QR verification state - private val _verifiedFingerprints = MutableStateFlow>(emptySet()) - val verifiedFingerprints: StateFlow> = _verifiedFingerprints.asStateFlow() - private val pendingQRVerifications = mutableMapOf() - private val lastVerifyNonceByPeer = mutableMapOf() - private val lastInboundVerifyChallengeAt = mutableMapOf() - private val lastMutualToastAt = mutableMapOf() + private val verificationHandler = VerificationHandler( + scope = viewModelScope, + meshService = meshService, + identityManager = identityManager, + state = state, + notificationManager = notificationManager, + messageManager = messageManager + ) + val verifiedFingerprints = verificationHandler.verifiedFingerprints // Media file sending manager private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager, meshService) @@ -284,7 +286,7 @@ class ChatViewModel( com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication()) // Load verified fingerprints from secure storage - loadVerifiedFingerprints() + verificationHandler.loadVerifiedFingerprints() // Ensure NostrTransport knows our mesh peer ID for embedded packets @@ -715,7 +717,7 @@ class ChatViewModel( // Flush any pending QR verification once a Noise session is established currentPeers.forEach { peerID -> if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) { - sendPendingVerificationIfNeeded(peerID) + verificationHandler.sendPendingVerificationIfNeeded(peerID) } } } @@ -724,125 +726,23 @@ class ChatViewModel( fun isPeerVerified(peerID: String, verifiedFingerprints: Set): Boolean { if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false - val fingerprint = getPeerFingerprintForDisplay(peerID) + val fingerprint = verificationHandler.getPeerFingerprintForDisplay(peerID) return fingerprint != null && verifiedFingerprints.contains(fingerprint) } fun isNoisePublicKeyVerified(noisePublicKey: ByteArray, verifiedFingerprints: Set): Boolean { - val fingerprint = fingerprintFromNoiseBytes(noisePublicKey) + val fingerprint = verificationHandler.fingerprintFromNoiseBytes(noisePublicKey) return verifiedFingerprints.contains(fingerprint) } - private fun loadVerifiedFingerprints() { - val identityManager = SecureIdentityStateManager(getApplication()) - _verifiedFingerprints.value = identityManager.getVerifiedFingerprints() - } - fun unverifyFingerprint(peerID: String) { - val fingerprint = meshService.getPeerFingerprint(peerID) ?: return - val identityManager = SecureIdentityStateManager(getApplication()) - identityManager.setVerifiedFingerprint(fingerprint, false) - _verifiedFingerprints.value -= fingerprint + verificationHandler.unverifyFingerprint(peerID) } fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean { - val targetNoise = qr.noiseKeyHex.lowercase() - val peerID = state.getConnectedPeersValue().firstOrNull { pid -> - val noiseKeyHex = meshService.getPeerInfo(pid)?.noisePublicKey?.hexEncodedString()?.lowercase() - noiseKeyHex == targetNoise - } ?: return false - - if (pendingQRVerifications.containsKey(peerID)) return true - val nonce = ByteArray(16) - java.security.SecureRandom().nextBytes(nonce) - val pending = PendingVerification(qr.noiseKeyHex, qr.signKeyHex, nonce, System.currentTimeMillis(), false) - pendingQRVerifications[peerID] = pending - - if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) { - meshService.sendVerifyChallenge(peerID, qr.noiseKeyHex, nonce) - pendingQRVerifications[peerID] = pending.copy(sent = true) - } else { - meshService.initiateNoiseHandshake(peerID) - } - fingerprintFromNoiseHex(qr.noiseKeyHex)?.let { fp -> - identityManager.cacheFingerprintNickname(fp, qr.nickname) - identityManager.cacheNoiseFingerprint(qr.noiseKeyHex, fp) - identityManager.cachePeerNoiseKey(peerID, qr.noiseKeyHex) - } - return true + return verificationHandler.beginQRVerification(qr) } - private fun sendPendingVerificationIfNeeded(peerID: String) { - val pending = pendingQRVerifications[peerID] ?: return - if (pending.sent) return - meshService.sendVerifyChallenge(peerID, pending.noiseKeyHex, pending.nonceA) - pendingQRVerifications[peerID] = pending.copy(sent = true) - } - - private fun addVerificationSystemMessage(peerID: String, text: String) { - val msg = BitchatMessage( - sender = "system", - content = text, - timestamp = Date(), - isRelay = false, - isPrivate = true, - senderPeerID = peerID - ) - messageManager.addPrivateMessageNoUnread(peerID, msg) - } - - private fun resolvePeerDisplayName(peerID: String): String { - val nick = try { meshService.getPeerInfo(peerID)?.nickname } catch (_: Exception) { null } - return nick ?: peerID.take(8) - } - - private fun sendVerificationNotification(title: String, body: String, peerID: String) { - notificationManager.showVerificationNotification(title, body, peerID) - } - - // MARK: - Debug and Troubleshooting - - fun getDebugStatus(): String { - return meshService.getDebugStatus() - } - - // Note: Mesh service restart is now handled by MainActivity - // This function is no longer needed - - fun setAppBackgroundState(inBackground: Boolean) { - // Forward to notification manager for notification logic - notificationManager.setAppBackgroundState(inBackground) - } - - fun setCurrentPrivateChatPeer(peerID: String?) { - // Update notification manager with current private chat peer - notificationManager.setCurrentPrivateChatPeer(peerID) - } - - fun setCurrentGeohash(geohash: String?) { - // Update notification manager with current geohash for notification logic - notificationManager.setCurrentGeohash(geohash) - } - - fun clearNotificationsForSender(peerID: String) { - // Clear notifications when user opens a chat - notificationManager.clearNotificationsForSender(peerID) - } - - fun clearNotificationsForGeohash(geohash: String) { - // Clear notifications when user opens a geohash chat - notificationManager.clearNotificationsForGeohash(geohash) - } - - /** - * Clear mesh mention notifications when user opens mesh chat - */ - fun clearMeshMentionNotifications() { - notificationManager.clearMeshMentionNotifications() - } - - private var reopenSidebarAfterVerification = false - fun showVerificationSheet(fromSidebar: Boolean = false) { if (fromSidebar) { reopenSidebarAfterVerification = true @@ -867,128 +767,23 @@ class ChatViewModel( } fun getPeerFingerprintForDisplay(peerID: String): String? { - val fromMap = peerFingerprints.value[peerID] - if (fromMap != null) return fromMap - val hexRegex = Regex("^[0-9a-fA-F]+$") - return try { - when { - peerID.length == 64 && peerID.matches(hexRegex) -> { - identityManager.getCachedNoiseFingerprint(peerID)?.let { return it } - fingerprintFromNoiseHex(peerID)?.also { identityManager.cacheNoiseFingerprint(peerID, it) } - } - peerID.length == 16 && peerID.matches(hexRegex) -> { - val meshFp = meshService.getPeerFingerprint(peerID) - if (meshFp != null) return meshFp - identityManager.getCachedPeerFingerprint(peerID)?.let { return it } - identityManager.getCachedNoiseKey(peerID)?.let { noiseHex -> - identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it } - return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) } - } - val favorite = try { - FavoritesPersistenceService.shared.getFavoriteStatus(peerID) - } catch (_: Exception) { - null - } - favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) } - } - peerID.startsWith("nostr_") -> { - val pubHex = GeohashAliasRegistry.get(peerID) - val noiseKey = pubHex?.let { - FavoritesPersistenceService.shared.findNoiseKey(it) - } - noiseKey?.let { - val noiseHex = it.hexEncodedString() - identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it) - } - } - peerID.startsWith("nostr:") -> { - val prefix = peerID.removePrefix("nostr:").lowercase() - val pubHex = GeohashAliasRegistry - .snapshot() - .values - .firstOrNull { it.lowercase().startsWith(prefix) } - val noiseKey = pubHex?.let { - FavoritesPersistenceService.shared.findNoiseKey(it) - } - noiseKey?.let { - val noiseHex = it.hexEncodedString() - identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it) - } - } - else -> { - val meshFp = meshService.getPeerFingerprint(peerID) - if (meshFp != null) return meshFp - identityManager.getCachedPeerFingerprint(peerID)?.let { return it } - identityManager.getCachedNoiseKey(peerID)?.let { noiseHex -> - identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it } - return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) } - } - val favorite = try { - FavoritesPersistenceService.shared.getFavoriteStatus(peerID) - } catch (_: Exception) { - null - } - favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) } - } - } - } catch (_: Exception) { - null - } + return verificationHandler.getPeerFingerprintForDisplay(peerID) } fun getMyFingerprint(): String { - return meshService.getIdentityFingerprint() + return verificationHandler.getMyFingerprint() } fun resolvePeerDisplayNameForFingerprint(peerID: String): String { - val nicknameMap = peerNicknames.value - nicknameMap[peerID]?.let { return it } - try { - meshService.getPeerInfo(peerID)?.nickname?.let { return it } - } catch (_: Exception) { } - - val fingerprint = getPeerFingerprintForDisplay(peerID) - fingerprint?.let { fp -> - identityManager.getCachedFingerprintNickname(fp)?.let { cached -> - if (cached.isNotBlank()) return cached - } - } - - val hexRegex = Regex("^[0-9a-fA-F]+$") - if (peerID.length == 64 && peerID.matches(hexRegex)) { - val noiseKeyBytes = try { - peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - } catch (_: Exception) { null } - val favorite = noiseKeyBytes?.let { - FavoritesPersistenceService.shared.getFavoriteStatus(it) - } - favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it } - } - - if (peerID.length == 16 && peerID.matches(hexRegex)) { - val favorite = try { - FavoritesPersistenceService.shared.getFavoriteStatus(peerID) - } catch (_: Exception) { - null - } - favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it } - } - - return peerID.take(8) + return verificationHandler.resolvePeerDisplayNameForFingerprint(peerID) } fun verifyFingerprintValue(fingerprint: String) { - if (fingerprint.isBlank()) return - val identityManager = SecureIdentityStateManager(getApplication()) - identityManager.setVerifiedFingerprint(fingerprint, true) - _verifiedFingerprints.value += fingerprint + verificationHandler.verifyFingerprintValue(fingerprint) } fun unverifyFingerprintValue(fingerprint: String) { - if (fingerprint.isBlank()) return - val identityManager = SecureIdentityStateManager(getApplication()) - identityManager.setVerifiedFingerprint(fingerprint, false) - _verifiedFingerprints.value -= fingerprint + verificationHandler.unverifyFingerprintValue(fingerprint) } // MARK: - Command Autocomplete (delegated) @@ -1034,78 +829,11 @@ class ChatViewModel( } override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) { - viewModelScope.launch { - val parsed = VerificationService.parseVerifyChallenge(payload) ?: return@launch - val myNoiseHex = meshService.getStaticNoisePublicKey()?.hexEncodedString()?.lowercase() ?: return@launch - if (parsed.first.lowercase() != myNoiseHex) return@launch - - val lastNonce = lastVerifyNonceByPeer[peerID] - if (lastNonce != null && lastNonce.contentEquals(parsed.second)) return@launch - lastVerifyNonceByPeer[peerID] = parsed.second - - val fp = meshService.getPeerFingerprint(peerID) - if (fp != null) { - lastInboundVerifyChallengeAt[fp] = System.currentTimeMillis() - if (_verifiedFingerprints.value.contains(fp)) { - val lastToast = lastMutualToastAt[fp] ?: 0L - if (System.currentTimeMillis() - lastToast > 60_000L) { - lastMutualToastAt[fp] = System.currentTimeMillis() - val name = resolvePeerDisplayName(peerID) - val body = "You and $name verified each other" - addVerificationSystemMessage(peerID, "mutual verification with $name") - sendVerificationNotification("Mutual verification", body, peerID) - } - } - } - - meshService.sendVerifyResponse(peerID, parsed.first, parsed.second) - } + verificationHandler.didReceiveVerifyChallenge(peerID, payload) } override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) { - viewModelScope.launch { - val resp = VerificationService.parseVerifyResponse(payload) ?: return@launch - val pending = pendingQRVerifications[peerID] ?: return@launch - if (!resp.noiseKeyHex.equals(pending.noiseKeyHex, ignoreCase = true)) return@launch - if (!resp.nonceA.contentEquals(pending.nonceA)) return@launch - - val ok = VerificationService.verifyResponseSignature( - noiseKeyHex = resp.noiseKeyHex, - nonceA = resp.nonceA, - signature = resp.signature, - signerPublicKeyHex = pending.signKeyHex - ) - if (!ok) return@launch - - pendingQRVerifications.remove(peerID) - val fp = meshService.getPeerFingerprint(peerID) ?: return@launch - identityManager.setVerifiedFingerprint(fp, true) - _verifiedFingerprints.value = _verifiedFingerprints.value + fp - val name = resolvePeerDisplayName(peerID) - identityManager.cacheFingerprintNickname(fp, name) - val noiseKeyHex = try { - meshService.getPeerInfo(peerID)?.noisePublicKey?.hexEncodedString() - } catch (_: Exception) { - null - } - if (noiseKeyHex != null) { - identityManager.cachePeerNoiseKey(peerID, noiseKeyHex) - identityManager.cacheNoiseFingerprint(noiseKeyHex, fp) - } - addVerificationSystemMessage(peerID, "verified $name") - sendVerificationNotification("Verified", "You verified $name", peerID) - - val lastChallenge = lastInboundVerifyChallengeAt[fp] ?: 0L - if (System.currentTimeMillis() - lastChallenge < 600_000L) { - val lastToast = lastMutualToastAt[fp] ?: 0L - if (System.currentTimeMillis() - lastToast > 60_000L) { - lastMutualToastAt[fp] = System.currentTimeMillis() - val body = "You and $name verified each other" - addVerificationSystemMessage(peerID, "mutual verification with $name") - sendVerificationNotification("Mutual verification", body, peerID) - } - } - } + verificationHandler.didReceiveVerifyResponse(peerID, payload) } override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { @@ -1316,16 +1044,6 @@ class ChatViewModel( } } - private fun fingerprintFromNoiseHex(noiseHex: String): String? { - val bytes = noiseHex.dataFromHexString() ?: return null - return fingerprintFromNoiseBytes(bytes) - } - - private fun fingerprintFromNoiseBytes(bytes: ByteArray): String { - val hash = MessageDigest.getInstance("SHA-256").digest(bytes) - return hash.hexEncodedString() - } - // MARK: - iOS-Compatible Color System /** @@ -1344,36 +1062,6 @@ class ChatViewModel( return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark) } - private data class PendingVerification( - val noiseKeyHex: String, - val signKeyHex: String, - val nonceA: ByteArray, - val startedAtMs: Long, - val sent: Boolean - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as PendingVerification - - if (startedAtMs != other.startedAtMs) return false - if (sent != other.sent) return false - if (noiseKeyHex != other.noiseKeyHex) return false - if (signKeyHex != other.signKeyHex) return false - if (!nonceA.contentEquals(other.nonceA)) return false - - return true - } - - override fun hashCode(): Int { - var result = startedAtMs.hashCode() - result = 31 * result + sent.hashCode() - result = 31 * result + noiseKeyHex.hashCode() - result = 31 * result + signKeyHex.hashCode() - result = 31 * result + nonceA.contentHashCode() - return result - } - } +} } diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt new file mode 100644 index 00000000..3dfae778 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt @@ -0,0 +1,365 @@ +package com.bitchat.android.ui + +import com.bitchat.android.favorites.FavoritesPersistenceService +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.nostr.GeohashAliasRegistry +import com.bitchat.android.services.VerificationService +import com.bitchat.android.util.dataFromHexString +import com.bitchat.android.util.hexEncodedString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.security.MessageDigest +import java.util.Date +import java.util.concurrent.ConcurrentHashMap + +/** + * Handles QR verification logic and state, extracted from ChatViewModel. + */ +class VerificationHandler( + private val scope: CoroutineScope, + private val meshService: BluetoothMeshService, + private val identityManager: SecureIdentityStateManager, + private val state: ChatState, + private val notificationManager: NotificationManager, + private val messageManager: MessageManager +) { + + private val _verifiedFingerprints = MutableStateFlow>(emptySet()) + val verifiedFingerprints: StateFlow> = _verifiedFingerprints.asStateFlow() + + private val pendingQRVerifications = ConcurrentHashMap() + private val lastVerifyNonceByPeer = ConcurrentHashMap() + private val lastInboundVerifyChallengeAt = ConcurrentHashMap() + private val lastMutualToastAt = ConcurrentHashMap() + + fun loadVerifiedFingerprints() { + _verifiedFingerprints.value = identityManager.getVerifiedFingerprints() + } + + fun isPeerVerified(peerID: String): Boolean { + if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false + val fingerprint = getPeerFingerprintForDisplay(peerID) + return fingerprint != null && _verifiedFingerprints.value.contains(fingerprint) + } + + fun isNoisePublicKeyVerified(noisePublicKey: ByteArray): Boolean { + val fingerprint = fingerprintFromNoiseBytes(noisePublicKey) + return _verifiedFingerprints.value.contains(fingerprint) + } + + fun unverifyFingerprint(peerID: String) { + val fingerprint = meshService.getPeerFingerprint(peerID) ?: return + identityManager.setVerifiedFingerprint(fingerprint, false) + val current = _verifiedFingerprints.value.toMutableSet() + current.remove(fingerprint) + _verifiedFingerprints.value = current + } + + fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean { + val targetNoise = qr.noiseKeyHex.lowercase() + val peerID = state.getConnectedPeersValue().firstOrNull { pid -> + val noiseKeyHex = meshService.getPeerInfo(pid)?.noisePublicKey?.hexEncodedString()?.lowercase() + noiseKeyHex == targetNoise + } ?: return false + + if (pendingQRVerifications.containsKey(peerID)) return true + val nonce = ByteArray(16) + java.security.SecureRandom().nextBytes(nonce) + val pending = PendingVerification(qr.noiseKeyHex, qr.signKeyHex, nonce, System.currentTimeMillis(), false) + pendingQRVerifications[peerID] = pending + + if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) { + meshService.sendVerifyChallenge(peerID, qr.noiseKeyHex, nonce) + pendingQRVerifications[peerID] = pending.copy(sent = true) + } else { + meshService.initiateNoiseHandshake(peerID) + } + fingerprintFromNoiseHex(qr.noiseKeyHex)?.let { fp -> + identityManager.cacheFingerprintNickname(fp, qr.nickname) + identityManager.cacheNoiseFingerprint(qr.noiseKeyHex, fp) + identityManager.cachePeerNoiseKey(peerID, qr.noiseKeyHex) + } + return true + } + + fun sendPendingVerificationIfNeeded(peerID: String) { + val pending = pendingQRVerifications[peerID] ?: return + if (pending.sent) return + meshService.sendVerifyChallenge(peerID, pending.noiseKeyHex, pending.nonceA) + pendingQRVerifications[peerID] = pending.copy(sent = true) + } + + fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray) { + scope.launch { + val parsed = VerificationService.parseVerifyChallenge(payload) ?: return@launch + val myNoiseHex = meshService.getStaticNoisePublicKey()?.hexEncodedString()?.lowercase() ?: return@launch + if (parsed.first.lowercase() != myNoiseHex) return@launch + + val lastNonce = lastVerifyNonceByPeer[peerID] + if (lastNonce != null && lastNonce.contentEquals(parsed.second)) return@launch + lastVerifyNonceByPeer[peerID] = parsed.second + + val fp = meshService.getPeerFingerprint(peerID) + if (fp != null) { + lastInboundVerifyChallengeAt[fp] = System.currentTimeMillis() + if (_verifiedFingerprints.value.contains(fp)) { + val lastToast = lastMutualToastAt[fp] ?: 0L + if (System.currentTimeMillis() - lastToast > 60_000L) { + lastMutualToastAt[fp] = System.currentTimeMillis() + val name = resolvePeerDisplayName(peerID) + val body = "You and $name verified each other" + addVerificationSystemMessage(peerID, "mutual verification with $name") + sendVerificationNotification("Mutual verification", body, peerID) + } + } + } + + meshService.sendVerifyResponse(peerID, parsed.first, parsed.second) + } + } + + fun didReceiveVerifyResponse(peerID: String, payload: ByteArray) { + scope.launch { + val resp = VerificationService.parseVerifyResponse(payload) ?: return@launch + val pending = pendingQRVerifications[peerID] ?: return@launch + if (!resp.noiseKeyHex.equals(pending.noiseKeyHex, ignoreCase = true)) return@launch + if (!resp.nonceA.contentEquals(pending.nonceA)) return@launch + + val ok = VerificationService.verifyResponseSignature( + noiseKeyHex = resp.noiseKeyHex, + nonceA = resp.nonceA, + signature = resp.signature, + signerPublicKeyHex = pending.signKeyHex + ) + if (!ok) return@launch + + pendingQRVerifications.remove(peerID) + val fp = meshService.getPeerFingerprint(peerID) ?: return@launch + identityManager.setVerifiedFingerprint(fp, true) + val current = _verifiedFingerprints.value.toMutableSet() + current.add(fp) + _verifiedFingerprints.value = current + + val name = resolvePeerDisplayName(peerID) + identityManager.cacheFingerprintNickname(fp, name) + val noiseKeyHex = try { + meshService.getPeerInfo(peerID)?.noisePublicKey?.hexEncodedString() + } catch (_: Exception) { + null + } + if (noiseKeyHex != null) { + identityManager.cachePeerNoiseKey(peerID, noiseKeyHex) + identityManager.cacheNoiseFingerprint(noiseKeyHex, fp) + } + addVerificationSystemMessage(peerID, "verified $name") + sendVerificationNotification("Verified", "You verified $name", peerID) + + val lastChallenge = lastInboundVerifyChallengeAt[fp] ?: 0L + if (System.currentTimeMillis() - lastChallenge < 600_000L) { + val lastToast = lastMutualToastAt[fp] ?: 0L + if (System.currentTimeMillis() - lastToast > 60_000L) { + lastMutualToastAt[fp] = System.currentTimeMillis() + val body = "You and $name verified each other" + addVerificationSystemMessage(peerID, "mutual verification with $name") + sendVerificationNotification("Mutual verification", body, peerID) + } + } + } + } + + fun getPeerFingerprintForDisplay(peerID: String): String? { + val fromMap = state.getPeerFingerprintsValue()[peerID] + if (fromMap != null) return fromMap + val hexRegex = Regex("^[0-9a-fA-F]+$") + return try { + when { + peerID.length == 64 && peerID.matches(hexRegex) -> { + identityManager.getCachedNoiseFingerprint(peerID)?.let { return it } + fingerprintFromNoiseHex(peerID)?.also { identityManager.cacheNoiseFingerprint(peerID, it) } + } + peerID.length == 16 && peerID.matches(hexRegex) -> { + val meshFp = meshService.getPeerFingerprint(peerID) + if (meshFp != null) return meshFp + identityManager.getCachedPeerFingerprint(peerID)?.let { return it } + identityManager.getCachedNoiseKey(peerID)?.let { noiseHex -> + identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it } + return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) } + } + val favorite = try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + } catch (_: Exception) { + null + } + favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) } + } + peerID.startsWith("nostr_") -> { + val pubHex = GeohashAliasRegistry.get(peerID) + val noiseKey = pubHex?.let { + FavoritesPersistenceService.shared.findNoiseKey(it) + } + noiseKey?.let { + val noiseHex = it.hexEncodedString() + identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it) + } + } + peerID.startsWith("nostr:") -> { + val prefix = peerID.removePrefix("nostr:").lowercase() + val pubHex = GeohashAliasRegistry + .snapshot() + .values + .firstOrNull { it.lowercase().startsWith(prefix) } + val noiseKey = pubHex?.let { + FavoritesPersistenceService.shared.findNoiseKey(it) + } + noiseKey?.let { + val noiseHex = it.hexEncodedString() + identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it) + } + } + else -> { + val meshFp = meshService.getPeerFingerprint(peerID) + if (meshFp != null) return meshFp + identityManager.getCachedPeerFingerprint(peerID)?.let { return it } + identityManager.getCachedNoiseKey(peerID)?.let { noiseHex -> + identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it } + return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) } + } + val favorite = try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + } catch (_: Exception) { + null + } + favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) } + } + } + } catch (_: Exception) { + null + } + } + + fun resolvePeerDisplayNameForFingerprint(peerID: String): String { + val nicknameMap = state.peerNicknames.value + nicknameMap[peerID]?.let { return it } + try { + meshService.getPeerInfo(peerID)?.nickname?.let { return it } + } catch (_: Exception) { } + + val fingerprint = getPeerFingerprintForDisplay(peerID) + fingerprint?.let { fp -> + identityManager.getCachedFingerprintNickname(fp)?.let { cached -> + if (cached.isNotBlank()) return cached + } + } + + val hexRegex = Regex("^[0-9a-fA-F]+$") + if (peerID.length == 64 && peerID.matches(hexRegex)) { + val noiseKeyBytes = try { + peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } catch (_: Exception) { null } + val favorite = noiseKeyBytes?.let { + FavoritesPersistenceService.shared.getFavoriteStatus(it) + } + favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it } + } + + if (peerID.length == 16 && peerID.matches(hexRegex)) { + val favorite = try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + } catch (_: Exception) { + null + } + favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it } + } + + return peerID.take(8) + } + + fun getMyFingerprint(): String { + return meshService.getIdentityFingerprint() + } + + fun verifyFingerprintValue(fingerprint: String) { + if (fingerprint.isBlank()) return + identityManager.setVerifiedFingerprint(fingerprint, true) + val current = _verifiedFingerprints.value.toMutableSet() + current.add(fingerprint) + _verifiedFingerprints.value = current + } + + fun unverifyFingerprintValue(fingerprint: String) { + if (fingerprint.isBlank()) return + identityManager.setVerifiedFingerprint(fingerprint, false) + val current = _verifiedFingerprints.value.toMutableSet() + current.remove(fingerprint) + _verifiedFingerprints.value = current + } + + private fun addVerificationSystemMessage(peerID: String, text: String) { + val msg = BitchatMessage( + sender = "system", + content = text, + timestamp = Date(), + isRelay = false, + isPrivate = true, + senderPeerID = peerID + ) + messageManager.addPrivateMessageNoUnread(peerID, msg) + } + + private fun resolvePeerDisplayName(peerID: String): String { + val nick = try { meshService.getPeerInfo(peerID)?.nickname } catch (_: Exception) { null } + return nick ?: peerID.take(8) + } + + private fun sendVerificationNotification(title: String, body: String, peerID: String) { + notificationManager.showVerificationNotification(title, body, peerID) + } + + private fun fingerprintFromNoiseHex(noiseHex: String): String? { + val bytes = noiseHex.dataFromHexString() ?: return null + return fingerprintFromNoiseBytes(bytes) + } + + fun fingerprintFromNoiseBytes(bytes: ByteArray): String { + val hash = MessageDigest.getInstance("SHA-256").digest(bytes) + return hash.hexEncodedString() + } + + private data class PendingVerification( + val noiseKeyHex: String, + val signKeyHex: String, + val nonceA: ByteArray, + val startedAtMs: Long, + val sent: Boolean + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PendingVerification + + if (startedAtMs != other.startedAtMs) return false + if (sent != other.sent) return false + if (noiseKeyHex != other.noiseKeyHex) return false + if (signKeyHex != other.signKeyHex) return false + if (!nonceA.contentEquals(other.nonceA)) return false + + return true + } + + override fun hashCode(): Int { + var result = startedAtMs.hashCode() + result = 31 * result + sent.hashCode() + result = 31 * result + noiseKeyHex.hashCode() + result = 31 * result + signKeyHex.hashCode() + result = 31 * result + nonceA.contentHashCode() + return result + } + } +}