mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-10-25 07:40:36 +00:00
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.
This commit is contained in:
parent
e6c2fed94c
commit
0c26bb1772
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() },
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
228
app/src/test/java/com/bitchat/android/ui/ChannelManagerTest.kt
Normal file
228
app/src/test/java/com/bitchat/android/ui/ChannelManagerTest.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@ -47,7 +47,8 @@ class CommandProcessorTest() {
|
||||
messageManager = messageManager,
|
||||
dataManager = DataManager(context = context),
|
||||
noiseSessionDelegate = mock<NoiseSessionDelegate>()
|
||||
)
|
||||
),
|
||||
coroutineScope = testScope
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user