Merge 0c26bb17723ccafaaac4559aa78f3413c411dbc5 into c8f45408b1984753a4df7f05aff03ceaa17bbba6

This commit is contained in:
Neeraj 2026-07-26 12:24:00 +03:00 committed by GitHub
commit 8ebe7624f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 873 additions and 167 deletions

View File

@ -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<String> = 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).
*/

View File

@ -414,22 +414,48 @@ class MeshCore(
fun sendMessage(content: String, mentions: List<String> = 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

View File

@ -13,6 +13,8 @@ interface MeshService {
fun stopServices()
fun sendMessage(content: String, mentions: List<String> = 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) {}

View File

@ -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)
?: com.bitchat.android.ui.ChannelManager.ENCRYPTED_PLACEHOLDER
} 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",

View File

@ -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,

View File

@ -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<BitchatMessage>) {
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) {

View File

@ -1,16 +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
* 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,
@ -18,24 +26,40 @@ 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
const val ENCRYPTED_PLACEHOLDER = "[Encrypted message - password required]"
}
// Channel encryption and security
private val channelKeys = mutableMapOf<String, SecretKeySpec>()
private val channelPasswords = mutableMapOf<String, String>()
private val channelKeyCommitments = mutableMapOf<String, String>()
private val retentionEnabledChannels = mutableSetOf<String>()
init {
channelKeyCommitments.putAll(dataManager.loadChannelKeyCommitments())
}
// 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) {
return verifyChannelPassword(channelTag, password)
val ok = verifyChannelPassword(channelTag, password)
if (ok) {
hidePasswordPrompt()
switchToChannel(channelTag)
}
return ok
} else {
state.setPasswordPromptChannel(channelTag)
state.setShowPasswordPrompt(true)
@ -45,198 +69,357 @@ class ChannelManager(
switchToChannel(channelTag)
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
} else if (password != null) {
if (password != null) {
if (!verifyChannelPassword(channelTag, password)) {
return false
}
hidePasswordPrompt()
} else {
state.setPasswordPromptChannel(channelTag)
state.setShowPasswordPrompt(true)
return false
}
}
// Join the channel
val updatedChannels = state.getJoinedChannelsValue().toMutableSet()
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)
}
// 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
private fun verifyChannelPassword(channel: String, password: String): Boolean {
// TODO: REMOVE THIS - FOR TESTING ONLY
/**
* Derive and validate a channel password on a background dispatcher.
* On success, stores the key and re-decrypts any placeholder history.
*/
suspend fun verifyChannelPassword(channel: String, password: String): Boolean {
if (password.isEmpty()) return false
val key = try {
withContext(Dispatchers.Default) {
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)
redecryptChannelHistory(channel, key)
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) }
}
/**
* 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)
}
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 on [Dispatchers.Default] and deliver the binary payload.
* On failure, calls [onFallback] — callers must NOT send plaintext when a channel key exists.
*/
fun sendEncryptedChannelMessage(
content: String,
mentions: List<String>,
channel: String,
senderNickname: String?,
content: String,
mentions: List<String>,
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(Dispatchers.Default) {
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)
}
}
fun markChannelPasswordProtected(channel: String) {
if (!state.getPasswordProtectedChannelsValue().contains(channel)) {
state.setPasswordProtectedChannels(
state.getPasswordProtectedChannelsValue().toMutableSet().apply { add(channel) }
)
saveChannelData()
}
}
/**
* 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)
}
fun cleanupDisconnectedMembers(connectedPeers: List<String>, 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 getChannelKeyCommitment(channel: String): String? {
return channelKeyCommitments[channel]
}
fun isChannelCreator(channel: String, peerID: String): Boolean {
return dataManager.isChannelCreator(channel, peerID)
}
fun getJoinedChannelsList(): List<String> {
return state.getJoinedChannelsValue().toList().sorted()
}
// MARK: - Data Persistence
private fun saveChannelData() {
dataManager.saveChannelData(state.getJoinedChannelsValue(), state.getPasswordProtectedChannelsValue())
}
fun loadChannelData(): Pair<Set<String>, Set<String>> {
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) {
suspend fun setChannelPassword(channel: String, password: String) {
if (password.isEmpty()) {
Log.w(TAG, "Ignoring empty password for $channel")
return
}
val key = withContext(Dispatchers.Default) {
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) }
@ -246,20 +429,23 @@ class ChannelManager(
state.getJoinedChannelsValue(),
state.getPasswordProtectedChannelsValue()
)
redecryptChannelHistory(channel, key)
}
// 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())
}
}

View File

@ -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() },

View File

@ -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,25 +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.sendMessage(content, mentions, currentChannelValue)
},
onFallback = {
mesh.sendMessage(content, mentions, currentChannelValue)
}
)
} 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)

View File

@ -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 <password>",
timestamp = Date(),
isRelay = false
)
channelManager.addChannelMessage(currentChannel,systemMessage,null)
channelManager.addChannelMessage(currentChannel, systemMessage, null)
}
}

View File

@ -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<String, String>) {
prefs.edit().putString("channel_key_commitments", gson.toJson(commitments)).apply()
}
@Suppress("UNCHECKED_CAST")
fun loadChannelKeyCommitments(): Map<String, String> {
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

View File

@ -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

View File

@ -74,6 +74,15 @@ class MessageManager(private val state: ChatState) {
state.setUnreadChannelMessages(currentUnread)
}
}
fun replaceChannelMessages(channel: String, messages: List<BitchatMessage>) {
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()

View File

@ -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.
*

View File

@ -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))
}
}

View File

@ -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<ByteArray?>(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
}
}

View File

@ -47,7 +47,8 @@ class CommandProcessorTest() {
messageManager = messageManager,
dataManager = DataManager(context = context),
noiseSessionDelegate = mock<NoiseSessionDelegate>()
)
),
coroutineScope = testScope
)
}