From c33e4086a23cf9e6904914498fa58bcc52c0ee5f Mon Sep 17 00:00:00 2001 From: Francisco Date: Fri, 1 Aug 2025 20:12:39 -0400 Subject: [PATCH] Added security verification window for IOS parity --- .../java/com/bitchat/android/ui/ChatHeader.kt | 14 +- .../java/com/bitchat/android/ui/ChatScreen.kt | 43 ++- .../java/com/bitchat/android/ui/ChatState.kt | 9 + .../com/bitchat/android/ui/ChatViewModel.kt | 85 ++++ .../android/ui/SecurityVerificationDialog.kt | 362 ++++++++++++++++++ 5 files changed, 504 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/SecurityVerificationDialog.kt diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index a5f90f848..561e5e8b5 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -201,7 +201,9 @@ fun ChatHeaderContent( onBackClick: () -> Unit, onSidebarClick: () -> Unit, onTripleClick: () -> Unit, - onShowAppInfo: () -> Unit + onShowAppInfo: () -> Unit, + showSecurityVerification: Boolean, + onSecurityVerificationChange: (Boolean) -> Unit ) { val colorScheme = MaterialTheme.colorScheme @@ -228,7 +230,8 @@ fun ChatHeaderContent( isFavorite = isFavorite, sessionState = sessionState, onBackClick = onBackClick, - onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) } + onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) }, + onShowSecurityVerification = { onSecurityVerificationChange(true) } ) } currentChannel != null -> { @@ -261,7 +264,8 @@ private fun PrivateChatHeader( isFavorite: Boolean, sessionState: String?, onBackClick: () -> Unit, - onToggleFavorite: () -> Unit + onToggleFavorite: () -> Unit, + onShowSecurityVerification: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme val peerNickname = peerNicknames[peerID] ?: peerID @@ -300,7 +304,9 @@ private fun PrivateChatHeader( // Title - perfectly centered regardless of other elements Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.align(Alignment.Center) + modifier = Modifier + .align(Alignment.Center) + .clickable { onShowSecurityVerification() } ) { Text( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index c4dd311c8..8f41f7feb 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -70,6 +70,7 @@ fun ChatScreen(viewModel: ChatViewModel) { var showPasswordPrompt by remember { mutableStateOf(false) } var showPasswordDialog by remember { mutableStateOf(false) } var passwordInput by remember { mutableStateOf("") } + var showSecurityVerification by remember { mutableStateOf(false) } // Show password dialog when needed LaunchedEffect(showPasswordPrompt) { @@ -147,7 +148,9 @@ fun ChatScreen(viewModel: ChatViewModel) { colorScheme = colorScheme, onSidebarToggle = { viewModel.showSidebar() }, onShowAppInfo = { viewModel.showAppInfo() }, - onPanicClear = { viewModel.panicClearAllData() } + onPanicClear = { viewModel.panicClearAllData() }, + showSecurityVerification = showSecurityVerification, + onSecurityVerificationChange = { showSecurityVerification = it } ) // Sidebar overlay @@ -191,7 +194,12 @@ fun ChatScreen(viewModel: ChatViewModel) { passwordInput = "" }, showAppInfo = showAppInfo, - onAppInfoDismiss = { viewModel.hideAppInfo() } + onAppInfoDismiss = { viewModel.hideAppInfo() }, + showSecurityVerification = showSecurityVerification, + selectedPrivatePeer = selectedPrivatePeer, + viewModel = viewModel, + onSecurityVerificationDismiss = { showSecurityVerification = false }, + onSecurityVerificationVerify = { showSecurityVerification = false } ) } @@ -251,7 +259,9 @@ private fun ChatFloatingHeader( colorScheme: ColorScheme, onSidebarToggle: () -> Unit, onShowAppInfo: () -> Unit, - onPanicClear: () -> Unit + onPanicClear: () -> Unit, + showSecurityVerification: Boolean, + onSecurityVerificationChange: (Boolean) -> Unit ) { Surface( modifier = Modifier @@ -277,7 +287,9 @@ private fun ChatFloatingHeader( }, onSidebarClick = onSidebarToggle, onTripleClick = onPanicClear, - onShowAppInfo = onShowAppInfo + onShowAppInfo = onShowAppInfo, + showSecurityVerification = showSecurityVerification, + onSecurityVerificationChange = onSecurityVerificationChange ) }, colors = TopAppBarDefaults.topAppBarColors( @@ -305,8 +317,16 @@ private fun ChatDialogs( onPasswordConfirm: () -> Unit, onPasswordDismiss: () -> Unit, showAppInfo: Boolean, - onAppInfoDismiss: () -> Unit + onAppInfoDismiss: () -> Unit, + showSecurityVerification: Boolean, + selectedPrivatePeer: String?, + viewModel: ChatViewModel, + onSecurityVerificationDismiss: () -> Unit, + onSecurityVerificationVerify: () -> Unit ) { + // Observe verified fingerprints to ensure dialog updates when verification state changes + val verifiedFingerprints by viewModel.verifiedFingerprints.observeAsState() + // Password dialog PasswordPromptDialog( show = showPasswordDialog, @@ -322,4 +342,17 @@ private fun ChatDialogs( show = showAppInfo, onDismiss = onAppInfoDismiss ) + + // Security verification dialog + if (showSecurityVerification && selectedPrivatePeer != null) { + SecurityVerificationDialog( + peerID = selectedPrivatePeer, + peerNicknames = viewModel.meshService.getPeerNicknames(), + peerFingerprints = viewModel.peerFingerprints.value ?: emptyMap(), + verifiedFingerprints = verifiedFingerprints ?: emptySet(), + viewModel = viewModel, + onDismiss = onSecurityVerificationDismiss, + onVerify = onSecurityVerificationVerify + ) + } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index a5a6e17a3..c7a8abaf0 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -91,6 +91,10 @@ class ChatState { private val _peerFingerprints = MutableLiveData>(emptyMap()) val peerFingerprints: LiveData> = _peerFingerprints + // Verified fingerprints - tracks which peer fingerprints have been verified by the user + private val _verifiedFingerprints = MutableLiveData>(emptySet()) + val verifiedFingerprints: LiveData> = _verifiedFingerprints + // peerIDToPublicKeyFingerprint REMOVED - fingerprints now handled centrally in PeerManager // Navigation state @@ -132,6 +136,7 @@ class ChatState { fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet() fun getPeerSessionStatesValue() = _peerSessionStates.value ?: emptyMap() fun getPeerFingerprintsValue() = _peerFingerprints.value ?: emptyMap() + fun getVerifiedFingerprintsValue() = _verifiedFingerprints.value ?: emptySet() fun getShowAppInfoValue() = _showAppInfo.value ?: false // Setters for state updates @@ -225,6 +230,10 @@ class ChatState { _peerFingerprints.value = fingerprints } + fun setVerifiedFingerprints(fingerprints: Set) { + _verifiedFingerprints.value = fingerprints + } + fun setShowAppInfo(show: Boolean) { _showAppInfo.value = show } 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 ea9f3b5ab..6543211e2 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -11,6 +11,8 @@ import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryAck import com.bitchat.android.model.ReadReceipt +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken import kotlinx.coroutines.launch import kotlinx.coroutines.delay import java.util.* @@ -86,6 +88,7 @@ class ChatViewModel( val favoritePeers: LiveData> = state.favoritePeers val peerSessionStates: LiveData> = state.peerSessionStates val peerFingerprints: LiveData> = state.peerFingerprints + val verifiedFingerprints: LiveData> = state.verifiedFingerprints val showAppInfo: LiveData = state.showAppInfo init { @@ -98,6 +101,10 @@ class ChatViewModel( val nickname = dataManager.loadNickname() state.setNickname(nickname) + // Load verified fingerprints from persistent storage + val verifiedFingerprints = loadVerifiedFingerprintsFromStorage() + state.setVerifiedFingerprints(verifiedFingerprints) + // Load data val (joinedChannels, protectedChannels) = channelManager.loadChannelData() state.setJoinedChannels(joinedChannels) @@ -412,6 +419,9 @@ class ChatViewModel( // Clear all cryptographic data clearAllCryptographicData() + // Clear verified fingerprints + clearVerifiedFingerprints() + // Clear all notifications notificationManager.clearAllNotifications() @@ -426,6 +436,20 @@ class ChatViewModel( // This method now only clears data, not mesh service lifecycle } + /** + * Clear verified fingerprints (used for panic clear) + */ + private fun clearVerifiedFingerprints() { + try { + val prefs = getApplication().getSharedPreferences("bitchat_security", Context.MODE_PRIVATE) + prefs.edit().remove("verified_fingerprints").apply() + state.setVerifiedFingerprints(emptySet()) + Log.d(TAG, "✅ Cleared verified fingerprints") + } catch (e: Exception) { + Log.e(TAG, "❌ Error clearing verified fingerprints: ${e.message}") + } + } + /** * Clear all mesh service related data */ @@ -481,6 +505,67 @@ class ChatViewModel( state.setShowSidebar(false) } + // MARK: - Security Verification + + fun getMyFingerprint(): String { + return meshService.getIdentityFingerprint() + } + + fun verifyFingerprint(peerID: String) { + // Get the peer's fingerprint from PrivateChatManager + val fingerprint = privateChatManager.getPeerFingerprint(peerID) + if (fingerprint != null) { + // Get current verified fingerprints + val currentVerified = state.getVerifiedFingerprintsValue().toMutableSet() + // Add this fingerprint to verified set + currentVerified.add(fingerprint) + // Update the state + state.setVerifiedFingerprints(currentVerified) + // Save to persistent storage + saveVerifiedFingerprintsToStorage(currentVerified) + Log.d("ChatViewModel", "User verified fingerprint for peer: $peerID, fingerprint: $fingerprint") + } else { + Log.w("ChatViewModel", "Could not verify fingerprint for peer: $peerID - no fingerprint found") + } + } + + fun isFingerprintVerified(peerID: String): Boolean { + val fingerprint = privateChatManager.getPeerFingerprint(peerID) + return fingerprint?.let { state.getVerifiedFingerprintsValue().contains(it) } ?: false + } + + private fun saveVerifiedFingerprintsToStorage(verifiedFingerprints: Set) { + try { + val prefs = getApplication().getSharedPreferences("bitchat_security", Context.MODE_PRIVATE) + val editor = prefs.edit() + // Convert set to JSON string + val json = Gson().toJson(verifiedFingerprints) + editor.putString("verified_fingerprints", json) + editor.apply() + Log.d("ChatViewModel", "Saved ${verifiedFingerprints.size} verified fingerprints to storage") + } catch (e: Exception) { + Log.e("ChatViewModel", "Failed to save verified fingerprints: ${e.message}") + } + } + + private fun loadVerifiedFingerprintsFromStorage(): Set { + return try { + val prefs = getApplication().getSharedPreferences("bitchat_security", Context.MODE_PRIVATE) + val json = prefs.getString("verified_fingerprints", null) + if (json != null) { + val type = object : TypeToken>() {}.type + val fingerprints = Gson().fromJson>(json, type) + Log.d("ChatViewModel", "Loaded ${fingerprints?.size ?: 0} verified fingerprints from storage") + fingerprints ?: emptySet() + } else { + emptySet() + } + } catch (e: Exception) { + Log.e("ChatViewModel", "Failed to load verified fingerprints: ${e.message}") + emptySet() + } + } + /** * Handle Android back navigation * Returns true if the back press was handled, false if it should be passed to the system diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationDialog.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationDialog.kt new file mode 100644 index 000000000..97caa3577 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationDialog.kt @@ -0,0 +1,362 @@ +package com.bitchat.android.ui + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties + +/** + * Security Verification Dialog - Android equivalent of iOS FingerprintView + * + * Shows fingerprint information for verifying the identity of the person you're chatting with + */ +@Composable +fun SecurityVerificationDialog( + peerID: String, + peerNicknames: Map, + peerFingerprints: Map, + verifiedFingerprints: Set, + viewModel: ChatViewModel, + onDismiss: () -> Unit, + onVerify: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + val clipboardManager = LocalClipboardManager.current + + val peerNickname = peerNicknames[peerID] ?: peerID + val myFingerprint = viewModel.getMyFingerprint() + val theirFingerprint = peerFingerprints[peerID] + val sessionState = viewModel.peerSessionStates.value?.get(peerID) ?: "unknown" + + // Check if this peer's fingerprint has been verified + val isVerified = theirFingerprint?.let { verifiedFingerprints.contains(it) } ?: false + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false + ) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.8f), + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + color = if (isSystemInDarkTheme()) Color.Black else Color.White + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "SECURITY VERIFICATION", + style = MaterialTheme.typography.titleMedium.copy( + fontFamily = FontFamily.Monospace, + color = if (isSystemInDarkTheme()) Color.Green else Color(0, 128, 0) + ) + ) + + TextButton(onClick = onDismiss) { + Text( + text = "DONE", + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + color = if (isSystemInDarkTheme()) Color.Green else Color(0, 128, 0) + ) + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Peer info + Surface( + shape = RoundedCornerShape(8.dp), + color = Color.Gray.copy(alpha = 0.1f) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (sessionState == "established") Icons.Filled.Lock else Icons.Outlined.Lock, + contentDescription = if (sessionState == "established") "Verified" else "Not verified", + tint = if (sessionState == "established") Color.Green else if (isSystemInDarkTheme()) Color.Green else Color(0, 128, 0) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + Text( + text = peerNickname, + style = MaterialTheme.typography.titleMedium.copy( + fontFamily = FontFamily.Monospace, + color = if (isSystemInDarkTheme()) Color.Green else Color(0, 128, 0) + ) + ) + + Text( + text = when (sessionState) { + "established" -> "End-to-end encrypted" + "handshaking" -> "Handshake in progress" + "uninitialized" -> "Ready for handshake" + else -> "Unsecured connection" + }, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + color = if (isSystemInDarkTheme()) Color.Green else Color(0, 128, 0).copy(alpha = 0.7f) + ) + ) + } + + Spacer(modifier = Modifier.weight(1f)) + } + } + + // Their fingerprint + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "THEIR FINGERPRINT:", + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + color = if (isSystemInDarkTheme()) Color.Green else Color(0, 128, 0).copy(alpha = 0.7f) + ) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (theirFingerprint != null) { + Surface( + shape = RoundedCornerShape(8.dp), + color = Color.Gray.copy(alpha = 0.1f), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = formatFingerprint(theirFingerprint), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + color = if (isSystemInDarkTheme()) Color.Green else Color(0, 128, 0), + textAlign = TextAlign.Center + ), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + TextButton( + onClick = { + clipboardManager.setText(AnnotatedString(theirFingerprint)) + } + ) { + Text( + text = "COPY", + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + color = if (isSystemInDarkTheme()) Color.Green else Color(0, 128, 0) + ) + } + } + } + } + } else { + Surface( + shape = RoundedCornerShape(8.dp), + color = Color.Gray.copy(alpha = 0.1f), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "not available - handshake in progress", + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + color = Color(0xFFFFA500) // Orange color + ), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) + } + } + } + + // My fingerprint + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "YOUR FINGERPRINT:", + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + color = if (isSystemInDarkTheme()) Color.Green else Color(0, 128, 0).copy(alpha = 0.7f) + ) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Surface( + shape = RoundedCornerShape(8.dp), + color = Color.Gray.copy(alpha = 0.1f), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = formatFingerprint(myFingerprint), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + color = if (isSystemInDarkTheme()) Color.Green else Color(0, 128, 0), + textAlign = TextAlign.Center + ), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + TextButton( + onClick = { + clipboardManager.setText(AnnotatedString(myFingerprint)) + } + ) { + Text( + text = "COPY", + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + color = if (isSystemInDarkTheme()) Color.Green else Color(0, 128, 0) + ) + } + } + } + } + } + + // Verification status + if (sessionState == "established" || sessionState == "handshaking") { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = if (isVerified) "✓ VERIFIED" else "⚠️ NOT VERIFIED", + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + color = if (isVerified) Color.Green else Color(0xFFFFA500) // Orange color + ) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = if (isVerified) { + "you have verified this person's identity." + } else { + "compare these fingerprints with $peerNickname using a secure channel." + }, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + color = if (isSystemInDarkTheme()) Color.Green else Color(0, 128, 0).copy(alpha = 0.7f), + textAlign = TextAlign.Center + ), + modifier = Modifier.fillMaxWidth() + ) + + if (!isVerified) { + Spacer(modifier = Modifier.height(12.dp)) + + Button( + onClick = { + viewModel.verifyFingerprint(peerID) + // Don't close the dialog immediately, let the user see the updated state + // onVerify() will be called by the parent component when needed + }, + colors = ButtonDefaults.buttonColors( + containerColor = Color.Green, + contentColor = Color.White + ), + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = "MARK AS VERIFIED", + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ) + ) + } + } + } + } + } + } + } + } +} + +/** + * Format fingerprint string with spaces and line breaks for better readability + */ +private fun formatFingerprint(fingerprint: String): String { + val uppercased = fingerprint.uppercase() + val formatted = StringBuilder() + + for (i in uppercased.indices) { + // Add space every 4 characters (but not at the start) + if (i > 0 && i % 4 == 0) { + // Add newline after every 16 characters (4 groups of 4) + if (i % 16 == 0) { + formatted.append("\n") + } else { + formatted.append(" ") + } + } + formatted.append(uppercased[i]) + } + + return formatted.toString() +}