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 b4f468e8..0b947ff7 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -431,7 +431,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro binaryMessage.encryptedContent != null ) { delegate?.decryptChannelMessage(binaryMessage.encryptedContent, channel) - ?: "[Encrypted message - password required]" + ?: com.bitchat.android.ui.ChannelManager.ENCRYPTED_PLACEHOLDER } else { binaryMessage.content } diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index 7a935396..64b67f2c 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -150,6 +150,19 @@ object AppStateStore { } } + /** + * Replace the full message list for a channel (e.g. after password unlock re-decrypt). + * Preserves message IDs already in the de-dup set. + */ + fun replaceChannelMessages(channel: String, messages: List) { + synchronized(this) { + messages.forEach { seenMessageIds.add(it.id) } + val map = _channelMessages.value.toMutableMap() + map[channel] = messages + _channelMessages.value = map + } + } + // Clear all in-memory state (used for full app shutdown) fun clear() { synchronized(this) { diff --git a/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt b/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt index 1a157c12..89cef17b 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt @@ -1,20 +1,24 @@ package com.bitchat.android.ui import android.util.Log +import com.bitchat.android.model.BitchatMessage import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.security.MessageDigest +import java.util.Date import javax.crypto.Cipher import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec -import com.bitchat.android.model.BitchatMessage -import java.util.* /** * Handles channel management including creation, joining, leaving, and encryption. * * Password-protected channels use PBKDF2-HMAC-SHA256 (100k iterations, channel name as salt) * + AES-256-GCM, matching the historical bitchat channel crypto format. + * + * PBKDF2 runs on [Dispatchers.Default] via suspend APIs so UI threads are not blocked. */ class ChannelManager( private val state: ChatState, @@ -28,6 +32,7 @@ class ChannelManager( private const val KEY_BITS = 256 private const val GCM_IV_BYTES = 12 private const val GCM_TAG_BITS = 128 + const val ENCRYPTED_PLACEHOLDER = "[Encrypted message - password required]" } // Channel encryption and security @@ -42,16 +47,16 @@ class ChannelManager( // MARK: - Channel Lifecycle - fun joinChannel(channel: String, password: String? = null, myPeerID: String): Boolean { + suspend fun joinChannel(channel: String, password: String? = null, myPeerID: String): Boolean { val channelTag = if (channel.startsWith("#")) channel else "#$channel" // Check if already joined if (state.getJoinedChannelsValue().contains(channelTag)) { if (state.getPasswordProtectedChannelsValue().contains(channelTag) && !channelKeys.containsKey(channelTag)) { - // Need password verification if (password != null) { val ok = verifyChannelPassword(channelTag, password) if (ok) { + hidePasswordPrompt() switchToChannel(channelTag) } return ok @@ -65,14 +70,13 @@ class ChannelManager( return true } - // If password protected and no key yet + // Password protected and no key yet — always require password (including creator after restart) if (state.getPasswordProtectedChannelsValue().contains(channelTag) && !channelKeys.containsKey(channelTag)) { - if (dataManager.isChannelCreator(channelTag, myPeerID)) { - // Channel creator bypass — they should already have set a password via /pass - } else if (password != null) { + if (password != null) { if (!verifyChannelPassword(channelTag, password)) { return false } + hidePasswordPrompt() } else { state.setPasswordPromptChannel(channelTag) state.setShowPasswordPrompt(true) @@ -85,7 +89,7 @@ class ChannelManager( updatedChannels.add(channelTag) state.setJoinedChannels(updatedChannels) - // Set as creator if new channel + // Set as creator if new unprotected channel if (!dataManager.channelCreators.containsKey(channelTag) && !state.getPasswordProtectedChannelsValue().contains(channelTag)) { dataManager.addChannelCreator(channelTag, myPeerID) } @@ -110,12 +114,10 @@ class ChannelManager( updatedChannels.remove(channel) state.setJoinedChannels(updatedChannels) - // Exit channel if currently in it if (state.getCurrentChannelValue() == channel) { state.setCurrentChannel(null) } - // Cleanup messageManager.removeChannelMessages(channel) dataManager.removeChannelMembers(channel) channelKeys.remove(channel) @@ -131,7 +133,6 @@ class ChannelManager( state.setCurrentChannel(channel) state.setSelectedPrivateChatPeer(null) - // Clear unread count channel?.let { ch -> messageManager.clearChannelUnreadCount(ch) } @@ -140,15 +141,16 @@ class ChannelManager( // MARK: - Channel Password and Encryption /** - * Derive and validate a channel password. - * Prefers SHA-256 key commitment when available, otherwise tries decrypting - * an existing encrypted message. If neither exists yet, accept and store. + * Derive and validate a channel password on a background dispatcher. + * On success, stores the key and re-decrypts any placeholder history. */ - private fun verifyChannelPassword(channel: String, password: String): Boolean { + suspend fun verifyChannelPassword(channel: String, password: String): Boolean { if (password.isEmpty()) return false val key = try { - deriveChannelKey(password, channel) + withContext(Dispatchers.Default) { + deriveChannelKey(password, channel) + } } catch (e: Exception) { Log.w(TAG, "Failed to derive channel key for $channel: ${e.message}") return false @@ -185,6 +187,8 @@ class ChannelManager( val commitment = calculateKeyCommitment(key) channelKeyCommitments[channel] = commitment dataManager.saveChannelKeyCommitments(channelKeyCommitments) + + redecryptChannelHistory(channel, key) return true } @@ -205,6 +209,32 @@ class ChannelManager( return digest.digest(key.encoded).joinToString("") { "%02x".format(it) } } + /** + * Apply the stored (or provided) key to all stored encrypted messages for [channel], + * replacing placeholder content with decrypted plaintext. + */ + fun redecryptChannelHistory(channel: String, key: SecretKeySpec? = null) { + val resolvedKey = key ?: channelKeys[channel] ?: return + val messages = state.getChannelMessagesValue()[channel] ?: return + var changed = false + val updated = messages.map { msg -> + if (msg.isEncrypted && msg.encryptedContent?.isNotEmpty() == true) { + val plain = decryptChannelMessage(msg.encryptedContent, channel, resolvedKey) + if (plain != null && plain != msg.content) { + changed = true + msg.copy(content = plain) + } else { + msg + } + } else { + msg + } + } + if (changed) { + messageManager.replaceChannelMessages(channel, updated) + } + } + fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { return decryptChannelMessage(encryptedContent, channel, null) } @@ -234,7 +264,7 @@ class ChannelManager( } /** - * Encrypt a channel message and deliver the BitchatMessage binary payload via [onEncryptedPayload]. + * Encrypt a channel message on [Dispatchers.Default] and deliver the binary payload. * On failure, calls [onFallback] — callers must NOT send plaintext when a channel key exists. */ fun sendEncryptedChannelMessage( @@ -253,7 +283,7 @@ class ChannelManager( return } - coroutineScope.launch { + coroutineScope.launch(Dispatchers.Default) { try { val contentBytes = content.toByteArray(Charsets.UTF_8) val cipher = Cipher.getInstance("AES/GCM/NoPadding") @@ -300,15 +330,11 @@ class ChannelManager( fun addChannelMessage(channel: String, message: BitchatMessage, senderPeerID: String?) { messageManager.addChannelMessage(channel, message) - // Track as channel member senderPeerID?.let { peerID -> dataManager.addChannelMember(channel, peerID) } } - /** - * Mark a channel as password-protected when we observe encrypted traffic for it. - */ fun markChannelPasswordProtected(channel: String) { if (!state.getPasswordProtectedChannelsValue().contains(channel)) { state.setPasswordProtectedChannels( @@ -318,6 +344,14 @@ class ChannelManager( } } + /** + * Prompt for password when the channel is protected but we have no key yet. + */ + fun requestPasswordForChannel(channel: String) { + state.setPasswordPromptChannel(channel) + state.setShowPasswordPrompt(true) + } + fun removeChannelMember(channel: String, peerID: String) { dataManager.removeChannelMember(channel, peerID) } @@ -340,6 +374,10 @@ class ChannelManager( return channelPasswords[channel] } + fun getChannelKeyCommitment(channel: String): String? { + return channelKeyCommitments[channel] + } + fun isChannelCreator(channel: String, peerID: String): Boolean { return dataManager.isChannelCreator(channel, peerID) } @@ -367,13 +405,15 @@ class ChannelManager( state.setPasswordPromptChannel(null) } - fun setChannelPassword(channel: String, password: String) { + suspend fun setChannelPassword(channel: String, password: String) { if (password.isEmpty()) { Log.w(TAG, "Ignoring empty password for $channel") return } - val key = deriveChannelKey(password, channel) + val key = withContext(Dispatchers.Default) { + deriveChannelKey(password, channel) + } channelPasswords[channel] = password channelKeys[channel] = key @@ -389,6 +429,8 @@ class ChannelManager( state.getJoinedChannelsValue(), state.getPasswordProtectedChannelsValue() ) + + redecryptChannelHistory(channel, key) } // MARK: - Emergency Clear 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 bc83a9f9..d5b313cb 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -82,6 +82,13 @@ fun ChatScreen(viewModel: ChatViewModel) { val isConnected by viewModel.isConnected.collectAsStateWithLifecycle() val passwordPromptChannel by viewModel.passwordPromptChannel.collectAsStateWithLifecycle() + val viewModelShowPasswordPrompt by viewModel.showPasswordPrompt.collectAsStateWithLifecycle() + + // Sync password dialog from ViewModel (join/send protected channel without key) + LaunchedEffect(viewModelShowPasswordPrompt) { + showPasswordPrompt = viewModelShowPasswordPrompt + showPasswordDialog = viewModelShowPasswordPrompt + } // Get location channel info for timeline switching val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() @@ -311,17 +318,19 @@ fun ChatScreen(viewModel: ChatViewModel) { passwordInput = passwordInput, onPasswordChange = { passwordInput = it }, onPasswordConfirm = { - if (passwordInput.isNotEmpty()) { - val success = viewModel.joinChannel(passwordPromptChannel!!, passwordInput) - if (success) { - showPasswordDialog = false - passwordInput = "" + if (passwordInput.isNotEmpty() && passwordPromptChannel != null) { + viewModel.joinChannel(passwordPromptChannel!!, passwordInput) { success -> + if (success) { + showPasswordDialog = false + passwordInput = "" + } } } }, onPasswordDismiss = { showPasswordDialog = false passwordInput = "" + viewModel.hidePasswordPrompt() }, showAppInfo = showAppInfo, onAppInfoDismiss = { viewModel.hideAppInfo() }, 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 2d45197f..dbcbb0ba 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -104,7 +104,7 @@ class ChatViewModel( } val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate) - private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager) + private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager, viewModelScope) private val notificationManager = NotificationManager( application.applicationContext, NotificationManagerCompat.from(application.applicationContext), @@ -379,7 +379,14 @@ class ChatViewModel( // MARK: - Channel Management (delegated) - fun joinChannel(channel: String, password: String? = null): Boolean { + fun joinChannel(channel: String, password: String? = null, onResult: ((Boolean) -> Unit)? = null) { + viewModelScope.launch { + val ok = channelManager.joinChannel(channel, password, mesh.myPeerID) + onResult?.invoke(ok) + } + } + + suspend fun joinChannelSuspend(channel: String, password: String? = null): Boolean { return channelManager.joinChannel(channel, password, mesh.myPeerID) } @@ -391,6 +398,10 @@ class ChatViewModel( channelManager.leaveChannel(channel) mesh.sendMessage("left $channel", emptyList(), null) } + + fun hidePasswordPrompt() { + channelManager.hidePasswordPrompt() + } // MARK: - Private Chat Management (delegated) @@ -566,32 +577,45 @@ class ChatViewModel( ) if (currentChannelValue != null) { - channelManager.addChannelMessage(currentChannelValue, message, mesh.myPeerID) - - // Check if encrypted channel - if (channelManager.hasChannelKey(currentChannelValue)) { - channelManager.sendEncryptedChannelMessage( - content, - mentions, - currentChannelValue, - state.getNicknameValue(), - mesh.myPeerID, - onEncryptedPayload = { encryptedData -> - mesh.sendBinaryBroadcast(encryptedData) - }, - onFallback = { - // Never send plaintext when a channel key is configured - val systemMessage = BitchatMessage( - sender = "system", - content = "failed to encrypt message for $currentChannelValue", - timestamp = Date(), - isRelay = false - ) - channelManager.addChannelMessage(currentChannelValue, systemMessage, null) - } - ) - } else { - mesh.sendMessage(content, mentions, currentChannelValue) + when { + channelManager.hasChannelKey(currentChannelValue) -> { + channelManager.addChannelMessage(currentChannelValue, message, mesh.myPeerID) + channelManager.sendEncryptedChannelMessage( + content, + mentions, + currentChannelValue, + state.getNicknameValue(), + mesh.myPeerID, + onEncryptedPayload = { encryptedData -> + mesh.sendBinaryBroadcast(encryptedData) + }, + onFallback = { + // Never send plaintext when a channel key is configured + val systemMessage = BitchatMessage( + sender = "system", + content = "failed to encrypt message for $currentChannelValue", + timestamp = Date(), + isRelay = false + ) + channelManager.addChannelMessage(currentChannelValue, systemMessage, null) + } + ) + } + channelManager.isChannelPasswordProtected(currentChannelValue) -> { + // Protected but no key yet — block plaintext leak and prompt + channelManager.requestPasswordForChannel(currentChannelValue) + val systemMessage = BitchatMessage( + sender = "system", + content = "password required to send in $currentChannelValue", + timestamp = Date(), + isRelay = false + ) + channelManager.addChannelMessage(currentChannelValue, systemMessage, null) + } + else -> { + channelManager.addChannelMessage(currentChannelValue, message, mesh.myPeerID) + mesh.sendMessage(content, mentions, currentChannelValue) + } } } else { messageManager.addMessage(message) diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index a92d2d60..3dc256d0 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -2,6 +2,8 @@ package com.bitchat.android.ui import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import java.util.Date /** @@ -11,7 +13,8 @@ class CommandProcessor( private val state: ChatState, private val messageManager: MessageManager, private val channelManager: ChannelManager, - private val privateChatManager: PrivateChatManager + private val privateChatManager: PrivateChatManager, + private val coroutineScope: CoroutineScope ) { // Available commands list @@ -56,15 +59,25 @@ class CommandProcessor( val channelName = parts[1] val channel = if (channelName.startsWith("#")) channelName else "#$channelName" val password = if (parts.size > 2) parts[2] else null - val success = channelManager.joinChannel(channel, password, myPeerID) - if (success) { - val systemMessage = BitchatMessage( - sender = "system", - content = "joined channel $channel", - timestamp = Date(), - isRelay = false - ) - messageManager.addMessage(systemMessage) + coroutineScope.launch { + val success = channelManager.joinChannel(channel, password, myPeerID) + if (success) { + val systemMessage = BitchatMessage( + sender = "system", + content = "joined channel $channel", + timestamp = Date(), + isRelay = false + ) + messageManager.addMessage(systemMessage) + } else if (password != null) { + val systemMessage = BitchatMessage( + sender = "system", + content = "wrong password for channel $channel", + timestamp = Date(), + isRelay = false + ) + messageManager.addMessage(systemMessage) + } } } else { val systemMessage = BitchatMessage( @@ -215,35 +228,36 @@ class CommandProcessor( return } - if (parts.size == 2){ - if(!channelManager.isChannelCreator(channel = currentChannel, peerID = peerID)){ + if (parts.size == 2) { + if (!channelManager.isChannelCreator(channel = currentChannel, peerID = peerID)) { val systemMessage = BitchatMessage( sender = "system", content = "you must be the channel creator to set a password.", timestamp = Date(), isRelay = false ) - channelManager.addChannelMessage(currentChannel,systemMessage,null) + channelManager.addChannelMessage(currentChannel, systemMessage, null) return } val newPassword = parts[1] - channelManager.setChannelPassword(currentChannel, newPassword) - val systemMessage = BitchatMessage( - sender = "system", - content = "password changed for channel $currentChannel", - timestamp = Date(), - isRelay = false - ) - channelManager.addChannelMessage(currentChannel,systemMessage,null) - } - else{ + coroutineScope.launch { + channelManager.setChannelPassword(currentChannel, newPassword) + val systemMessage = BitchatMessage( + sender = "system", + content = "password changed for channel $currentChannel", + timestamp = Date(), + isRelay = false + ) + channelManager.addChannelMessage(currentChannel, systemMessage, null) + } + } else { val systemMessage = BitchatMessage( sender = "system", content = "usage: /pass ", timestamp = Date(), isRelay = false ) - channelManager.addChannelMessage(currentChannel,systemMessage,null) + channelManager.addChannelMessage(currentChannel, systemMessage, null) } } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index 40b7eb98..9a4beece 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -74,6 +74,15 @@ class MessageManager(private val state: ChatState) { state.setUnreadChannelMessages(currentUnread) } } + + fun replaceChannelMessages(channel: String, messages: List) { + val updatedChannelMessages = state.getChannelMessagesValue().toMutableMap() + updatedChannelMessages[channel] = messages + state.setChannelMessages(updatedChannelMessages) + try { + com.bitchat.android.services.AppStateStore.replaceChannelMessages(channel, messages) + } catch (_: Exception) { } + } fun clearChannelMessages(channel: String) { val updatedChannelMessages = state.getChannelMessagesValue().toMutableMap() diff --git a/app/src/test/java/com/bitchat/android/ui/ChannelManagerTest.kt b/app/src/test/java/com/bitchat/android/ui/ChannelManagerTest.kt new file mode 100644 index 00000000..b2a23f75 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/ChannelManagerTest.kt @@ -0,0 +1,228 @@ +package com.bitchat.android.ui + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.model.BitchatMessage +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Date +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ChannelManagerTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private lateinit var chatState: ChatState + private lateinit var messageManager: MessageManager + private lateinit var dataManager: DataManager + private lateinit var channelManager: ChannelManager + + @Before + fun setup() { + dataManager = DataManager(context = context) + dataManager.clearAllData() + chatState = ChatState(scope = testScope) + messageManager = MessageManager(state = chatState) + channelManager = ChannelManager( + state = chatState, + messageManager = messageManager, + dataManager = dataManager, + coroutineScope = testScope + ) + } + + @Test + fun setChannelPassword_persistsCommitment_andHasKey() = runTest(testDispatcher) { + val channel = "#secret" + chatState.setJoinedChannels(setOf(channel)) + chatState.setCurrentChannel(channel) + + channelManager.setChannelPassword(channel, "correct-horse") + + assertTrue(channelManager.hasChannelKey(channel)) + assertTrue(channelManager.isChannelPasswordProtected(channel)) + val commitment = channelManager.getChannelKeyCommitment(channel) + assertNotNull(commitment) + assertEquals(commitment, dataManager.loadChannelKeyCommitments()[channel]) + } + + @Test + fun verifyChannelPassword_rejectsWrongPassword_whenCommitmentExists() = runTest(testDispatcher) { + val channel = "#ops" + channelManager.setChannelPassword(channel, "right-password") + val commitment = channelManager.getChannelKeyCommitment(channel)!! + dataManager.saveChannelKeyCommitments(mapOf(channel to commitment)) + + val freshState = ChatState(scope = testScope) + freshState.setJoinedChannels(setOf(channel)) + freshState.setPasswordProtectedChannels(setOf(channel)) + val freshManager = ChannelManager( + freshState, + MessageManager(freshState), + dataManager, + testScope + ) + + assertFalse(freshManager.hasChannelKey(channel)) + assertFalse(freshManager.verifyChannelPassword(channel, "wrong-password")) + assertFalse(freshManager.hasChannelKey(channel)) + assertTrue(freshManager.verifyChannelPassword(channel, "right-password")) + assertTrue(freshManager.hasChannelKey(channel)) + } + + @Test + fun verifyChannelPassword_redecryptsPlaceholderHistory() = runTest(testDispatcher) { + val channel = "#vault" + val password = "shared-secret" + val plaintext = "hello after unlock" + + channelManager.setChannelPassword(channel, password) + val commitment = channelManager.getChannelKeyCommitment(channel)!! + val key = deriveKeyForTest(password, channel) + val ciphertext = encryptForTest(plaintext, key) + dataManager.saveChannelKeyCommitments(mapOf(channel to commitment)) + + val placeholder = BitchatMessage( + id = "MSG-1", + sender = "alice", + content = ChannelManager.ENCRYPTED_PLACEHOLDER, + timestamp = Date(), + senderPeerID = "peer-a", + channel = channel, + encryptedContent = ciphertext, + isEncrypted = true + ) + + val freshState = ChatState(scope = testScope) + freshState.setJoinedChannels(setOf(channel)) + freshState.setPasswordProtectedChannels(setOf(channel)) + freshState.setChannelMessages(mapOf(channel to listOf(placeholder))) + val freshMessages = MessageManager(freshState) + val freshManager = ChannelManager(freshState, freshMessages, dataManager, testScope) + + assertTrue(freshManager.verifyChannelPassword(channel, password)) + assertEquals(plaintext, freshState.getChannelMessagesValue()[channel]?.first()?.content) + } + + @Test + fun sendEncryptedChannelMessage_emitsBinaryPayload_notFallback_whenKeyed() = runTest(testDispatcher) { + val channel = "#enc" + channelManager.setChannelPassword(channel, "pw") + + val latch = CountDownLatch(1) + val payload = AtomicReference(null) + var fallbackCalled = false + + channelManager.sendEncryptedChannelMessage( + content = "secret text", + mentions = emptyList(), + channel = channel, + senderNickname = "me", + myPeerID = "peer-me", + onEncryptedPayload = { + payload.set(it) + latch.countDown() + }, + onFallback = { + fallbackCalled = true + latch.countDown() + } + ) + + assertTrue(latch.await(5, TimeUnit.SECONDS)) + assertFalse(fallbackCalled) + assertNotNull(payload.get()) + val parsed = BitchatMessage.fromBinaryPayload(payload.get()!!) + assertNotNull(parsed) + assertTrue(parsed!!.isEncrypted) + assertEquals(channel, parsed.channel) + assertTrue(parsed.encryptedContent?.isNotEmpty() == true) + } + + @Test + fun sendEncryptedChannelMessage_callsFallback_whenNoKey() { + val latch = CountDownLatch(1) + var fallbackCalled = false + var payloadCalled = false + + channelManager.sendEncryptedChannelMessage( + content = "should not send", + mentions = emptyList(), + channel = "#nokey", + senderNickname = "me", + myPeerID = "peer-me", + onEncryptedPayload = { + payloadCalled = true + latch.countDown() + }, + onFallback = { + fallbackCalled = true + latch.countDown() + } + ) + + assertTrue(latch.await(2, TimeUnit.SECONDS)) + assertTrue(fallbackCalled) + assertFalse(payloadCalled) + } + + @Test + fun joinChannel_requiresPassword_whenProtectedWithoutKey() = runTest(testDispatcher) { + val channel = "#locked" + channelManager.setChannelPassword(channel, "real-pw") + val commitment = channelManager.getChannelKeyCommitment(channel)!! + dataManager.saveChannelKeyCommitments(mapOf(channel to commitment)) + + val freshState = ChatState(scope = testScope) + freshState.setPasswordProtectedChannels(setOf(channel)) + val freshManager = ChannelManager( + freshState, + MessageManager(freshState), + dataManager, + testScope + ) + + assertFalse(freshManager.joinChannel(channel, null, "peer-1")) + assertTrue(freshState.getShowPasswordPromptValue()) + assertFalse(freshManager.joinChannel(channel, "bad", "peer-1")) + assertTrue(freshManager.joinChannel(channel, "real-pw", "peer-1")) + assertTrue(freshManager.hasChannelKey(channel)) + } + + private fun deriveKeyForTest(password: String, channel: String): SecretKeySpec { + val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + val spec = javax.crypto.spec.PBEKeySpec( + password.toCharArray(), + channel.toByteArray(Charsets.UTF_8), + 100_000, + 256 + ) + return SecretKeySpec(factory.generateSecret(spec).encoded, "AES") + } + + private fun encryptForTest(plaintext: String, key: SecretKeySpec): ByteArray { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key) + val iv = cipher.iv + val encrypted = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + return iv + encrypted + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt index 431d8500..4a2eb92f 100644 --- a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt @@ -47,7 +47,8 @@ class CommandProcessorTest() { messageManager = messageManager, dataManager = DataManager(context = context), noiseSessionDelegate = mock() - ) + ), + coroutineScope = testScope ) }