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:
Neeraj 2026-07-24 23:37:05 +05:30
parent b7f0b33d3a
commit e6c2fed94c
11 changed files with 470 additions and 104 deletions

View File

@ -760,31 +760,60 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
/**
* Send public message
* Send public message.
* Public (no channel): plain UTF-8 for iOS compatibility.
* Channel: BitchatMessage binary so receivers get channel metadata.
*/
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null) {
if (content.isEmpty()) return
serviceScope.launch {
val packet = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = SpecialRecipients.BROADCAST,
timestamp = System.currentTimeMillis().toULong(),
payload = content.toByteArray(Charsets.UTF_8),
signature = null,
ttl = MAX_TTL
)
// Sign the packet before broadcasting
val signedPacket = signPacketBeforeBroadcast(packet)
broadcastRoutedPacket(RoutedPacket(signedPacket))
// Track our own broadcast message for sync
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
serviceScope.launch {
val payload = if (channel != null) {
val nickname = delegate?.getNickname() ?: myPeerID
val message = com.bitchat.android.model.BitchatMessage(
sender = nickname,
content = content,
timestamp = java.util.Date(),
isRelay = false,
senderPeerID = myPeerID,
mentions = if (mentions.isNotEmpty()) mentions else null,
channel = channel
)
message.toBinaryPayload() ?: content.toByteArray(Charsets.UTF_8)
} else {
content.toByteArray(Charsets.UTF_8)
}
broadcastMessagePayload(payload)
}
}
/**
* Broadcast a pre-encoded payload (encrypted channel messages, etc.).
*/
fun sendBinaryBroadcast(payload: ByteArray) {
if (payload.isEmpty()) return
serviceScope.launch {
broadcastMessagePayload(payload)
}
}
private suspend fun broadcastMessagePayload(payload: ByteArray) {
val packet = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = SpecialRecipients.BROADCAST,
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
signature = null,
ttl = MAX_TTL
)
val signedPacket = signPacketBeforeBroadcast(packet)
broadcastRoutedPacket(RoutedPacket(signedPacket))
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
}
/**
* Send a file over mesh as a broadcast MESSAGE (public mesh timeline/channels).
*/

View File

@ -414,22 +414,48 @@ class MeshCore(
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null) {
if (content.isEmpty()) return
scope.launch {
val packet = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
recipientID = SpecialRecipients.BROADCAST,
timestamp = System.currentTimeMillis().toULong(),
payload = content.toByteArray(Charsets.UTF_8),
signature = null,
ttl = maxTtl
)
val signedPacket = signPacketBeforeBroadcast(packet)
dispatchGlobal(RoutedPacket(signedPacket))
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
val payload = if (channel != null) {
val nickname = delegate?.getNickname() ?: myPeerID
val message = BitchatMessage(
sender = nickname,
content = content,
timestamp = java.util.Date(),
isRelay = false,
senderPeerID = myPeerID,
mentions = if (mentions.isNotEmpty()) mentions else null,
channel = channel
)
message.toBinaryPayload() ?: content.toByteArray(Charsets.UTF_8)
} else {
content.toByteArray(Charsets.UTF_8)
}
broadcastMessagePayload(payload)
}
}
fun sendBinaryBroadcast(payload: ByteArray) {
if (payload.isEmpty()) return
scope.launch {
broadcastMessagePayload(payload)
}
}
private suspend fun broadcastMessagePayload(payload: ByteArray) {
val packet = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
recipientID = SpecialRecipients.BROADCAST,
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
signature = null,
ttl = maxTtl
)
val signedPacket = signPacketBeforeBroadcast(packet)
dispatchGlobal(RoutedPacket(signedPacket))
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
}
fun sendFileBroadcast(file: BitchatFilePacket) {
try {
val payload = file.encode() ?: return

View File

@ -13,6 +13,8 @@ interface MeshService {
fun stopServices()
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null)
/** Broadcast a pre-encoded message payload (e.g. encrypted channel BitchatMessage binary). */
fun sendBinaryBroadcast(payload: ByteArray)
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null)
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String)
fun sendDeliveryAck(messageID: String, recipientPeerID: String) {}

View File

