mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-10-25 07:40:36 +00:00
feat: restore password-protected channel encryption
Wire PBKDF2 + AES-GCM channel crypto end-to-end so /pass channels no longer send plaintext, with commitment-based password checks and no plaintext fallback on encrypt failure.
This commit is contained in:
parent
b7f0b33d3a
commit
e6c2fed94c
@ -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)
|
||||
?: "[Encrypted message - password required]"
|
||||
} else {
|
||||
binaryMessage.content
|
||||
}
|
||||
|
||||
val message = binaryMessage.copy(
|
||||
id = binaryMessage.id.ifBlank { PacketIdUtil.computeIdHex(packet).uppercase() },
|
||||
content = finalContent,
|
||||
sender = delegate?.getPeerNickname(peerID) ?: binaryMessage.sender,
|
||||
senderPeerID = peerID,
|
||||
timestamp = Date(packet.timestamp.toLong())
|
||||
)
|
||||
Log.d(TAG, "📢 Channel message from ${peerID.take(8)} channel=${channel ?: "?"} encrypted=${binaryMessage.isEncrypted}")
|
||||
delegate?.onMessageReceived(message)
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: plain text (iOS-compatible public mesh)
|
||||
val message = BitchatMessage(
|
||||
id = PacketIdUtil.computeIdHex(packet).uppercase(),
|
||||
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.security.MessageDigest
|
||||
@ -10,7 +11,10 @@ import com.bitchat.android.model.BitchatMessage
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Handles channel management including creation, joining, leaving, and encryption
|
||||
* Handles channel management including creation, joining, leaving, and encryption.
|
||||
*
|
||||
* Password-protected channels use PBKDF2-HMAC-SHA256 (100k iterations, channel name as salt)
|
||||
* + AES-256-GCM, matching the historical bitchat channel crypto format.
|
||||
*/
|
||||
class ChannelManager(
|
||||
private val state: ChatState,
|
||||
@ -18,24 +22,39 @@ class ChannelManager(
|
||||
private val dataManager: DataManager,
|
||||
private val coroutineScope: CoroutineScope
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ChannelManager"
|
||||
private const val PBKDF2_ITERATIONS = 100_000
|
||||
private const val KEY_BITS = 256
|
||||
private const val GCM_IV_BYTES = 12
|
||||
private const val GCM_TAG_BITS = 128
|
||||
}
|
||||
|
||||
// Channel encryption and security
|
||||
private val channelKeys = mutableMapOf<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 {
|
||||
val channelTag = if (channel.startsWith("#")) channel else "#$channel"
|
||||
|
||||
|
||||
// Check if already joined
|
||||
if (state.getJoinedChannelsValue().contains(channelTag)) {
|
||||
if (state.getPasswordProtectedChannelsValue().contains(channelTag) && !channelKeys.containsKey(channelTag)) {
|
||||
// Need password verification
|
||||
if (password != null) {
|
||||
return verifyChannelPassword(channelTag, password)
|
||||
val ok = verifyChannelPassword(channelTag, password)
|
||||
if (ok) {
|
||||
switchToChannel(channelTag)
|
||||
}
|
||||
return ok
|
||||
} else {
|
||||
state.setPasswordPromptChannel(channelTag)
|
||||
state.setShowPasswordPrompt(true)
|
||||
@ -45,11 +64,11 @@ class ChannelManager(
|
||||
switchToChannel(channelTag)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// If password protected and no key yet
|
||||
if (state.getPasswordProtectedChannelsValue().contains(channelTag) && !channelKeys.containsKey(channelTag)) {
|
||||
if (dataManager.isChannelCreator(channelTag, myPeerID)) {
|
||||
// Channel creator bypass
|
||||
// Channel creator bypass — they should already have set a password via /pass
|
||||
} else if (password != null) {
|
||||
if (!verifyChannelPassword(channelTag, password)) {
|
||||
return false
|
||||
@ -60,183 +79,307 @@ class ChannelManager(
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Join the channel
|
||||
val updatedChannels = state.getJoinedChannelsValue().toMutableSet()
|
||||
updatedChannels.add(channelTag)
|
||||
state.setJoinedChannels(updatedChannels)
|
||||
|
||||
|
||||
// Set as creator if new channel
|
||||
if (!dataManager.channelCreators.containsKey(channelTag) && !state.getPasswordProtectedChannelsValue().contains(channelTag)) {
|
||||
dataManager.addChannelCreator(channelTag, myPeerID)
|
||||
}
|
||||
|
||||
|
||||
// Add ourselves as member
|
||||
dataManager.addChannelMember(channelTag, myPeerID)
|
||||
|
||||
|
||||
// Initialize channel messages if needed
|
||||
if (!state.getChannelMessagesValue().containsKey(channelTag)) {
|
||||
val updatedChannelMessages = state.getChannelMessagesValue().toMutableMap()
|
||||
updatedChannelMessages[channelTag] = emptyList()
|
||||
state.setChannelMessages(updatedChannelMessages)
|
||||
}
|
||||
|
||||
|
||||
switchToChannel(channelTag)
|
||||
saveChannelData()
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
fun leaveChannel(channel: String) {
|
||||
val updatedChannels = state.getJoinedChannelsValue().toMutableSet()
|
||||
updatedChannels.remove(channel)
|
||||
state.setJoinedChannels(updatedChannels)
|
||||
|
||||
|
||||
// Exit channel if currently in it
|
||||
if (state.getCurrentChannelValue() == channel) {
|
||||
state.setCurrentChannel(null)
|
||||
}
|
||||
|
||||
|
||||
// Cleanup
|
||||
messageManager.removeChannelMessages(channel)
|
||||
dataManager.removeChannelMembers(channel)
|
||||
channelKeys.remove(channel)
|
||||
channelPasswords.remove(channel)
|
||||
channelKeyCommitments.remove(channel)
|
||||
dataManager.saveChannelKeyCommitments(channelKeyCommitments)
|
||||
dataManager.removeChannelCreator(channel)
|
||||
|
||||
|
||||
saveChannelData()
|
||||
}
|
||||
|
||||
|
||||
fun switchToChannel(channel: String?) {
|
||||
state.setCurrentChannel(channel)
|
||||
state.setSelectedPrivateChatPeer(null)
|
||||
|
||||
|
||||
// Clear unread count
|
||||
channel?.let { ch ->
|
||||
messageManager.clearChannelUnreadCount(ch)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Channel Password and Encryption
|
||||
|
||||
|
||||
/**
|
||||
* Derive and validate a channel password.
|
||||
* Prefers SHA-256 key commitment when available, otherwise tries decrypting
|
||||
* an existing encrypted message. If neither exists yet, accept and store.
|
||||
*/
|
||||
private fun verifyChannelPassword(channel: String, password: String): Boolean {
|
||||
// TODO: REMOVE THIS - FOR TESTING ONLY
|
||||
if (password.isEmpty()) return false
|
||||
|
||||
val key = try {
|
||||
deriveChannelKey(password, channel)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to derive channel key for $channel: ${e.message}")
|
||||
return false
|
||||
}
|
||||
|
||||
val expectedCommitment = channelKeyCommitments[channel]
|
||||
?: dataManager.loadChannelKeyCommitments()[channel]
|
||||
if (expectedCommitment != null) {
|
||||
val actual = calculateKeyCommitment(key)
|
||||
if (!actual.equals(expectedCommitment, ignoreCase = true)) {
|
||||
Log.w(TAG, "Channel password rejected for $channel (commitment mismatch)")
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
val existingMessages = state.getChannelMessagesValue()[channel]?.filter { it.isEncrypted }
|
||||
if (!existingMessages.isNullOrEmpty()) {
|
||||
val testMessage = existingMessages.firstOrNull { it.encryptedContent?.isNotEmpty() == true }
|
||||
if (testMessage != null) {
|
||||
val decrypted = decryptChannelMessage(
|
||||
testMessage.encryptedContent ?: byteArrayOf(),
|
||||
channel,
|
||||
key
|
||||
)
|
||||
if (decrypted == null) {
|
||||
Log.w(TAG, "Channel password rejected for $channel (decrypt failed)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
channelKeys[channel] = key
|
||||
channelPasswords[channel] = password
|
||||
val commitment = calculateKeyCommitment(key)
|
||||
channelKeyCommitments[channel] = commitment
|
||||
dataManager.saveChannelKeyCommitments(channelKeyCommitments)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
private fun deriveChannelKey(password: String, channelName: String): SecretKeySpec {
|
||||
// PBKDF2 key derivation (same as iOS version)
|
||||
val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
|
||||
val spec = javax.crypto.spec.PBEKeySpec(
|
||||
password.toCharArray(),
|
||||
channelName.toByteArray(),
|
||||
100000, // 100,000 iterations (same as iOS)
|
||||
256 // 256-bit key
|
||||
channelName.toByteArray(Charsets.UTF_8),
|
||||
PBKDF2_ITERATIONS,
|
||||
KEY_BITS
|
||||
)
|
||||
val secretKey = factory.generateSecret(spec)
|
||||
return SecretKeySpec(secretKey.encoded, "AES")
|
||||
}
|
||||
|
||||
|
||||
private fun calculateKeyCommitment(key: SecretKeySpec): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
return digest.digest(key.encoded).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
return decryptChannelMessage(encryptedContent, channel, null)
|
||||
}
|
||||
|
||||
private fun decryptChannelMessage(encryptedContent: ByteArray, channel: String, testKey: SecretKeySpec?): String? {
|
||||
|
||||
private fun decryptChannelMessage(
|
||||
encryptedContent: ByteArray,
|
||||
channel: String,
|
||||
testKey: SecretKeySpec?
|
||||
): String? {
|
||||
val key = testKey ?: channelKeys[channel] ?: return null
|
||||
|
||||
try {
|
||||
if (encryptedContent.size < 16) return null // 12 bytes IV + minimum ciphertext
|
||||
|
||||
|
||||
return try {
|
||||
if (encryptedContent.size < GCM_IV_BYTES + 1) return null
|
||||
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
val iv = encryptedContent.sliceArray(0..11)
|
||||
val ciphertext = encryptedContent.sliceArray(12 until encryptedContent.size)
|
||||
|
||||
val gcmSpec = GCMParameterSpec(128, iv)
|
||||
val iv = encryptedContent.sliceArray(0 until GCM_IV_BYTES)
|
||||
val ciphertext = encryptedContent.sliceArray(GCM_IV_BYTES until encryptedContent.size)
|
||||
|
||||
val gcmSpec = GCMParameterSpec(GCM_TAG_BITS, iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec)
|
||||
|
||||
|
||||
val decryptedData = cipher.doFinal(ciphertext)
|
||||
return String(decryptedData, Charsets.UTF_8)
|
||||
|
||||
String(decryptedData, Charsets.UTF_8)
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Encrypt a channel message and deliver the BitchatMessage binary payload via [onEncryptedPayload].
|
||||
* On failure, calls [onFallback] — callers must NOT send plaintext when a channel key exists.
|
||||
*/
|
||||
fun sendEncryptedChannelMessage(
|
||||
content: String,
|
||||
mentions: List<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 {
|
||||
try {
|
||||
val contentBytes = content.toByteArray(Charsets.UTF_8)
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||
|
||||
val iv = cipher.iv
|
||||
require(iv.size == GCM_IV_BYTES) {
|
||||
"Unexpected GCM IV size: ${iv.size}"
|
||||
}
|
||||
val encryptedData = cipher.doFinal(contentBytes)
|
||||
|
||||
val combined = ByteArray(iv.size + encryptedData.size)
|
||||
System.arraycopy(iv, 0, combined, 0, iv.size)
|
||||
System.arraycopy(encryptedData, 0, combined, iv.size, encryptedData.size)
|
||||
|
||||
val encryptedMessage = BitchatMessage(
|
||||
sender = senderNickname ?: myPeerID,
|
||||
content = "",
|
||||
timestamp = Date(),
|
||||
isRelay = false,
|
||||
senderPeerID = myPeerID,
|
||||
mentions = if (mentions.isNotEmpty()) mentions else null,
|
||||
channel = channel,
|
||||
encryptedContent = combined,
|
||||
isEncrypted = true
|
||||
)
|
||||
|
||||
val messageData = encryptedMessage.toBinaryPayload()
|
||||
if (messageData != null) {
|
||||
onEncryptedPayload(messageData)
|
||||
} else {
|
||||
Log.e(TAG, "Failed to encode encrypted channel message for $channel")
|
||||
onFallback()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to encrypt channel message for $channel: ${e.message}")
|
||||
onFallback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Channel Management
|
||||
|
||||
|
||||
fun addChannelMessage(channel: String, message: BitchatMessage, senderPeerID: String?) {
|
||||
messageManager.addChannelMessage(channel, message)
|
||||
|
||||
|
||||
// Track as channel member
|
||||
senderPeerID?.let { peerID ->
|
||||
dataManager.addChannelMember(channel, peerID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Mark a channel as password-protected when we observe encrypted traffic for it.
|
||||
*/
|
||||
fun markChannelPasswordProtected(channel: String) {
|
||||
if (!state.getPasswordProtectedChannelsValue().contains(channel)) {
|
||||
state.setPasswordProtectedChannels(
|
||||
state.getPasswordProtectedChannelsValue().toMutableSet().apply { add(channel) }
|
||||
)
|
||||
saveChannelData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeChannelMember(channel: String, peerID: String) {
|
||||
dataManager.removeChannelMember(channel, peerID)
|
||||
}
|
||||
|
||||
|
||||
fun cleanupDisconnectedMembers(connectedPeers: List<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 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) {
|
||||
if (password.isEmpty()) {
|
||||
Log.w(TAG, "Ignoring empty password for $channel")
|
||||
return
|
||||
}
|
||||
|
||||
val key = deriveChannelKey(password, channel)
|
||||
channelPasswords[channel] = password
|
||||
channelKeys[channel] = key
|
||||
|
||||
channelKeys[channel] = deriveChannelKey(password, channel)
|
||||
val commitment = calculateKeyCommitment(key)
|
||||
channelKeyCommitments[channel] = commitment
|
||||
dataManager.saveChannelKeyCommitments(channelKeyCommitments)
|
||||
|
||||
state.setPasswordProtectedChannels(
|
||||
state.getPasswordProtectedChannelsValue().toMutableSet().apply { add(channel) }
|
||||
@ -247,19 +390,20 @@ class ChannelManager(
|
||||
state.getPasswordProtectedChannelsValue()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Emergency Clear
|
||||
|
||||
|
||||
fun clearAllChannels() {
|
||||
state.setJoinedChannels(emptySet())
|
||||
state.setCurrentChannel(null)
|
||||
state.setPasswordProtectedChannels(emptySet())
|
||||
state.setShowPasswordPrompt(false)
|
||||
state.setPasswordPromptChannel(null)
|
||||
|
||||
|
||||
channelKeys.clear()
|
||||
channelPasswords.clear()
|
||||
channelKeyCommitments.clear()
|
||||
retentionEnabledChannels.clear()
|
||||
dataManager.saveChannelKeyCommitments(emptyMap())
|
||||
}
|
||||
}
|
||||
|
||||
@ -577,10 +577,17 @@ class ChatViewModel(
|
||||
state.getNicknameValue(),
|
||||
mesh.myPeerID,
|
||||
onEncryptedPayload = { encryptedData ->
|
||||
mesh.sendMessage(content, mentions, currentChannelValue)
|
||||
mesh.sendBinaryBroadcast(encryptedData)
|
||||
},
|
||||
onFallback = {
|
||||
mesh.sendMessage(content, mentions, currentChannelValue)
|
||||
// Never send plaintext when a channel key is configured
|
||||
val systemMessage = BitchatMessage(
|
||||
sender = "system",
|
||||
content = "failed to encrypt message for $currentChannelValue",
|
||||
timestamp = Date(),
|
||||
isRelay = false
|
||||
)
|
||||
channelManager.addChannelMessage(currentChannelValue, systemMessage, null)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user