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:
Neeraj 2026-07-25 00:29:49 +05:30
parent e6c2fed94c
commit 0c26bb1772
9 changed files with 424 additions and 84 deletions

View File

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

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

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

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

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

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