From e6c2fed94c64f6e0b8828c4551ef08d33c140624 Mon Sep 17 00:00:00 2001 From: Neeraj Date: Fri, 24 Jul 2026 23:37:05 +0530 Subject: [PATCH 1/2] feat: restore password-protected channel encryption Wire PBKDF2 + AES-GCM channel crypto end-to-end so /pass channels no longer send plaintext, with commitment-based password checks and no plaintext fallback on encrypt failure. --- .../android/mesh/BluetoothMeshService.kt | 65 ++-- .../java/com/bitchat/android/mesh/MeshCore.kt | 52 +++- .../com/bitchat/android/mesh/MeshService.kt | 2 + .../bitchat/android/mesh/MessageHandler.kt | 33 +- .../android/mesh/UnifiedMeshService.kt | 7 + .../com/bitchat/android/ui/ChannelManager.kt | 282 +++++++++++++----- .../com/bitchat/android/ui/ChatViewModel.kt | 11 +- .../com/bitchat/android/ui/DataManager.kt | 24 ++ .../bitchat/android/ui/MeshDelegateHandler.kt | 3 + .../wifi-aware/WifiAwareMeshService.kt | 4 + .../noise/NoiseChannelEncryptionTest.kt | 91 ++++++ 11 files changed, 470 insertions(+), 104 deletions(-) create mode 100644 app/src/test/java/com/bitchat/android/noise/NoiseChannelEncryptionTest.kt 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 c6c10473..4ee4c8e1 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -760,31 +760,60 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } /** - * Send public message + * Send public message. + * Public (no channel): plain UTF-8 for iOS compatibility. + * Channel: BitchatMessage binary so receivers get channel metadata. */ fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null) { if (content.isEmpty()) return - - serviceScope.launch { - val packet = BitchatPacket( - version = 1u, - type = MessageType.MESSAGE.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = SpecialRecipients.BROADCAST, - timestamp = System.currentTimeMillis().toULong(), - payload = content.toByteArray(Charsets.UTF_8), - signature = null, - ttl = MAX_TTL - ) - // Sign the packet before broadcasting - val signedPacket = signPacketBeforeBroadcast(packet) - broadcastRoutedPacket(RoutedPacket(signedPacket)) - // Track our own broadcast message for sync - try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } + serviceScope.launch { + val payload = if (channel != null) { + val nickname = delegate?.getNickname() ?: myPeerID + val message = com.bitchat.android.model.BitchatMessage( + sender = nickname, + content = content, + timestamp = java.util.Date(), + isRelay = false, + senderPeerID = myPeerID, + mentions = if (mentions.isNotEmpty()) mentions else null, + channel = channel + ) + message.toBinaryPayload() ?: content.toByteArray(Charsets.UTF_8) + } else { + content.toByteArray(Charsets.UTF_8) + } + broadcastMessagePayload(payload) } } + /** + * Broadcast a pre-encoded payload (encrypted channel messages, etc.). + */ + fun sendBinaryBroadcast(payload: ByteArray) { + if (payload.isEmpty()) return + serviceScope.launch { + broadcastMessagePayload(payload) + } + } + + private suspend fun broadcastMessagePayload(payload: ByteArray) { + val packet = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + signature = null, + ttl = MAX_TTL + ) + + val signedPacket = signPacketBeforeBroadcast(packet) + broadcastRoutedPacket(RoutedPacket(signedPacket)) + try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } + } + /** * Send a file over mesh as a broadcast MESSAGE (public mesh timeline/channels). */ 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 46518428..83609ad5 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -414,22 +414,48 @@ class MeshCore( fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null) { if (content.isEmpty()) return scope.launch { - val packet = BitchatPacket( - version = 1u, - type = MessageType.MESSAGE.value, - senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), - recipientID = SpecialRecipients.BROADCAST, - timestamp = System.currentTimeMillis().toULong(), - payload = content.toByteArray(Charsets.UTF_8), - signature = null, - ttl = maxTtl - ) - val signedPacket = signPacketBeforeBroadcast(packet) - dispatchGlobal(RoutedPacket(signedPacket)) - try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } + val payload = if (channel != null) { + val nickname = delegate?.getNickname() ?: myPeerID + val message = BitchatMessage( + sender = nickname, + content = content, + timestamp = java.util.Date(), + isRelay = false, + senderPeerID = myPeerID, + mentions = if (mentions.isNotEmpty()) mentions else null, + channel = channel + ) + message.toBinaryPayload() ?: content.toByteArray(Charsets.UTF_8) + } else { + content.toByteArray(Charsets.UTF_8) + } + broadcastMessagePayload(payload) } } + fun sendBinaryBroadcast(payload: ByteArray) { + if (payload.isEmpty()) return + scope.launch { + broadcastMessagePayload(payload) + } + } + + private suspend fun broadcastMessagePayload(payload: ByteArray) { + val packet = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + signature = null, + ttl = maxTtl + ) + val signedPacket = signPacketBeforeBroadcast(packet) + dispatchGlobal(RoutedPacket(signedPacket)) + try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { } + } + fun sendFileBroadcast(file: BitchatFilePacket) { try { val payload = file.encode() ?: return diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt index c612e8ed..b1646561 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt @@ -13,6 +13,8 @@ interface MeshService { fun stopServices() fun sendMessage(content: String, mentions: List = emptyList(), channel: String? = null) + /** Broadcast a pre-encoded message payload (e.g. encrypted channel BitchatMessage binary). */ + fun sendBinaryBroadcast(payload: ByteArray) fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) fun sendDeliveryAck(messageID: String, recipientPeerID: String) {} 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 d6daa547..b4f468e8 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -382,7 +382,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro } /** - * Handle broadcast message with verification enforcement + * Handle broadcast message with verification enforcement. + * Public mesh (iOS-compatible): plain UTF-8 text. + * Channel messages: BitchatMessage binary (optionally AES-GCM encrypted). */ private suspend fun handleBroadcastMessage(routed: RoutedPacket) { val packet = routed.packet @@ -419,7 +421,34 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro Log.w(TAG, "⚠️ FILE_TRANSFER decode failed (broadcast) from ${peerID.take(8)} payloadSize=${packet.payload.size}") } - // Fallback: plain text + // Channel / encrypted channel: BitchatMessage binary payload + val binaryMessage = BitchatMessage.fromBinaryPayload(packet.payload) + if (binaryMessage != null && (binaryMessage.channel != null || binaryMessage.isEncrypted)) { + val channel = binaryMessage.channel + val finalContent = if ( + channel != null && + binaryMessage.isEncrypted && + binaryMessage.encryptedContent != null + ) { + delegate?.decryptChannelMessage(binaryMessage.encryptedContent, channel) + ?: "[Encrypted message - password required]" + } else { + binaryMessage.content + } + + val message = binaryMessage.copy( + id = binaryMessage.id.ifBlank { PacketIdUtil.computeIdHex(packet).uppercase() }, + content = finalContent, + sender = delegate?.getPeerNickname(peerID) ?: binaryMessage.sender, + senderPeerID = peerID, + timestamp = Date(packet.timestamp.toLong()) + ) + Log.d(TAG, "📢 Channel message from ${peerID.take(8)} channel=${channel ?: "?"} encrypted=${binaryMessage.isEncrypted}") + delegate?.onMessageReceived(message) + return + } + + // Fallback: plain text (iOS-compatible public mesh) val message = BitchatMessage( id = PacketIdUtil.computeIdHex(packet).uppercase(), sender = delegate?.getPeerNickname(peerID) ?: "unknown", diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index 46d36a8f..b709d1f5 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -63,6 +63,13 @@ class UnifiedMeshService( } } + override fun sendBinaryBroadcast(payload: ByteArray) { + when { + isBleEnabled() -> bluetooth.sendBinaryBroadcast(payload) + else -> wifiService()?.sendBinaryBroadcast(payload) + } + } + override fun sendPrivateMessage( content: String, recipientPeerID: String, 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 5cc1375c..1a157c12 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt @@ -1,5 +1,6 @@ package com.bitchat.android.ui +import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.security.MessageDigest @@ -10,7 +11,10 @@ import com.bitchat.android.model.BitchatMessage import java.util.* /** - * Handles channel management including creation, joining, leaving, and encryption + * 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. */ class ChannelManager( private val state: ChatState, @@ -18,24 +22,39 @@ class ChannelManager( private val dataManager: DataManager, private val coroutineScope: CoroutineScope ) { - + companion object { + private const val TAG = "ChannelManager" + private const val PBKDF2_ITERATIONS = 100_000 + private const val KEY_BITS = 256 + private const val GCM_IV_BYTES = 12 + private const val GCM_TAG_BITS = 128 + } + // Channel encryption and security private val channelKeys = mutableMapOf() private val channelPasswords = mutableMapOf() private val channelKeyCommitments = mutableMapOf() private val retentionEnabledChannels = mutableSetOf() - + + init { + channelKeyCommitments.putAll(dataManager.loadChannelKeyCommitments()) + } + // MARK: - Channel Lifecycle - + 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) { - return verifyChannelPassword(channelTag, password) + val ok = verifyChannelPassword(channelTag, password) + if (ok) { + switchToChannel(channelTag) + } + return ok } else { state.setPasswordPromptChannel(channelTag) state.setShowPasswordPrompt(true) @@ -45,11 +64,11 @@ class ChannelManager( switchToChannel(channelTag) return true } - + // If password protected and no key yet if (state.getPasswordProtectedChannelsValue().contains(channelTag) && !channelKeys.containsKey(channelTag)) { if (dataManager.isChannelCreator(channelTag, myPeerID)) { - // Channel creator bypass + // Channel creator bypass — they should already have set a password via /pass } else if (password != null) { if (!verifyChannelPassword(channelTag, password)) { return false @@ -60,183 +79,307 @@ class ChannelManager( return false } } - + // Join the channel val updatedChannels = state.getJoinedChannelsValue().toMutableSet() updatedChannels.add(channelTag) state.setJoinedChannels(updatedChannels) - + // Set as creator if new channel if (!dataManager.channelCreators.containsKey(channelTag) && !state.getPasswordProtectedChannelsValue().contains(channelTag)) { dataManager.addChannelCreator(channelTag, myPeerID) } - + // Add ourselves as member dataManager.addChannelMember(channelTag, myPeerID) - + // Initialize channel messages if needed if (!state.getChannelMessagesValue().containsKey(channelTag)) { val updatedChannelMessages = state.getChannelMessagesValue().toMutableMap() updatedChannelMessages[channelTag] = emptyList() state.setChannelMessages(updatedChannelMessages) } - + switchToChannel(channelTag) saveChannelData() return true } - + fun leaveChannel(channel: String) { val updatedChannels = state.getJoinedChannelsValue().toMutableSet() 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) channelPasswords.remove(channel) + channelKeyCommitments.remove(channel) + dataManager.saveChannelKeyCommitments(channelKeyCommitments) dataManager.removeChannelCreator(channel) - + saveChannelData() } - + fun switchToChannel(channel: String?) { state.setCurrentChannel(channel) state.setSelectedPrivateChatPeer(null) - + // Clear unread count channel?.let { ch -> messageManager.clearChannelUnreadCount(ch) } } - + // 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. + */ private fun verifyChannelPassword(channel: String, password: String): Boolean { - // TODO: REMOVE THIS - FOR TESTING ONLY + if (password.isEmpty()) return false + + val key = try { + deriveChannelKey(password, channel) + } catch (e: Exception) { + Log.w(TAG, "Failed to derive channel key for $channel: ${e.message}") + return false + } + + val expectedCommitment = channelKeyCommitments[channel] + ?: dataManager.loadChannelKeyCommitments()[channel] + if (expectedCommitment != null) { + val actual = calculateKeyCommitment(key) + if (!actual.equals(expectedCommitment, ignoreCase = true)) { + Log.w(TAG, "Channel password rejected for $channel (commitment mismatch)") + return false + } + } else { + val existingMessages = state.getChannelMessagesValue()[channel]?.filter { it.isEncrypted } + if (!existingMessages.isNullOrEmpty()) { + val testMessage = existingMessages.firstOrNull { it.encryptedContent?.isNotEmpty() == true } + if (testMessage != null) { + val decrypted = decryptChannelMessage( + testMessage.encryptedContent ?: byteArrayOf(), + channel, + key + ) + if (decrypted == null) { + Log.w(TAG, "Channel password rejected for $channel (decrypt failed)") + return false + } + } + } + } + + channelKeys[channel] = key + channelPasswords[channel] = password + val commitment = calculateKeyCommitment(key) + channelKeyCommitments[channel] = commitment + dataManager.saveChannelKeyCommitments(channelKeyCommitments) return true } - + private fun deriveChannelKey(password: String, channelName: String): SecretKeySpec { - // PBKDF2 key derivation (same as iOS version) val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") val spec = javax.crypto.spec.PBEKeySpec( password.toCharArray(), - channelName.toByteArray(), - 100000, // 100,000 iterations (same as iOS) - 256 // 256-bit key + channelName.toByteArray(Charsets.UTF_8), + PBKDF2_ITERATIONS, + KEY_BITS ) val secretKey = factory.generateSecret(spec) return SecretKeySpec(secretKey.encoded, "AES") } - + + private fun calculateKeyCommitment(key: SecretKeySpec): String { + val digest = MessageDigest.getInstance("SHA-256") + return digest.digest(key.encoded).joinToString("") { "%02x".format(it) } + } + fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { return decryptChannelMessage(encryptedContent, channel, null) } - - private fun decryptChannelMessage(encryptedContent: ByteArray, channel: String, testKey: SecretKeySpec?): String? { + + private fun decryptChannelMessage( + encryptedContent: ByteArray, + channel: String, + testKey: SecretKeySpec? + ): String? { val key = testKey ?: channelKeys[channel] ?: return null - - try { - if (encryptedContent.size < 16) return null // 12 bytes IV + minimum ciphertext - + + return try { + if (encryptedContent.size < GCM_IV_BYTES + 1) return null + val cipher = Cipher.getInstance("AES/GCM/NoPadding") - val iv = encryptedContent.sliceArray(0..11) - val ciphertext = encryptedContent.sliceArray(12 until encryptedContent.size) - - val gcmSpec = GCMParameterSpec(128, iv) + val iv = encryptedContent.sliceArray(0 until GCM_IV_BYTES) + val ciphertext = encryptedContent.sliceArray(GCM_IV_BYTES until encryptedContent.size) + + val gcmSpec = GCMParameterSpec(GCM_TAG_BITS, iv) cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec) - + val decryptedData = cipher.doFinal(ciphertext) - return String(decryptedData, Charsets.UTF_8) - + String(decryptedData, Charsets.UTF_8) } catch (e: Exception) { - return null + null } } - + + /** + * Encrypt a channel message and deliver the BitchatMessage binary payload via [onEncryptedPayload]. + * On failure, calls [onFallback] — callers must NOT send plaintext when a channel key exists. + */ fun sendEncryptedChannelMessage( - content: String, - mentions: List, - channel: String, - senderNickname: String?, + content: String, + mentions: List, + channel: String, + senderNickname: String?, myPeerID: String, onEncryptedPayload: (ByteArray) -> Unit, onFallback: () -> Unit ) { - // TODO: REIMPLEMENT – REMOVED FOR NOW - return + val key = channelKeys[channel] + if (key == null) { + Log.w(TAG, "No channel key for $channel; cannot encrypt") + onFallback() + return + } + + coroutineScope.launch { + try { + val contentBytes = content.toByteArray(Charsets.UTF_8) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key) + + val iv = cipher.iv + require(iv.size == GCM_IV_BYTES) { + "Unexpected GCM IV size: ${iv.size}" + } + val encryptedData = cipher.doFinal(contentBytes) + + val combined = ByteArray(iv.size + encryptedData.size) + System.arraycopy(iv, 0, combined, 0, iv.size) + System.arraycopy(encryptedData, 0, combined, iv.size, encryptedData.size) + + val encryptedMessage = BitchatMessage( + sender = senderNickname ?: myPeerID, + content = "", + timestamp = Date(), + isRelay = false, + senderPeerID = myPeerID, + mentions = if (mentions.isNotEmpty()) mentions else null, + channel = channel, + encryptedContent = combined, + isEncrypted = true + ) + + val messageData = encryptedMessage.toBinaryPayload() + if (messageData != null) { + onEncryptedPayload(messageData) + } else { + Log.e(TAG, "Failed to encode encrypted channel message for $channel") + onFallback() + } + } catch (e: Exception) { + Log.e(TAG, "Failed to encrypt channel message for $channel: ${e.message}") + onFallback() + } + } } - + // MARK: - Channel Management - + 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( + state.getPasswordProtectedChannelsValue().toMutableSet().apply { add(channel) } + ) + saveChannelData() + } + } + fun removeChannelMember(channel: String, peerID: String) { dataManager.removeChannelMember(channel, peerID) } - + fun cleanupDisconnectedMembers(connectedPeers: List, myPeerID: String) { dataManager.cleanupAllDisconnectedMembers(connectedPeers, myPeerID) } - + // MARK: - Channel Information - + fun isChannelPasswordProtected(channel: String): Boolean { return state.getPasswordProtectedChannelsValue().contains(channel) } - + fun hasChannelKey(channel: String): Boolean { return channelKeys.containsKey(channel) } - + fun getChannelPassword(channel: String): String? { return channelPasswords[channel] } - + fun isChannelCreator(channel: String, peerID: String): Boolean { return dataManager.isChannelCreator(channel, peerID) } - + fun getJoinedChannelsList(): List { return state.getJoinedChannelsValue().toList().sorted() } - + // MARK: - Data Persistence - + private fun saveChannelData() { dataManager.saveChannelData(state.getJoinedChannelsValue(), state.getPasswordProtectedChannelsValue()) } - + fun loadChannelData(): Pair, Set> { + channelKeyCommitments.clear() + channelKeyCommitments.putAll(dataManager.loadChannelKeyCommitments()) return dataManager.loadChannelData() } - + // MARK: - Password Management - + fun hidePasswordPrompt() { state.setShowPasswordPrompt(false) state.setPasswordPromptChannel(null) } fun setChannelPassword(channel: String, password: String) { + if (password.isEmpty()) { + Log.w(TAG, "Ignoring empty password for $channel") + return + } + val key = deriveChannelKey(password, channel) channelPasswords[channel] = password + channelKeys[channel] = key - channelKeys[channel] = deriveChannelKey(password, channel) + val commitment = calculateKeyCommitment(key) + channelKeyCommitments[channel] = commitment + dataManager.saveChannelKeyCommitments(channelKeyCommitments) state.setPasswordProtectedChannels( state.getPasswordProtectedChannelsValue().toMutableSet().apply { add(channel) } @@ -247,19 +390,20 @@ class ChannelManager( state.getPasswordProtectedChannelsValue() ) } - + // MARK: - Emergency Clear - + fun clearAllChannels() { state.setJoinedChannels(emptySet()) state.setCurrentChannel(null) state.setPasswordProtectedChannels(emptySet()) state.setShowPasswordPrompt(false) state.setPasswordPromptChannel(null) - + channelKeys.clear() channelPasswords.clear() channelKeyCommitments.clear() retentionEnabledChannels.clear() + dataManager.saveChannelKeyCommitments(emptyMap()) } } 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 2dd82cdb..2d45197f 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -577,10 +577,17 @@ class ChatViewModel( state.getNicknameValue(), mesh.myPeerID, onEncryptedPayload = { encryptedData -> - mesh.sendMessage(content, mentions, currentChannelValue) + mesh.sendBinaryBroadcast(encryptedData) }, onFallback = { - mesh.sendMessage(content, mentions, currentChannelValue) + // 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 { diff --git a/app/src/main/java/com/bitchat/android/ui/DataManager.kt b/app/src/main/java/com/bitchat/android/ui/DataManager.kt index b338c864..59bafde4 100644 --- a/app/src/main/java/com/bitchat/android/ui/DataManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/DataManager.kt @@ -109,6 +109,30 @@ class DataManager(private val context: Context) { apply() } } + + /** + * Persist SHA-256 key commitments (not passwords) so password verification + * still works after restart when no encrypted messages are cached. + */ + fun saveChannelKeyCommitments(commitments: Map) { + prefs.edit().putString("channel_key_commitments", gson.toJson(commitments)).apply() + } + + @Suppress("UNCHECKED_CAST") + fun loadChannelKeyCommitments(): Map { + val json = prefs.getString("channel_key_commitments", null) ?: return emptyMap() + return try { + val map = gson.fromJson(json, Map::class.java) as? Map<*, *> + map?.mapNotNull { (k, v) -> + val key = k as? String + val value = v as? String + if (key != null && value != null) key to value else null + }?.toMap() ?: emptyMap() + } catch (e: Exception) { + Log.w(TAG, "Failed to load channel key commitments: ${e.message}") + emptyMap() + } + } fun addChannelCreator(channel: String, creatorID: String) { _channelCreators[channel] = creatorID diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index f77e146d..578c60da 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -65,6 +65,9 @@ class MeshDelegateHandler( } } else if (message.channel != null) { // Channel message: AppStateStore is the source of truth for list; only manage unread + if (message.isEncrypted) { + channelManager.markChannelPasswordProtected(message.channel) + } if (state.getJoinedChannelsValue().contains(message.channel)) { val channel = message.channel val viewingClassic = state.getCurrentChannelValue() == channel diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index 36eaea1c..2381a3ce 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -1346,6 +1346,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor meshCore.sendMessage(content, mentions, channel) } + override fun sendBinaryBroadcast(payload: ByteArray) { + meshCore.sendBinaryBroadcast(payload) + } + /** * Sends a private encrypted message to a specific peer. * diff --git a/app/src/test/java/com/bitchat/android/noise/NoiseChannelEncryptionTest.kt b/app/src/test/java/com/bitchat/android/noise/NoiseChannelEncryptionTest.kt new file mode 100644 index 00000000..cb2ece48 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/noise/NoiseChannelEncryptionTest.kt @@ -0,0 +1,91 @@ +package com.bitchat.android.noise + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Channel password crypto: PBKDF2 + AES-256-GCM roundtrip and key commitments. + */ +class NoiseChannelEncryptionTest { + + private lateinit var encryption: NoiseChannelEncryption + + @Before + fun setup() { + encryption = NoiseChannelEncryption() + } + + @Test + fun encryptDecryptRoundtrip() { + val channel = "#secret" + val password = "correct-horse-battery" + val plaintext = "hello from the mesh" + + encryption.setChannelPassword(password, channel) + val encrypted = encryption.encryptChannelMessage(plaintext, channel) + assertTrue(encrypted.size > 12) + + val decrypted = encryption.decryptChannelMessage(encrypted, channel) + assertEquals(plaintext, decrypted) + } + + @Test + fun wrongPasswordFailsDecrypt() { + val channel = "#secret" + encryption.setChannelPassword("right-password", channel) + val encrypted = encryption.encryptChannelMessage("classified", channel) + + val other = NoiseChannelEncryption() + other.setChannelPassword("wrong-password", channel) + + try { + other.decryptChannelMessage(encrypted, channel) + throw AssertionError("Expected decrypt to fail with wrong password") + } catch (_: Exception) { + // expected + } + } + + @Test + fun keyCommitmentMatchesSamePassword() { + val channel = "#ops" + encryption.setChannelPassword("shared-secret", channel) + val commitment = encryption.calculateKeyCommitment(channel) + assertNotNull(commitment) + assertTrue(encryption.verifyKeyCommitment(channel, commitment!!)) + + val peer = NoiseChannelEncryption() + peer.setChannelPassword("shared-secret", channel) + assertTrue(peer.verifyKeyCommitment(channel, commitment)) + } + + @Test + fun keyCommitmentRejectsWrongPassword() { + val channel = "#ops" + encryption.setChannelPassword("shared-secret", channel) + val commitment = encryption.calculateKeyCommitment(channel)!! + + val peer = NoiseChannelEncryption() + peer.setChannelPassword("different-secret", channel) + assertFalse(peer.verifyKeyCommitment(channel, commitment)) + assertNotEquals(commitment, peer.calculateKeyCommitment(channel)) + } + + @Test + fun samePasswordSameChannelDerivesSameKey() { + val channel = "#room" + val password = "pw" + + encryption.setChannelPassword(password, channel) + val first = encryption.calculateKeyCommitment(channel) + + val again = NoiseChannelEncryption() + again.setChannelPassword(password, channel) + assertEquals(first, again.calculateKeyCommitment(channel)) + } +} From 0c26bb17723ccafaaac4559aa78f3413c411dbc5 Mon Sep 17 00:00:00 2001 From: Neeraj Date: Sat, 25 Jul 2026 00:29:49 +0530 Subject: [PATCH 2/2] fix: address channel encryption review feedback Move PBKDF2 off the UI thread, block plaintext sends on protected channels without a key, re-decrypt stored history after password unlock, and cover ChannelManager with unit tests. --- .../bitchat/android/mesh/MessageHandler.kt | 2 +- .../bitchat/android/services/AppStateStore.kt | 13 + .../com/bitchat/android/ui/ChannelManager.kt | 92 +++++-- .../java/com/bitchat/android/ui/ChatScreen.kt | 19 +- .../com/bitchat/android/ui/ChatViewModel.kt | 80 +++--- .../bitchat/android/ui/CommandProcessor.kt | 62 +++-- .../com/bitchat/android/ui/MessageManager.kt | 9 + .../bitchat/android/ui/ChannelManagerTest.kt | 228 ++++++++++++++++++ .../android/ui/CommandProcessorTest.kt | 3 +- 9 files changed, 424 insertions(+), 84 deletions(-) create mode 100644 app/src/test/java/com/bitchat/android/ui/ChannelManagerTest.kt 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 ) }