From e6c2fed94c64f6e0b8828c4551ef08d33c140624 Mon Sep 17 00:00:00 2001 From: Neeraj Date: Fri, 24 Jul 2026 23:37:05 +0530 Subject: [PATCH] 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)) + } +}