mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-10-25 07:40:36 +00:00
Merge 0c26bb17723ccafaaac4559aa78f3413c411dbc5 into c8f45408b1984753a4df7f05aff03ceaa17bbba6
This commit is contained in:
commit
8ebe7624f2
@ -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).
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
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