@ -382,7 +382,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
}
/**
* Handle broadcast message with verification enforcement
* Handle broadcast message with verification enforcement.
* Public mesh (iOS-compatible): plain UTF-8 text.
* Channel messages: BitchatMessage binary (optionally AES-GCM encrypted).
*/
private suspend fun handleBroadcastMessage(routed: RoutedPacket) {
val packet = routed.packet
@ -419,7 +421,34 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
Log.w(TAG, "⚠️ FILE_TRANSFER decode failed (broadcast) from ${peerID.take(8)} payloadSize=${packet.payload.size}")
}
// Fallback: plain text
// Channel / encrypted channel: BitchatMessage binary payload
val binaryMessage = BitchatMessage.fromBinaryPayload(packet.payload)
if (binaryMessage != null && (binaryMessage.channel != null || binaryMessage.isEncrypted)) {
val channel = binaryMessage.channel
val finalContent = if (
channel != null &&
binaryMessage.isEncrypted &&
binaryMessage.encryptedContent != null
) {
delegate?.decryptChannelMessage(binaryMessage.encryptedContent, channel)
?: "[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",

View File

@ -63,6 +63,13 @@ class UnifiedMeshService(
}
}
override fun sendBinaryBroadcast(payload: ByteArray) {
when {
isBleEnabled() -> bluetooth.sendBinaryBroadcast(payload)
else -> wifiService()?.sendBinaryBroadcast(payload)
}
}
override fun sendPrivateMessage(
content: String,
recipientPeerID: String,

View File

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

View File

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

View File

@ -109,6 +109,30 @@ class DataManager(private val context: Context) {
apply()
}
}
/**
* Persist SHA-256 key commitments (not passwords) so password verification
* still works after restart when no encrypted messages are cached.
*/
fun saveChannelKeyCommitments(commitments: Map<String, String>) {
prefs.edit().putString("channel_key_commitments", gson.toJson(commitments)).apply()
}
@Suppress("UNCHECKED_CAST")
fun loadChannelKeyCommitments(): Map<String, String> {
val json = prefs.getString("channel_key_commitments", null) ?: return emptyMap()
return try {
val map = gson.fromJson(json, Map::class.java) as? Map<*, *>
map?.mapNotNull { (k, v) ->
val key = k as? String
val value = v as? String
if (key != null && value != null) key to value else null
}?.toMap() ?: emptyMap()
} catch (e: Exception) {
Log.w(TAG, "Failed to load channel key commitments: ${e.message}")
emptyMap()
}
}
fun addChannelCreator(channel: String, creatorID: String) {
_channelCreators[channel] = creatorID

View File

@ -65,6 +65,9 @@ class MeshDelegateHandler(
}
} else if (message.channel != null) {
// Channel message: AppStateStore is the source of truth for list; only manage unread
if (message.isEncrypted) {
channelManager.markChannelPasswordProtected(message.channel)
}
if (state.getJoinedChannelsValue().contains(message.channel)) {
val channel = message.channel
val viewingClassic = state.getCurrentChannelValue() == channel

View File

@ -1346,6 +1346,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
meshCore.sendMessage(content, mentions, channel)
}
override fun sendBinaryBroadcast(payload: ByteArray) {
meshCore.sendBinaryBroadcast(payload)
}
/**
* Sends a private encrypted message to a specific peer.
*

View File

@ -0,0 +1,91 @@
package com.bitchat.android.noise
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Channel password crypto: PBKDF2 + AES-256-GCM roundtrip and key commitments.
*/
class NoiseChannelEncryptionTest {
private lateinit var encryption: NoiseChannelEncryption
@Before
fun setup() {
encryption = NoiseChannelEncryption()
}
@Test
fun encryptDecryptRoundtrip() {
val channel = "#secret"
val password = "correct-horse-battery"
val plaintext = "hello from the mesh"
encryption.setChannelPassword(password, channel)
val encrypted = encryption.encryptChannelMessage(plaintext, channel)
assertTrue(encrypted.size > 12)
val decrypted = encryption.decryptChannelMessage(encrypted, channel)
assertEquals(plaintext, decrypted)
}
@Test
fun wrongPasswordFailsDecrypt() {
val channel = "#secret"
encryption.setChannelPassword("right-password", channel)
val encrypted = encryption.encryptChannelMessage("classified", channel)
val other = NoiseChannelEncryption()
other.setChannelPassword("wrong-password", channel)
try {
other.decryptChannelMessage(encrypted, channel)
throw AssertionError("Expected decrypt to fail with wrong password")
} catch (_: Exception) {
// expected
}
}
@Test
fun keyCommitmentMatchesSamePassword() {
val channel = "#ops"
encryption.setChannelPassword("shared-secret", channel)
val commitment = encryption.calculateKeyCommitment(channel)
assertNotNull(commitment)
assertTrue(encryption.verifyKeyCommitment(channel, commitment!!))
val peer = NoiseChannelEncryption()
peer.setChannelPassword("shared-secret", channel)
assertTrue(peer.verifyKeyCommitment(channel, commitment))
}
@Test
fun keyCommitmentRejectsWrongPassword() {
val channel = "#ops"
encryption.setChannelPassword("shared-secret", channel)
val commitment = encryption.calculateKeyCommitment(channel)!!
val peer = NoiseChannelEncryption()
peer.setChannelPassword("different-secret", channel)
assertFalse(peer.verifyKeyCommitment(channel, commitment))
assertNotEquals(commitment, peer.calculateKeyCommitment(channel))
}
@Test
fun samePasswordSameChannelDerivesSameKey() {
val channel = "#room"
val password = "pw"
encryption.setChannelPassword(password, channel)
val first = encryption.calculateKeyCommitment(channel)
val again = NoiseChannelEncryption()
again.setChannelPassword(password, channel)
assertEquals(first, again.calculateKeyCommitment(channel))
}
}