From 188993c2cadd2964f653161b77d9e47031f089b0 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:36:09 +0200 Subject: [PATCH] feat: add private groups --- .../android/groups/GroupCoordinator.kt | 336 +++++++++ .../bitchat/android/groups/GroupProtocol.kt | 642 ++++++++++++++++++ .../com/bitchat/android/groups/GroupStore.kt | 283 ++++++++ .../android/mesh/BluetoothMeshService.kt | 67 ++ .../java/com/bitchat/android/mesh/MeshCore.kt | 63 ++ .../com/bitchat/android/mesh/MeshDelegate.kt | 11 + .../com/bitchat/android/mesh/MeshService.kt | 5 + .../bitchat/android/mesh/MessageHandler.kt | 32 + .../bitchat/android/mesh/PacketProcessor.kt | 8 + .../android/mesh/UnifiedMeshService.kt | 50 ++ .../bitchat/android/model/NoiseEncrypted.kt | 2 + .../bitchat/android/model/PeerCapabilities.kt | 5 +- .../android/model/RequestSyncPacket.kt | 11 +- .../nostr/NostrDirectMessageHandler.kt | 2 + .../android/protocol/BinaryProtocol.kt | 3 +- .../bitchat/android/services/AppStateStore.kt | 13 + .../bitchat/android/sync/GossipSyncManager.kt | 142 ++-- .../com/bitchat/android/sync/SyncTypeFlags.kt | 77 +++ .../com/bitchat/android/ui/ChatViewModel.kt | 177 +++++ .../bitchat/android/ui/CommandProcessor.kt | 52 +- .../bitchat/android/ui/MeshPeerListSheet.kt | 203 +++++- .../com/bitchat/android/ui/MessageManager.kt | 16 + .../bitchat/android/ui/PrivateChatManager.kt | 6 + .../wifi-aware/WifiAwareMeshService.kt | 16 + app/src/main/res/values/strings.xml | 6 + .../android/groups/GroupCoordinatorTest.kt | 330 +++++++++ .../android/groups/GroupProtocolTest.kt | 263 +++++++ .../bitchat/android/groups/GroupStoreTest.kt | 113 +++ .../android/model/IdentityAnnouncementTest.kt | 12 +- .../android/sync/GossipSyncGroupTest.kt | 116 ++++ .../android/sync/SyncTypeFlagsGroupTest.kt | 38 ++ 31 files changed, 3031 insertions(+), 69 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt create mode 100644 app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt create mode 100644 app/src/main/java/com/bitchat/android/groups/GroupStore.kt create mode 100644 app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/groups/GroupCoordinatorTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/groups/GroupProtocolTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/groups/GroupStoreTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/sync/GossipSyncGroupTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/sync/SyncTypeFlagsGroupTest.kt diff --git a/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt b/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt new file mode 100644 index 00000000..30a0c59a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt @@ -0,0 +1,336 @@ +package com.bitchat.android.groups + +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus +import java.util.Date +import java.util.UUID + +data class GroupPeerIdentity( + val fingerprint: String, + val signingKey: ByteArray +) + +data class GroupCommandResult( + val success: Boolean, + val message: String +) + +interface GroupCoordinatorContext { + val groupStore: GroupStore + val nickname: String + val myPeerID: String + val selectedConversationID: String? + + fun myNoiseFingerprint(): String + fun mySigningPublicKey(): ByteArray? + fun sign(data: ByteArray): ByteArray? + + fun peerIDForNickname(nickname: String): String? + fun isPeerConnected(peerID: String): Boolean + fun peerNickname(peerID: String): String? + fun peerIdentity(peerID: String): GroupPeerIdentity? + fun connectedPeerID(fingerprint: String): String? + fun isFingerprintBlocked(fingerprint: String): Boolean + + fun sendGroupInvite(payload: ByteArray, peerID: String) + fun sendGroupKeyUpdate(payload: ByteArray, peerID: String) + fun broadcastGroupMessage(payload: ByteArray) + + fun appendGroupMessage(groupPeerID: String, message: BitchatMessage): Boolean + fun markGroupUnread(groupPeerID: String) + fun removeGroupConversation(groupPeerID: String) + fun openGroupConversation(groupPeerID: String) + fun closeGroupConversation() + fun addSystemMessage(message: String) + fun addGroupSystemMessage(groupPeerID: String, message: String) + fun notifyGroupMessage(groupPeerID: String, sender: String, message: String) +} + +/** + * Creator-managed private-group state machine matching iOS v1. + */ +class GroupCoordinator(private val context: GroupCoordinatorContext) { + fun createGroup(rawName: String): GroupCommandResult { + val name = rawName.trim() + if (name.isEmpty()) return error("usage: /group create ") + if (name.codePointCount(0, name.length) > MAX_GROUP_NAME_LENGTH) { + return error("group name must be $MAX_GROUP_NAME_LENGTH characters or fewer") + } + val fingerprint = context.myNoiseFingerprint() + val signingKey = context.mySigningPublicKey() + if (!FINGERPRINT.matches(fingerprint) || signingKey?.size != 32) { + return error("your cryptographic identity is not ready") + } + val creator = GroupMember(fingerprint, signingKey, context.nickname) + val group = context.groupStore.createGroup(name, creator) + ?: return error("could not create group") + context.openGroupConversation(group.peerID) + return success("created private group #${group.name}") + } + + fun inviteMember(rawNickname: String): GroupCommandResult { + val nickname = normalizeNickname(rawNickname) + if (nickname.isEmpty()) return error("usage: /group invite ") + val group = selectedGroup() ?: return error("open a private group first") + if (!isCreator(group)) return error("only the group creator can change members") + val peerID = context.peerIDForNickname(nickname) + ?: return error("user '$nickname' was not found") + if (!context.isPeerConnected(peerID)) return error("$nickname is not connected") + val identity = context.peerIdentity(peerID) + ?: return error("$nickname does not have a verified mesh identity") + if (group.isMember(identity.fingerprint)) return error("$nickname is already a member") + if (group.members.size >= BitchatGroup.MAX_MEMBERS) { + return error("groups are limited to ${BitchatGroup.MAX_MEMBERS} members") + } + + val member = GroupMember( + identity.fingerprint, + identity.signingKey, + context.peerNickname(peerID) ?: nickname + ) + val (updated, key) = context.groupStore.rotateKey( + group.groupID, + group.members + member + ) ?: return error("could not rotate the group key") + val payload = signedStatePayload(updated, key) + ?: return error("could not sign the group invite") + + context.sendGroupInvite(payload, peerID) + distributeState(payload, updated, setOf(identity.fingerprint)) + return success("invited $nickname to #${updated.name}") + } + + fun removeMember(rawNickname: String): GroupCommandResult { + val nickname = normalizeNickname(rawNickname) + if (nickname.isEmpty()) return error("usage: /group remove ") + val group = selectedGroup() ?: return error("open a private group first") + if (!isCreator(group)) return error("only the group creator can change members") + val member = group.members.firstOrNull { + it.nickname.equals(nickname, ignoreCase = true) + } ?: return error("$nickname is not in this group") + if (member.fingerprint == group.creatorFingerprint) { + return error("the creator cannot remove themselves") + } + + val remaining = group.members.filterNot { it.fingerprint == member.fingerprint } + val (rotated, key) = context.groupStore.rotateKey(group.groupID, remaining) + ?: return error("could not rotate the group key") + val payload = signedStatePayload(rotated, key) + ?: return error("could not sign the group update") + distributeState(payload, rotated, emptySet()) + notifyRemovedMember(member, rotated) + return success("removed ${member.nickname} and rotated the group key") + } + + fun leaveGroup(): GroupCommandResult { + val group = selectedGroup() ?: return error("open a private group first") + context.closeGroupConversation() + context.removeGroupConversation(group.peerID) + context.groupStore.removeGroup(group.groupID) + return success("left #${group.name}") + } + + fun listGroups(): GroupCommandResult { + val groups = context.groupStore.groups.value + if (groups.isEmpty()) return success("you are not in any private groups") + val fingerprint = context.myNoiseFingerprint() + val lines = groups.joinToString("\n") { group -> + val role = if (group.creatorFingerprint == fingerprint) " (creator)" else "" + "#${group.name}$role — ${group.members.size}/${BitchatGroup.MAX_MEMBERS}" + } + return success("private groups:\n$lines") + } + + fun sendMessage(content: String, groupPeerID: String) { + if (content.isEmpty() || + content.codePointCount(0, content.length) > MAX_MESSAGE_LENGTH + ) { + return + } + val group = context.groupStore.group(groupPeerID) + val key = group?.let { context.groupStore.key(it.groupID) } + if (group == null || key == null) { + context.addGroupSystemMessage(groupPeerID, "this private group is unavailable") + return + } + val signingKey = context.mySigningPublicKey() + if (signingKey?.size != 32) { + context.addGroupSystemMessage(groupPeerID, "your signing identity is unavailable") + return + } + + val messageID = UUID.randomUUID().toString() + val timestamp = System.currentTimeMillis() + val payload = try { + GroupCrypto.sealMessage( + content = content, + messageID = messageID, + senderNickname = context.nickname, + senderSigningKey = signingKey, + timestampMs = timestamp, + groupID = group.groupID, + epoch = group.epoch, + key = key, + sign = context::sign + ) + } catch (_: Exception) { + context.addGroupSystemMessage(groupPeerID, "could not encrypt group message") + return + } + + context.appendGroupMessage( + groupPeerID, + BitchatMessage( + id = messageID, + sender = context.nickname, + content = content, + timestamp = Date(timestamp), + isPrivate = true, + recipientNickname = group.name, + senderPeerID = context.myPeerID, + deliveryStatus = DeliveryStatus.Sent + ) + ) + context.broadcastGroupMessage(payload) + } + + @Suppress("UNUSED_PARAMETER") + fun handleMessage(payload: ByteArray, receivedAtMs: Long) { + val envelope = GroupMessageEnvelope.decode(payload) ?: return + val group = context.groupStore.group(envelope.groupID) ?: return + if (envelope.epoch != group.epoch) return + val key = context.groupStore.key(group.groupID) ?: return + val plaintext = try { + GroupCrypto.openMessage(envelope, key) + } catch (_: Exception) { + return + } + val member = group.memberWithSigningKey(plaintext.senderSigningKey) ?: return + val ownSigningKey = context.mySigningPublicKey() + if (ownSigningKey != null && plaintext.senderSigningKey.contentEquals(ownSigningKey)) return + if (context.isFingerprintBlocked(member.fingerprint)) return + + val now = System.currentTimeMillis() + val timestamp = plaintext.timestampMs.coerceIn(0, now) + val sender = member.nickname.ifBlank { plaintext.senderNickname } + val message = BitchatMessage( + id = plaintext.messageID, + sender = sender, + content = plaintext.content, + timestamp = Date(timestamp), + isPrivate = true, + recipientNickname = group.name, + senderPeerID = member.fingerprint.take(16) + ) + if (!context.appendGroupMessage(group.peerID, message)) return + + if (context.selectedConversationID != group.peerID) { + context.markGroupUnread(group.peerID) + if (now - timestamp < RECENT_NOTIFICATION_WINDOW_MS) { + context.notifyGroupMessage(group.peerID, "$sender @ ${group.name}", plaintext.content) + } + } + } + + fun handleInvite( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + payload: ByteArray + ) { + applyState(peerID, authenticatedRemoteStaticKey, payload) + } + + fun handleKeyUpdate( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + payload: ByteArray + ) { + applyState(peerID, authenticatedRemoteStaticKey, payload) + } + + private fun applyState( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + payload: ByteArray + ) { + val state = GroupStatePayload.decode(payload) ?: return + val senderFingerprint = sha256(authenticatedRemoteStaticKey).toHex() + if (senderFingerprint != state.creatorFingerprint) return + if (!state.verifyCreatorSignature()) return + + val ownFingerprint = context.myNoiseFingerprint() + val existing = context.groupStore.group(state.groupID) + if (state.members.none { it.fingerprint == ownFingerprint }) { + if (existing != null) { + if (context.selectedConversationID == existing.peerID) { + context.closeGroupConversation() + } + context.removeGroupConversation(existing.peerID) + context.groupStore.removeGroup(existing.groupID) + context.addSystemMessage("you were removed from #${existing.name}") + } + return + } + if (existing != null && state.epoch < existing.epoch) return + if (!context.groupStore.upsert(state.asGroup(), state.key)) return + + if (existing == null) { + val inviter = state.members.firstOrNull { + it.fingerprint == state.creatorFingerprint + }?.nickname ?: context.peerNickname(peerID) ?: "unknown" + val notice = "joined #${state.name}, invited by $inviter" + context.addSystemMessage(notice) + context.markGroupUnread(state.asGroup().peerID) + context.notifyGroupMessage(state.asGroup().peerID, inviter, notice) + } + } + + private fun signedStatePayload(group: BitchatGroup, key: ByteArray): ByteArray? = + GroupStatePayload.makeSigned(group, key, context::sign)?.encode() + + private fun distributeState( + payload: ByteArray, + group: BitchatGroup, + excludedFingerprints: Set + ) { + val ownFingerprint = context.myNoiseFingerprint() + group.members.forEach { member -> + if (member.fingerprint == ownFingerprint || + member.fingerprint in excludedFingerprints + ) { + return@forEach + } + context.connectedPeerID(member.fingerprint)?.let { peerID -> + context.sendGroupKeyUpdate(payload, peerID) + } + } + } + + private fun notifyRemovedMember(member: GroupMember, rotated: BitchatGroup) { + val peerID = context.connectedPeerID(member.fingerprint) ?: return + val payload = signedStatePayload(rotated, ByteArray(BitchatGroup.KEY_LENGTH)) ?: return + context.sendGroupKeyUpdate(payload, peerID) + } + + private fun selectedGroup(): BitchatGroup? = + context.selectedConversationID?.let(context.groupStore::group) + + private fun isCreator(group: BitchatGroup): Boolean = + group.creatorFingerprint == context.myNoiseFingerprint() + + private fun normalizeNickname(raw: String): String = + raw.trim().removePrefix("@") + + private fun success(message: String) = GroupCommandResult(true, message) + private fun error(message: String) = GroupCommandResult(false, message) + + private fun ByteArray.toHex(): String = + joinToString("") { "%02x".format(it) } + + companion object { + private const val MAX_GROUP_NAME_LENGTH = 40 + private const val MAX_MESSAGE_LENGTH = 60_000 + private const val RECENT_NOTIFICATION_WINDOW_MS = 30_000L + private val FINGERPRINT = Regex("^[0-9a-fA-F]{64}$") + } +} diff --git a/app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt b/app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt new file mode 100644 index 00000000..a96c2c7f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt @@ -0,0 +1,642 @@ +package com.bitchat.android.groups + +import com.bitchat.android.util.dataFromHexString +import com.bitchat.android.util.hexEncodedString +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.charset.CodingErrorAction +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Arrays +import org.bouncycastle.crypto.InvalidCipherTextException +import org.bouncycastle.crypto.modes.ChaCha20Poly1305 +import org.bouncycastle.crypto.params.AEADParameters +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.params.KeyParameter +import org.bouncycastle.crypto.signers.Ed25519Signer + +data class GroupMember( + val fingerprint: String, + val signingKey: ByteArray, + val nickname: String +) { + override fun equals(other: Any?): Boolean = + this === other || + (other is GroupMember && + fingerprint == other.fingerprint && + signingKey.contentEquals(other.signingKey) && + nickname == other.nickname) + + override fun hashCode(): Int = + 31 * (31 * fingerprint.hashCode() + signingKey.contentHashCode()) + nickname.hashCode() +} + +data class BitchatGroup( + val groupID: ByteArray, + val name: String, + val epoch: Long, + val members: List, + val creatorFingerprint: String +) { + val peerID: String get() = GroupIds.peerID(groupID) + val creator: GroupMember? get() = members.firstOrNull { it.fingerprint == creatorFingerprint } + + fun isMember(fingerprint: String): Boolean = + members.any { it.fingerprint == fingerprint } + + fun memberWithSigningKey(signingKey: ByteArray): GroupMember? = + members.firstOrNull { it.signingKey.contentEquals(signingKey) } + + override fun equals(other: Any?): Boolean = + this === other || + (other is BitchatGroup && + groupID.contentEquals(other.groupID) && + name == other.name && + epoch == other.epoch && + members == other.members && + creatorFingerprint == other.creatorFingerprint) + + override fun hashCode(): Int { + var result = groupID.contentHashCode() + result = 31 * result + name.hashCode() + result = 31 * result + epoch.hashCode() + result = 31 * result + members.hashCode() + result = 31 * result + creatorFingerprint.hashCode() + return result + } + + companion object { + const val MAX_MEMBERS = 16 + const val GROUP_ID_LENGTH = 16 + const val KEY_LENGTH = 32 + const val MAX_EPOCH = 0xffff_ffffL + } +} + +object GroupIds { + private const val PREFIX = "group_" + private val pattern = Regex("^group_[0-9a-f]{32}$") + + fun peerID(groupID: ByteArray): String { + require(groupID.size == BitchatGroup.GROUP_ID_LENGTH) + return PREFIX + groupID.hexEncodedString() + } + + fun groupID(peerID: String): ByteArray? { + val normalized = peerID.lowercase() + if (!pattern.matches(normalized)) return null + return normalized.removePrefix(PREFIX).dataFromHexString() + } + + fun isGroup(peerID: String?): Boolean = + peerID != null && pattern.matches(peerID.lowercase()) +} + +class GroupTlvValueTooLongException : IllegalArgumentException("group TLV value exceeds UInt16") + +internal object GroupTLV { + data class Field(val type: Int, val value: ByteArray) + + fun put(type: Int, value: ByteArray, output: ByteArrayOutputStream) { + if (value.size > 0xffff) throw GroupTlvValueTooLongException() + output.write(type and 0xff) + output.write((value.size ushr 8) and 0xff) + output.write(value.size and 0xff) + output.write(value) + } + + fun encode(vararg fields: Pair): ByteArray { + val output = ByteArrayOutputStream() + fields.forEach { (type, value) -> put(type, value, output) } + return output.toByteArray() + } + + fun parse(data: ByteArray): List? { + val fields = mutableListOf() + var offset = 0 + while (offset < data.size) { + if (offset + 3 > data.size) return null + val type = data[offset].toInt() and 0xff + val length = + ((data[offset + 1].toInt() and 0xff) shl 8) or + (data[offset + 2].toInt() and 0xff) + offset += 3 + if (offset + length > data.size) return null + fields += Field(type, data.copyOfRange(offset, offset + length)) + offset += length + } + return fields + } + + fun epochData(epoch: Long): ByteArray { + require(epoch in 0..BitchatGroup.MAX_EPOCH) + return ByteBuffer.allocate(Int.SIZE_BYTES) + .order(ByteOrder.BIG_ENDIAN) + .putInt(epoch.toInt()) + .array() + } + + fun epoch(data: ByteArray): Long? { + if (data.size != Int.SIZE_BYTES) return null + return ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN).int.toLong() and 0xffff_ffffL + } + + fun timestampData(timestampMs: Long): ByteArray = + ByteBuffer.allocate(Long.SIZE_BYTES) + .order(ByteOrder.BIG_ENDIAN) + .putLong(timestampMs) + .array() + + fun timestamp(data: ByteArray): Long? { + if (data.size != Long.SIZE_BYTES) return null + return ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN).long + } + + fun strictUtf8(data: ByteArray): String? = try { + Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(data)) + .toString() + } catch (_: Exception) { + null + } +} + +object GroupRosterCoding { + private const val FINGERPRINT_LENGTH = 32 + private const val SIGNING_KEY_LENGTH = 32 + private const val MAX_NICKNAME_BYTES = 64 + + fun encode(members: List): ByteArray? { + if (members.size > BitchatGroup.MAX_MEMBERS) return null + val output = ByteArrayOutputStream() + output.write(members.size) + members.forEach { member -> + val fingerprint = member.fingerprint.dataFromHexString() + if (fingerprint?.size != FINGERPRINT_LENGTH || + member.signingKey.size != SIGNING_KEY_LENGTH + ) { + return null + } + output.write(fingerprint) + output.write(member.signingKey) + val nickname = truncatedNicknameBytes(member.nickname) + output.write(nickname.size) + output.write(nickname) + } + return output.toByteArray() + } + + fun decode(data: ByteArray): List? { + if (data.isEmpty()) return null + val count = data[0].toInt() and 0xff + if (count > BitchatGroup.MAX_MEMBERS) return null + val members = mutableListOf() + var offset = 1 + repeat(count) { + val fixedLength = FINGERPRINT_LENGTH + SIGNING_KEY_LENGTH + 1 + if (offset + fixedLength > data.size) return null + val fingerprint = + data.copyOfRange(offset, offset + FINGERPRINT_LENGTH).hexEncodedString() + offset += FINGERPRINT_LENGTH + val signingKey = data.copyOfRange(offset, offset + SIGNING_KEY_LENGTH) + offset += SIGNING_KEY_LENGTH + val nicknameLength = data[offset].toInt() and 0xff + offset += 1 + if (offset + nicknameLength > data.size) return null + val nickname = GroupTLV.strictUtf8( + data.copyOfRange(offset, offset + nicknameLength) + ) ?: return null + offset += nicknameLength + members += GroupMember(fingerprint, signingKey, nickname) + } + if (offset != data.size) return null + return members + } + + private fun truncatedNicknameBytes(nickname: String): ByteArray { + val output = StringBuilder() + var offset = 0 + while (offset < nickname.length) { + val codePoint = nickname.codePointAt(offset) + val candidate = output.toString() + String(Character.toChars(codePoint)) + if (candidate.toByteArray(Charsets.UTF_8).size > MAX_NICKNAME_BYTES) break + output.appendCodePoint(codePoint) + offset += Character.charCount(codePoint) + } + return output.toString().toByteArray(Charsets.UTF_8) + } +} + +class GroupStatePayload( + val groupID: ByteArray, + val name: String, + val key: ByteArray, + val epoch: Long, + val members: List, + val creatorFingerprint: String, + val signature: ByteArray +) { + fun encode(): ByteArray? { + val roster = GroupRosterCoding.encode(members) ?: return null + val creator = creatorFingerprint.dataFromHexString() + if (creator?.size != 32) return null + return try { + GroupTLV.encode( + FIELD_GROUP_ID to groupID, + FIELD_NAME to name.toByteArray(Charsets.UTF_8), + FIELD_KEY to key, + FIELD_EPOCH to GroupTLV.epochData(epoch), + FIELD_ROSTER to roster, + FIELD_CREATOR_FINGERPRINT to creator, + FIELD_SIGNATURE to signature + ) + } catch (_: GroupTlvValueTooLongException) { + null + } + } + + fun verifyCreatorSignature(): Boolean { + if (members.size > BitchatGroup.MAX_MEMBERS) return false + val creator = members.firstOrNull { it.fingerprint == creatorFingerprint } ?: return false + val roster = GroupRosterCoding.encode(members) ?: return false + return GroupCrypto.verify( + signature, + signingContent(groupID, epoch, key, roster, name), + creator.signingKey + ) + } + + fun asGroup(): BitchatGroup = + BitchatGroup(groupID, name, epoch, members, creatorFingerprint) + + override fun equals(other: Any?): Boolean = + this === other || + (other is GroupStatePayload && + groupID.contentEquals(other.groupID) && + name == other.name && + key.contentEquals(other.key) && + epoch == other.epoch && + members == other.members && + creatorFingerprint == other.creatorFingerprint && + signature.contentEquals(other.signature)) + + override fun hashCode(): Int { + var result = Arrays.hashCode(groupID) + result = 31 * result + name.hashCode() + result = 31 * result + Arrays.hashCode(key) + result = 31 * result + epoch.hashCode() + result = 31 * result + members.hashCode() + result = 31 * result + creatorFingerprint.hashCode() + result = 31 * result + Arrays.hashCode(signature) + return result + } + + companion object { + private const val FIELD_GROUP_ID = 0x01 + private const val FIELD_NAME = 0x02 + private const val FIELD_KEY = 0x03 + private const val FIELD_EPOCH = 0x04 + private const val FIELD_ROSTER = 0x05 + private const val FIELD_CREATOR_FINGERPRINT = 0x06 + private const val FIELD_SIGNATURE = 0x07 + private val SIGNING_DOMAIN = "bitchat-group-v1".toByteArray(Charsets.UTF_8) + + fun signingContent( + groupID: ByteArray, + epoch: Long, + key: ByteArray, + rosterBlob: ByteArray, + name: String + ): ByteArray = concat( + SIGNING_DOMAIN, + groupID, + GroupTLV.epochData(epoch), + sha256(key), + sha256(rosterBlob), + sha256(name.toByteArray(Charsets.UTF_8)) + ) + + fun makeSigned( + group: BitchatGroup, + key: ByteArray, + sign: (ByteArray) -> ByteArray? + ): GroupStatePayload? { + val roster = GroupRosterCoding.encode(group.members) ?: return null + val signature = sign( + signingContent(group.groupID, group.epoch, key, roster, group.name) + ) ?: return null + if (signature.size != 64) return null + return GroupStatePayload( + group.groupID, + group.name, + key, + group.epoch, + group.members, + group.creatorFingerprint, + signature + ) + } + + fun decode(data: ByteArray): GroupStatePayload? { + val fields = GroupTLV.parse(data) ?: return null + var groupID: ByteArray? = null + var name: String? = null + var key: ByteArray? = null + var epoch: Long? = null + var members: List? = null + var creatorFingerprint: String? = null + var signature: ByteArray? = null + fields.forEach { field -> + when (field.type) { + FIELD_GROUP_ID -> + if (field.value.size == BitchatGroup.GROUP_ID_LENGTH) groupID = field.value + FIELD_NAME -> name = GroupTLV.strictUtf8(field.value) + FIELD_KEY -> + if (field.value.size == BitchatGroup.KEY_LENGTH) key = field.value + FIELD_EPOCH -> epoch = GroupTLV.epoch(field.value) + FIELD_ROSTER -> members = GroupRosterCoding.decode(field.value) + FIELD_CREATOR_FINGERPRINT -> + if (field.value.size == 32) creatorFingerprint = field.value.hexEncodedString() + FIELD_SIGNATURE -> if (field.value.size == 64) signature = field.value + } + } + val decodedMembers = members ?: return null + if (decodedMembers.isEmpty()) return null + return GroupStatePayload( + groupID ?: return null, + name ?: return null, + key ?: return null, + epoch ?: return null, + decodedMembers, + creatorFingerprint ?: return null, + signature ?: return null + ) + } + } +} + +class GroupMessageEnvelope( + val groupID: ByteArray, + val epoch: Long, + val nonce: ByteArray, + val ciphertext: ByteArray +) { + fun encode(): ByteArray = GroupTLV.encode( + FIELD_GROUP_ID to groupID, + FIELD_EPOCH to GroupTLV.epochData(epoch), + FIELD_NONCE to nonce, + FIELD_CIPHERTEXT to ciphertext + ) + + override fun equals(other: Any?): Boolean = + this === other || + (other is GroupMessageEnvelope && + groupID.contentEquals(other.groupID) && + epoch == other.epoch && + nonce.contentEquals(other.nonce) && + ciphertext.contentEquals(other.ciphertext)) + + override fun hashCode(): Int { + var result = groupID.contentHashCode() + result = 31 * result + epoch.hashCode() + result = 31 * result + nonce.contentHashCode() + result = 31 * result + ciphertext.contentHashCode() + return result + } + + companion object { + private const val FIELD_GROUP_ID = 0x01 + private const val FIELD_EPOCH = 0x02 + private const val FIELD_NONCE = 0x03 + private const val FIELD_CIPHERTEXT = 0x04 + + fun decode(data: ByteArray): GroupMessageEnvelope? { + val fields = GroupTLV.parse(data) ?: return null + var groupID: ByteArray? = null + var epoch: Long? = null + var nonce: ByteArray? = null + var ciphertext: ByteArray? = null + fields.forEach { field -> + when (field.type) { + FIELD_GROUP_ID -> + if (field.value.size == BitchatGroup.GROUP_ID_LENGTH) groupID = field.value + FIELD_EPOCH -> epoch = GroupTLV.epoch(field.value) + FIELD_NONCE -> if (field.value.size == 12) nonce = field.value + FIELD_CIPHERTEXT -> if (field.value.isNotEmpty()) ciphertext = field.value + } + } + return GroupMessageEnvelope( + groupID ?: return null, + epoch ?: return null, + nonce ?: return null, + ciphertext ?: return null + ) + } + } +} + +data class GroupMessagePlaintext( + val messageID: String, + val senderSigningKey: ByteArray, + val senderNickname: String, + val timestampMs: Long, + val content: String +) { + override fun equals(other: Any?): Boolean = + this === other || + (other is GroupMessagePlaintext && + messageID == other.messageID && + senderSigningKey.contentEquals(other.senderSigningKey) && + senderNickname == other.senderNickname && + timestampMs == other.timestampMs && + content == other.content) + + override fun hashCode(): Int { + var result = messageID.hashCode() + result = 31 * result + senderSigningKey.contentHashCode() + result = 31 * result + senderNickname.hashCode() + result = 31 * result + timestampMs.hashCode() + result = 31 * result + content.hashCode() + return result + } +} + +sealed class GroupCryptoException(message: String) : Exception(message) { + class MalformedPayload : GroupCryptoException("malformed group payload") + class SigningFailed : GroupCryptoException("group message signing failed") + class SealFailed : GroupCryptoException("group message sealing failed") + class DecryptionFailed : GroupCryptoException("group message decryption failed") + class BadSenderSignature : GroupCryptoException("bad group sender signature") +} + +object GroupCrypto { + private const val FIELD_MESSAGE_ID = 0x01 + private const val FIELD_SENDER_SIGNING_KEY = 0x02 + private const val FIELD_SENDER_NICKNAME = 0x03 + private const val FIELD_TIMESTAMP = 0x04 + private const val FIELD_CONTENT = 0x05 + private const val FIELD_SIGNATURE = 0x06 + private val MESSAGE_SIGNING_DOMAIN = + "bitchat-group-msg-v1".toByteArray(Charsets.UTF_8) + private val random = SecureRandom() + + fun messageSigningContent( + groupID: ByteArray, + epoch: Long, + messageID: String, + timestampMs: Long, + content: String + ): ByteArray = concat( + MESSAGE_SIGNING_DOMAIN, + groupID, + GroupTLV.epochData(epoch), + messageID.toByteArray(Charsets.UTF_8), + GroupTLV.timestampData(timestampMs), + content.toByteArray(Charsets.UTF_8) + ) + + fun verify(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean { + if (signature.size != 64 || publicKey.size != 32) return false + return try { + val verifier = Ed25519Signer() + verifier.init(false, Ed25519PublicKeyParameters(publicKey, 0)) + verifier.update(data, 0, data.size) + verifier.verifySignature(signature) + } catch (_: Exception) { + false + } + } + + @Throws(GroupCryptoException::class, GroupTlvValueTooLongException::class) + fun sealMessage( + content: String, + messageID: String, + senderNickname: String, + senderSigningKey: ByteArray, + timestampMs: Long, + groupID: ByteArray, + epoch: Long, + key: ByteArray, + sign: (ByteArray) -> ByteArray? + ): ByteArray { + val signature = sign( + messageSigningContent(groupID, epoch, messageID, timestampMs, content) + ) + if (signature?.size != 64) throw GroupCryptoException.SigningFailed() + + val inner = GroupTLV.encode( + FIELD_MESSAGE_ID to messageID.toByteArray(Charsets.UTF_8), + FIELD_SENDER_SIGNING_KEY to senderSigningKey, + FIELD_SENDER_NICKNAME to senderNickname.toByteArray(Charsets.UTF_8), + FIELD_TIMESTAMP to GroupTLV.timestampData(timestampMs), + FIELD_CONTENT to content.toByteArray(Charsets.UTF_8), + FIELD_SIGNATURE to signature + ) + if (key.size != BitchatGroup.KEY_LENGTH || + groupID.size != BitchatGroup.GROUP_ID_LENGTH + ) { + throw GroupCryptoException.SealFailed() + } + + return try { + val nonce = ByteArray(12).also(random::nextBytes) + val aad = concat(groupID, GroupTLV.epochData(epoch)) + val ciphertext = crypt(encrypt = true, key, nonce, aad, inner) + GroupMessageEnvelope(groupID, epoch, nonce, ciphertext).encode() + } catch (error: GroupTlvValueTooLongException) { + throw error + } catch (_: Exception) { + throw GroupCryptoException.SealFailed() + } + } + + @Throws(GroupCryptoException::class) + fun openMessage(envelope: GroupMessageEnvelope, key: ByteArray): GroupMessagePlaintext { + if (key.size != BitchatGroup.KEY_LENGTH || envelope.ciphertext.size <= 16) { + throw GroupCryptoException.DecryptionFailed() + } + val inner = try { + crypt( + encrypt = false, + key, + envelope.nonce, + concat(envelope.groupID, GroupTLV.epochData(envelope.epoch)), + envelope.ciphertext + ) + } catch (_: Exception) { + throw GroupCryptoException.DecryptionFailed() + } + + val fields = GroupTLV.parse(inner) ?: throw GroupCryptoException.MalformedPayload() + var messageID: String? = null + var senderSigningKey: ByteArray? = null + var senderNickname: String? = null + var timestampMs: Long? = null + var content: String? = null + var signature: ByteArray? = null + fields.forEach { field -> + when (field.type) { + FIELD_MESSAGE_ID -> messageID = GroupTLV.strictUtf8(field.value) + FIELD_SENDER_SIGNING_KEY -> + if (field.value.size == 32) senderSigningKey = field.value + FIELD_SENDER_NICKNAME -> senderNickname = GroupTLV.strictUtf8(field.value) + FIELD_TIMESTAMP -> timestampMs = GroupTLV.timestamp(field.value) + FIELD_CONTENT -> content = GroupTLV.strictUtf8(field.value) + FIELD_SIGNATURE -> if (field.value.size == 64) signature = field.value + } + } + val decodedMessageID = messageID?.takeIf { it.isNotEmpty() } + ?: throw GroupCryptoException.MalformedPayload() + val decodedSigningKey = senderSigningKey ?: throw GroupCryptoException.MalformedPayload() + val decodedNickname = senderNickname ?: throw GroupCryptoException.MalformedPayload() + val decodedTimestamp = timestampMs ?: throw GroupCryptoException.MalformedPayload() + val decodedContent = content ?: throw GroupCryptoException.MalformedPayload() + val decodedSignature = signature ?: throw GroupCryptoException.MalformedPayload() + + val signingContent = messageSigningContent( + envelope.groupID, + envelope.epoch, + decodedMessageID, + decodedTimestamp, + decodedContent + ) + if (!verify(decodedSignature, signingContent, decodedSigningKey)) { + throw GroupCryptoException.BadSenderSignature() + } + return GroupMessagePlaintext( + decodedMessageID, + decodedSigningKey, + decodedNickname, + decodedTimestamp, + decodedContent + ) + } + + @Throws(InvalidCipherTextException::class) + private fun crypt( + encrypt: Boolean, + key: ByteArray, + nonce: ByteArray, + aad: ByteArray, + input: ByteArray + ): ByteArray { + val cipher = ChaCha20Poly1305() + cipher.init(encrypt, AEADParameters(KeyParameter(key), 128, nonce, aad)) + val output = ByteArray(cipher.getOutputSize(input.size)) + var length = cipher.processBytes(input, 0, input.size, output, 0) + length += cipher.doFinal(output, length) + return output.copyOf(length) + } +} + +internal fun sha256(data: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256").digest(data) + +internal fun concat(vararg arrays: ByteArray): ByteArray { + val output = ByteArrayOutputStream(arrays.sumOf(ByteArray::size)) + arrays.forEach(output::write) + return output.toByteArray() +} diff --git a/app/src/main/java/com/bitchat/android/groups/GroupStore.kt b/app/src/main/java/com/bitchat/android/groups/GroupStore.kt new file mode 100644 index 00000000..46f3dbe7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/groups/GroupStore.kt @@ -0,0 +1,283 @@ +package com.bitchat.android.groups + +import android.annotation.SuppressLint +import android.content.Context +import android.content.SharedPreferences +import android.util.Base64 +import android.util.Log +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.bitchat.android.util.hexEncodedString +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.io.File +import java.io.FileOutputStream +import java.security.SecureRandom +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +internal interface GroupKeyStorage { + fun get(key: String): ByteArray? + fun put(key: String, value: ByteArray): Boolean + fun remove(key: String): Boolean +} + +@SuppressLint("ApplySharedPref", "UseKtx") +private class EncryptedPreferencesGroupKeyStorage(context: Context) : GroupKeyStorage { + private val preferences: SharedPreferences + + init { + val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + preferences = EncryptedSharedPreferences.create( + context, + "bitchat_private_groups", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + override fun get(key: String): ByteArray? = try { + preferences.getString(key, null)?.let { + Base64.decode(it, Base64.NO_WRAP) + } + } catch (_: Exception) { + null + } + + override fun put(key: String, value: ByteArray): Boolean = try { + // The metadata must not advance unless the epoch key is durably stored. + preferences.edit() + .putString(key, Base64.encodeToString(value, Base64.NO_WRAP)) + .commit() + } catch (_: Exception) { + false + } + + override fun remove(key: String): Boolean = try { + // Removal is a security boundary, so report the synchronous result. + preferences.edit().remove(key).commit() + } catch (_: Exception) { + false + } +} + +/** + * Persistent private-group metadata and epoch keys. + * + * Metadata is kept in app-private no-backup storage. Symmetric group keys are + * stored separately in EncryptedSharedPreferences backed by Android Keystore. + */ +class GroupStore private constructor( + private val keyStorage: GroupKeyStorage, + private val metadataFile: File? +) { + private data class StoredMember( + val fingerprint: String, + val signingKey: String, + val nickname: String + ) + + private data class StoredGroup( + val groupID: String, + val name: String, + val epoch: Long, + val members: List, + val creatorFingerprint: String + ) + + private val lock = Any() + private val gson = Gson() + private val random = SecureRandom() + private val _groups = MutableStateFlow>(emptyList()) + val groups: StateFlow> = _groups.asStateFlow() + + constructor(context: Context) : this( + EncryptedPreferencesGroupKeyStorage(context.applicationContext), + File(context.noBackupFilesDir, "groups/groups.json") + ) + + internal constructor( + keyStorage: GroupKeyStorage, + metadataFile: File? = null, + testOnly: Boolean + ) : this(keyStorage, metadataFile) { + require(testOnly) + } + + init { + loadFromDisk() + } + + fun group(groupID: ByteArray): BitchatGroup? = synchronized(lock) { + _groups.value.firstOrNull { it.groupID.contentEquals(groupID) } + } + + fun group(peerID: String): BitchatGroup? = + GroupIds.groupID(peerID)?.let(::group) + + fun key(groupID: ByteArray): ByteArray? = + keyStorage.get(keyName(groupID))?.takeIf { it.size == BitchatGroup.KEY_LENGTH } + + fun createGroup(name: String, creator: GroupMember): BitchatGroup? { + val groupID = ByteArray(BitchatGroup.GROUP_ID_LENGTH).also(random::nextBytes) + val key = ByteArray(BitchatGroup.KEY_LENGTH).also(random::nextBytes) + val group = BitchatGroup( + groupID = groupID, + name = name, + epoch = 1, + members = listOf(creator), + creatorFingerprint = creator.fingerprint + ) + return group.takeIf { upsert(it, key) } + } + + fun upsert(group: BitchatGroup, key: ByteArray): Boolean = synchronized(lock) { + if (!isValid(group, key)) return@synchronized false + if (!keyStorage.put(keyName(group.groupID), key.copyOf())) { + Log.e(TAG, "Failed to store private-group epoch key") + return@synchronized false + } + val updated = _groups.value.toMutableList() + val index = updated.indexOfFirst { it.groupID.contentEquals(group.groupID) } + if (index >= 0) { + updated[index] = group.deepCopy() + } else { + updated += group.deepCopy() + } + _groups.value = updated + persistLocked() + true + } + + fun rotateKey( + groupID: ByteArray, + members: List + ): Pair? = synchronized(lock) { + val existing = _groups.value.firstOrNull { it.groupID.contentEquals(groupID) } + ?: return@synchronized null + val newKey = ByteArray(BitchatGroup.KEY_LENGTH).also(random::nextBytes) + val rotated = existing.copy( + epoch = (existing.epoch + 1) and BitchatGroup.MAX_EPOCH, + members = members.map { it.deepCopy() } + ) + if (!upsert(rotated, newKey)) return@synchronized null + rotated to newKey + } + + fun removeGroup(groupID: ByteArray) = synchronized(lock) { + _groups.value = _groups.value.filterNot { it.groupID.contentEquals(groupID) } + keyStorage.remove(keyName(groupID)) + persistLocked() + } + + fun wipe() = synchronized(lock) { + _groups.value.forEach { keyStorage.remove(keyName(it.groupID)) } + _groups.value = emptyList() + try { + metadataFile?.delete() + metadataFile?.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete() + } catch (_: Exception) { + } + } + + private fun isValid(group: BitchatGroup, key: ByteArray): Boolean = + group.groupID.size == BitchatGroup.GROUP_ID_LENGTH && + key.size == BitchatGroup.KEY_LENGTH && + group.epoch in 0..BitchatGroup.MAX_EPOCH && + group.members.isNotEmpty() && + group.members.size <= BitchatGroup.MAX_MEMBERS && + group.creator != null && + group.members.all { + it.fingerprint.matches(Regex("^[0-9a-fA-F]{64}$")) && + it.signingKey.size == 32 + } + + private fun persistLocked() { + val file = metadataFile ?: return + try { + if (_groups.value.isEmpty()) { + file.delete() + return + } + file.parentFile?.mkdirs() + val stored = _groups.value.map { group -> + StoredGroup( + groupID = Base64.encodeToString(group.groupID, Base64.NO_WRAP), + name = group.name, + epoch = group.epoch, + members = group.members.map { member -> + StoredMember( + fingerprint = member.fingerprint, + signingKey = Base64.encodeToString(member.signingKey, Base64.NO_WRAP), + nickname = member.nickname + ) + }, + creatorFingerprint = group.creatorFingerprint + ) + } + val temporary = File(file.parentFile, "${file.name}.tmp") + FileOutputStream(temporary).use { output -> + output.write(gson.toJson(stored).toByteArray(Charsets.UTF_8)) + output.fd.sync() + } + if (!temporary.renameTo(file)) { + temporary.copyTo(file, overwrite = true) + temporary.delete() + } + } catch (error: Exception) { + Log.e(TAG, "Failed to persist private-group metadata: ${error.message}") + } + } + + private fun loadFromDisk() = synchronized(lock) { + val file = metadataFile ?: return@synchronized + val stored = try { + if (!file.exists()) return@synchronized + val type = object : TypeToken>() {}.type + gson.fromJson>(file.readText(Charsets.UTF_8), type) + } catch (_: Exception) { + null + } ?: return@synchronized + + _groups.value = stored.mapNotNull { item -> + try { + val group = BitchatGroup( + groupID = Base64.decode(item.groupID, Base64.NO_WRAP), + name = item.name, + epoch = item.epoch, + members = item.members.map { member -> + GroupMember( + member.fingerprint, + Base64.decode(member.signingKey, Base64.NO_WRAP), + member.nickname + ) + }, + creatorFingerprint = item.creatorFingerprint + ) + val storedKey = key(group.groupID) ?: return@mapNotNull null + group.takeIf { isValid(it, storedKey) } + } catch (_: Exception) { + null + } + } + } + + private fun keyName(groupID: ByteArray): String = + "groupKey-${groupID.hexEncodedString()}" + + private fun BitchatGroup.deepCopy(): BitchatGroup = copy( + groupID = groupID.copyOf(), + members = members.map { it.deepCopy() } + ) + + private fun GroupMember.deepCopy(): GroupMember = + copy(signingKey = signingKey.copyOf()) + + companion object { + private const val TAG = "GroupStore" + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 9a6dff60..ca00150c 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -546,6 +546,26 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) { delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs) } + + override fun onGroupInviteReceived( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + payload: ByteArray + ) { + delegate?.didReceiveGroupInvite(peerID, authenticatedRemoteStaticKey, payload) + } + + override fun onGroupKeyUpdateReceived( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + payload: ByteArray + ) { + delegate?.didReceiveGroupKeyUpdate(peerID, authenticatedRemoteStaticKey, payload) + } + + override fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) { + delegate?.didReceiveGroupMessage(payload, timestampMs) + } } // PacketProcessor delegates @@ -634,6 +654,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } } catch (_: Exception) { } } + + override fun handleGroupMessage(routed: RoutedPacket) { + messageHandler.handleGroupMessage(routed) + try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } + } override fun handleLeave(routed: RoutedPacket) { serviceScope.launch { messageHandler.handleLeave(routed) } @@ -1192,6 +1217,42 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic sendNoisePayloadToPeer(payload, peerID, "verify response") } + fun sendGroupInvite(payload: ByteArray, recipientPeerID: String) { + sendNoisePayloadToPeer( + NoisePayload(NoisePayloadType.GROUP_INVITE, payload), + recipientPeerID, + "group invite" + ) + } + + fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String) { + sendNoisePayloadToPeer( + NoisePayload(NoisePayloadType.GROUP_KEY_UPDATE, payload), + recipientPeerID, + "group key update" + ) + } + + fun broadcastGroupMessage(payload: ByteArray) { + if (payload.isEmpty()) return + serviceScope.launch { + val packet = BitchatPacket( + version = if (payload.size > 0xffff) 2u else 1u, + type = MessageType.GROUP_MESSAGE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + signature = null, + ttl = MAX_TTL + ) + // The outer packet is intentionally unsigned. Authenticity is + // verified from the Ed25519 signature inside the ciphertext. + broadcastRoutedPacket(RoutedPacket(packet)) + try { gossipSyncManager.onPublicPacketSeen(packet) } catch (_: Exception) { } + } + } + private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String, label: String) { serviceScope.launch { try { @@ -1455,6 +1516,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic fun getStaticNoisePublicKey(): ByteArray? { return encryptionService.getStaticPublicKey() } + + fun getSigningPublicKey(): ByteArray? = + encryptionService.getSigningPublicKey()?.copyOf() + + fun signData(data: ByteArray): ByteArray? = + encryptionService.signData(data) /** * Check if encryption icon should be shown for a peer diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index 375e6531..eb49eb0b 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -418,6 +418,26 @@ class MeshCore( override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) { delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs) } + + override fun onGroupInviteReceived( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + payload: ByteArray + ) { + delegate?.didReceiveGroupInvite(peerID, authenticatedRemoteStaticKey, payload) + } + + override fun onGroupKeyUpdateReceived( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + payload: ByteArray + ) { + delegate?.didReceiveGroupKeyUpdate(peerID, authenticatedRemoteStaticKey, payload) + } + + override fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) { + delegate?.didReceiveGroupMessage(payload, timestampMs) + } } packetProcessor.delegate = object : PacketProcessorDelegate { @@ -468,6 +488,11 @@ class MeshCore( } catch (_: Exception) { } } + override fun handleGroupMessage(routed: RoutedPacket) { + messageHandler.handleGroupMessage(routed) + try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } + } + override fun handleLeave(routed: RoutedPacket) { scope.launch { messageHandler.handleLeave(routed) } } @@ -722,6 +747,38 @@ class MeshCore( sendNoisePayloadToPeer(payload, peerID) } + fun sendGroupInvite(payload: ByteArray, recipientPeerID: String) { + sendNoisePayloadToPeer( + NoisePayload(NoisePayloadType.GROUP_INVITE, payload), + recipientPeerID + ) + } + + fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String) { + sendNoisePayloadToPeer( + NoisePayload(NoisePayloadType.GROUP_KEY_UPDATE, payload), + recipientPeerID + ) + } + + fun broadcastGroupMessage(payload: ByteArray) { + if (payload.isEmpty()) return + scope.launch { + val packet = BitchatPacket( + version = if (payload.size > 0xffff) 2u else 1u, + type = MessageType.GROUP_MESSAGE.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = SpecialRecipients.BROADCAST, + timestamp = System.currentTimeMillis().toULong(), + payload = payload, + signature = null, + ttl = maxTtl + ) + dispatchGlobal(RoutedPacket(packet)) + try { gossipSyncManager.onPublicPacketSeen(packet) } catch (_: Exception) { } + } + } + private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String) { scope.launch { try { @@ -952,6 +1009,12 @@ class MeshCore( fun getStaticNoisePublicKey(): ByteArray? = encryptionService.getStaticPublicKey() + fun getSigningPublicKey(): ByteArray? = + encryptionService.getSigningPublicKey()?.copyOf() + + fun signData(data: ByteArray): ByteArray? = + encryptionService.signData(data) + fun shouldShowEncryptionIcon(peerID: String): Boolean = encryptionService.hasEstablishedSession(peerID) fun getEncryptedPeers(): List = emptyList() diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt index b54b1152..ebd3b74a 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt @@ -13,6 +13,17 @@ interface MeshDelegate { fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {} fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {} + fun didReceiveGroupInvite( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + payload: ByteArray + ) {} + fun didReceiveGroupKeyUpdate( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + payload: ByteArray + ) {} + fun didReceiveGroupMessage(payload: ByteArray, timestampMs: Long) {} /** Current Noise generation either proved peer state or exhausted its 5-second watchdog. */ fun didResolvePrivateMediaPolicy(peerID: String) {} fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt index 063fe4f8..20fd4e32 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt @@ -19,6 +19,9 @@ interface MeshService { fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {} fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) + fun sendGroupInvite(payload: ByteArray, recipientPeerID: String) + fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String) + fun broadcastGroupMessage(payload: ByteArray) fun sendFileBroadcast(file: BitchatFilePacket) fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) fun prepareFilePrivate( @@ -49,6 +52,8 @@ interface MeshService { ): Boolean fun getIdentityFingerprint(): String fun getStaticNoisePublicKey(): ByteArray? + fun getSigningPublicKey(): ByteArray? + fun signData(data: ByteArray): ByteArray? fun shouldShowEncryptionIcon(peerID: String): Boolean fun getEncryptedPeers(): List diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index beedd857..d4a268e6 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -185,12 +185,33 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro Log.d(TAG, "🔐 Verify response received from $peerID (${noisePayload.data.size} bytes)") delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong()) } + com.bitchat.android.model.NoisePayloadType.GROUP_INVITE -> { + delegate?.onGroupInviteReceived( + peerID, + decryption.authenticatedSession.remoteStaticKey.copyOf(), + noisePayload.data + ) + } + com.bitchat.android.model.NoisePayloadType.GROUP_KEY_UPDATE -> { + delegate?.onGroupKeyUpdateReceived( + peerID, + decryption.authenticatedSession.remoteStaticKey.copyOf(), + noisePayload.data + ) + } } } catch (e: Exception) { Log.e(TAG, "Error processing Noise encrypted message from $peerID: ${e.message}") } } + + fun handleGroupMessage(routed: RoutedPacket) { + delegate?.onGroupMessageReceived( + routed.packet.payload, + routed.packet.timestamp.toLong() + ) + } /** * Send delivery ACK for a received private message - exactly like iOS @@ -681,4 +702,15 @@ interface MessageHandlerDelegate { fun onReadReceiptReceived(messageID: String, peerID: String) fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) + fun onGroupInviteReceived( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + payload: ByteArray + ) {} + fun onGroupKeyUpdateReceived( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + payload: ByteArray + ) {} + fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) {} } diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt index 0fdae3b0..2d666ad5 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -146,6 +146,7 @@ class PacketProcessor(private val myPeerID: String) { MessageType.ANNOUNCE -> validPacket = handleAnnounce(routed) MessageType.MESSAGE -> handleMessage(routed) MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler + MessageType.GROUP_MESSAGE -> handleGroupMessage(routed) MessageType.LEAVE -> handleLeave(routed) MessageType.FRAGMENT -> handleFragment(routed) MessageType.REQUEST_SYNC -> handleRequestSync(routed) @@ -211,6 +212,12 @@ class PacketProcessor(private val myPeerID: String) { Log.d(TAG, "Processing message from ${formatPeerForLog(peerID)}") delegate?.handleMessage(routed) } + + private fun handleGroupMessage(routed: RoutedPacket) { + val peerID = routed.peerID ?: "unknown" + Log.d(TAG, "Processing private-group message from ${formatPeerForLog(peerID)}") + delegate?.handleGroupMessage(routed) + } /** * Handle leave message @@ -323,6 +330,7 @@ interface PacketProcessorDelegate { fun handleNoiseEncrypted(routed: RoutedPacket) suspend fun handleAnnounce(routed: RoutedPacket): Boolean fun handleMessage(routed: RoutedPacket) + fun handleGroupMessage(routed: RoutedPacket) {} fun handleLeave(routed: RoutedPacket) fun handleFragment(packet: BitchatPacket): BitchatPacket? fun handleRequestSync(routed: RoutedPacket) diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index ca49def9..846a55a8 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -113,6 +113,30 @@ class UnifiedMeshService( } } + override fun sendGroupInvite(payload: ByteArray, recipientPeerID: String) { + when { + isBleReady(recipientPeerID) -> bluetooth.sendGroupInvite(payload, recipientPeerID) + isWifiReady(recipientPeerID) -> + wifiService()?.sendGroupInvite(payload, recipientPeerID) + } + } + + override fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String) { + when { + isBleReady(recipientPeerID) -> + bluetooth.sendGroupKeyUpdate(payload, recipientPeerID) + isWifiReady(recipientPeerID) -> + wifiService()?.sendGroupKeyUpdate(payload, recipientPeerID) + } + } + + override fun broadcastGroupMessage(payload: ByteArray) { + when { + isBleEnabled() -> bluetooth.broadcastGroupMessage(payload) + else -> wifiService()?.broadcastGroupMessage(payload) + } + } + override fun sendFileBroadcast(file: BitchatFilePacket) { when { isBleEnabled() -> bluetooth.sendFileBroadcast(file) @@ -273,6 +297,12 @@ class UnifiedMeshService( return bluetooth.getStaticNoisePublicKey() ?: wifiService()?.getStaticNoisePublicKey() } + override fun getSigningPublicKey(): ByteArray? = + bluetooth.getSigningPublicKey() ?: wifiService()?.getSigningPublicKey() + + override fun signData(data: ByteArray): ByteArray? = + bluetooth.signData(data) ?: wifiService()?.signData(data) + override fun shouldShowEncryptionIcon(peerID: String): Boolean { return hasEstablishedSession(peerID) } @@ -359,6 +389,26 @@ class UnifiedMeshService( delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs) } + override fun didReceiveGroupInvite( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + payload: ByteArray + ) { + delegate?.didReceiveGroupInvite(peerID, authenticatedRemoteStaticKey, payload) + } + + override fun didReceiveGroupKeyUpdate( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + payload: ByteArray + ) { + delegate?.didReceiveGroupKeyUpdate(peerID, authenticatedRemoteStaticKey, payload) + } + + override fun didReceiveGroupMessage(payload: ByteArray, timestampMs: Long) { + delegate?.didReceiveGroupMessage(payload, timestampMs) + } + override fun didResolvePrivateMediaPolicy(peerID: String) { delegate?.didResolvePrivateMediaPolicy(peerID) } diff --git a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt index cba647a7..461c0035 100644 --- a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt +++ b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt @@ -21,6 +21,8 @@ enum class NoisePayloadType(val value: UByte) { PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding READ_RECEIPT(0x02u), // Message was read DELIVERED(0x03u), // Message was delivered + GROUP_INVITE(0x06u), // Creator-signed private-group state + GROUP_KEY_UPDATE(0x07u), // Creator-signed roster/key rotation VERIFY_CHALLENGE(0x10u), // Verification challenge VERIFY_RESPONSE(0x11u), // Verification response FILE_TRANSFER(0x20u), diff --git a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt index d53f3d20..c64b79c5 100644 --- a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt +++ b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt @@ -29,11 +29,14 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable { companion object { val NONE = PeerCapabilities(0) + /** Creator-managed encrypted private groups (iOS capability bit 3). */ + val GROUPS = PeerCapabilities(1L shl 3) + /** Noise-encrypted private BitchatFilePacket using payload type 0x20. */ val PRIVATE_MEDIA = PeerCapabilities(1L shl 8) /** Capabilities implemented by this Android build. */ - val LOCAL_SUPPORTED = PRIVATE_MEDIA + val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or GROUPS.rawValue) /** * Decode the low 64 bits and ignore any future extension bytes, which diff --git a/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt b/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt index ab52f071..c4dba01c 100644 --- a/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt +++ b/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt @@ -1,6 +1,7 @@ package com.bitchat.android.model import com.bitchat.android.sync.SyncDefaults +import com.bitchat.android.sync.SyncTypeFlags /** * REQUEST_SYNC payload using GCS (Golomb-Coded Set) parameters. @@ -12,7 +13,8 @@ import com.bitchat.android.sync.SyncDefaults data class RequestSyncPacket( val p: Int, val m: Long, - val data: ByteArray + val data: ByteArray, + val types: SyncTypeFlags? = null ) { fun encode(): ByteArray { val out = ArrayList() @@ -38,6 +40,7 @@ data class RequestSyncPacket( ) // data putTLV(0x03, data) + types?.encoded()?.let { putTLV(0x04, it) } return out.toByteArray() } @@ -50,6 +53,7 @@ data class RequestSyncPacket( var p: Int? = null var m: Long? = null var payload: ByteArray? = null + var types: SyncTypeFlags? = null while (off + 3 <= data.size) { val t = (data[off].toInt() and 0xFF); off += 1 @@ -69,6 +73,9 @@ data class RequestSyncPacket( if (v.size > MAX_ACCEPT_FILTER_BYTES) return null payload = v } + 0x04 -> { + SyncTypeFlags.decode(v)?.let { types = it } + } } } @@ -76,7 +83,7 @@ data class RequestSyncPacket( val mm = m ?: return null val dd = payload ?: return null if (pp < 1 || mm <= 0L) return null - return RequestSyncPacket(pp, mm, dd) + return RequestSyncPacket(pp, mm, dd, types) } } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 9d8ae8af..c1a38c2f 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -217,6 +217,8 @@ class NostrDirectMessageHandler( } NoisePayloadType.VERIFY_CHALLENGE, NoisePayloadType.VERIFY_RESPONSE, + NoisePayloadType.GROUP_INVITE, + NoisePayloadType.GROUP_KEY_UPDATE, NoisePayloadType.PEER_STATE -> Unit // Peer state is bound to a live mesh Noise generation. } } diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index a952d5fa..6f63fa18 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -17,7 +17,8 @@ enum class MessageType(val value: UByte) { NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message FRAGMENT(0x20u), // Fragmentation for large packets REQUEST_SYNC(0x21u), // GCS-based sync request - FILE_TRANSFER(0x22u); // New: File transfer packet (BLE voice notes, etc.) + FILE_TRANSFER(0x22u), // New: File transfer packet (BLE voice notes, etc.) + GROUP_MESSAGE(0x25u); // Opaque private-group ciphertext broadcast companion object { fun fromValue(value: UByte): MessageType? { diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index c7971eb8..602f78ea 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -107,6 +107,19 @@ object AppStateStore { } } + fun removePrivateConversation(peerID: String) { + synchronized(this) { + val conversationID = ContactDirectory.canonicalConversationId(peerID) + val map = _privateMessages.value.toMutableMap() + val removedPeer = map.remove(peerID) != null + val removedConversation = map.remove(conversationID) != null + val changed = removedPeer || removedConversation + if (changed) { + _privateMessages.value = map + } + } + } + private fun statusPriority(status: DeliveryStatus?): Int = when (status) { null -> 0 is DeliveryStatus.Sending -> 1 diff --git a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt index 5c786365..922e02cb 100644 --- a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt +++ b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt @@ -33,6 +33,7 @@ class GossipSyncManager( companion object { private const val TAG = "GossipSyncManager" + private const val GROUP_MESSAGE_CAPACITY = 200 } var delegate: Delegate? = null @@ -44,6 +45,9 @@ class GossipSyncManager( // Stored packets for sync: // - broadcast messages: keep up to seenCapacity() most recent, keyed by packetId private val messages = LinkedHashMap() + // Opaque group ciphertexts are kept separately so sync filters can + // request them without mixing their IDs into public-message rounds. + private val groupMessages = LinkedHashMap() // - announcements: only keep latest per sender peerID private val latestAnnouncementByPeer = ConcurrentHashMap>() @@ -94,23 +98,33 @@ class GossipSyncManager( } fun onPublicPacketSeen(packet: BitchatPacket) { - // Only ANNOUNCE or broadcast MESSAGE val mt = MessageType.fromValue(packet.type) val isBroadcastMessage = (mt == MessageType.MESSAGE && (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))) + val isGroupMessage = (mt == MessageType.GROUP_MESSAGE && + (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))) val isAnnouncement = (mt == MessageType.ANNOUNCE) - if (!isBroadcastMessage && !isAnnouncement) return + if (!isBroadcastMessage && !isGroupMessage && !isAnnouncement) return val idBytes = PacketIdUtil.computeIdBytes(packet) val id = idBytes.joinToString("") { b -> "%02x".format(b) } - if (isBroadcastMessage) { - synchronized(messages) { - messages[id] = packet - // Enforce capacity (remove oldest when exceeded) - val cap = configProvider.seenCapacity().coerceAtLeast(1) - while (messages.size > cap) { - val it = messages.entries.iterator() - if (it.hasNext()) { it.next(); it.remove() } else break + if (isBroadcastMessage || isGroupMessage) { + val store = if (isGroupMessage) groupMessages else messages + synchronized(store) { + store[id] = packet + val cap = if (isGroupMessage) { + GROUP_MESSAGE_CAPACITY + } else { + configProvider.seenCapacity().coerceAtLeast(1) + } + while (store.size > cap) { + val iterator = store.entries.iterator() + if (iterator.hasNext()) { + iterator.next() + iterator.remove() + } else { + break + } } } } else if (isAnnouncement) { @@ -134,7 +148,11 @@ class GossipSyncManager( } private fun sendRequestSync() { - val payload = buildGcsPayload() + sendRequestSync(SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.GROUP_MESSAGE)) + } + + private fun sendRequestSync(types: SyncTypeFlags) { + val payload = buildGcsPayload(types) val packet = BitchatPacket( type = MessageType.REQUEST_SYNC.value, @@ -149,7 +167,14 @@ class GossipSyncManager( } private fun sendRequestSyncToPeer(peerID: String) { - val payload = buildGcsPayload() + sendRequestSyncToPeer( + peerID, + SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.GROUP_MESSAGE) + ) + } + + private fun sendRequestSyncToPeer(peerID: String, types: SyncTypeFlags) { + val payload = buildGcsPayload(types) val packet = BitchatPacket( type = MessageType.REQUEST_SYNC.value, @@ -174,26 +199,43 @@ class GossipSyncManager( return GCSFilter.contains(sorted, nonZeroV) } - // 1) Announcements: send latest per peerID if remote doesn't have them - for ((_, pair) in latestAnnouncementByPeer.entries) { - val (id, pkt) = pair - val idBytes = hexToBytes(id) - if (!mightContain(idBytes)) { - // Send original packet unchanged to requester only (keep local TTL) - val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) - delegate?.sendPacketToPeer(fromPeerID, toSend) - Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}") + val requestedTypes = request.types ?: SyncTypeFlags.PUBLIC_MESSAGES + + if (requestedTypes.contains(MessageType.ANNOUNCE)) { + for ((_, pair) in latestAnnouncementByPeer.entries) { + val (id, pkt) = pair + val idBytes = hexToBytes(id) + if (!mightContain(idBytes)) { + val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) + delegate?.sendPacketToPeer(fromPeerID, toSend) + Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}") + } } } - // 2) Broadcast messages: send all they lack - val toSendMsgs = synchronized(messages) { messages.values.toList() } - for (pkt in toSendMsgs) { - val idBytes = PacketIdUtil.computeIdBytes(pkt) + if (requestedTypes.contains(MessageType.MESSAGE)) { + sendMissingPackets(fromPeerID, synchronized(messages) { messages.values.toList() }, ::mightContain) + } + if (requestedTypes.contains(MessageType.GROUP_MESSAGE)) { + sendMissingPackets( + fromPeerID, + synchronized(groupMessages) { groupMessages.values.toList() }, + ::mightContain + ) + } + } + + private fun sendMissingPackets( + peerID: String, + packets: List, + mightContain: (ByteArray) -> Boolean + ) { + packets.forEach { packet -> + val idBytes = PacketIdUtil.computeIdBytes(packet) if (!mightContain(idBytes)) { - val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) - delegate?.sendPacketToPeer(fromPeerID, toSend) - Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}") + val toSend = packet.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) + delegate?.sendPacketToPeer(peerID, toSend) + Log.d(TAG, "Sent sync message: Type ${toSend.type} to $peerID packet id ${idBytes.toHexString()}") } } } @@ -223,16 +265,16 @@ class GossipSyncManager( return out } - private fun buildGcsPayload(): ByteArray { - // Collect candidates: latest announcement per peer + recent broadcast messages + private fun buildGcsPayload(types: SyncTypeFlags): ByteArray { val list = ArrayList() - // announcements - for ((_, pair) in latestAnnouncementByPeer) { - list.add(pair.second) + if (types.contains(MessageType.ANNOUNCE)) { + latestAnnouncementByPeer.values.forEach { list += it.second } } - // messages - synchronized(messages) { - list.addAll(messages.values) + if (types.contains(MessageType.MESSAGE)) { + synchronized(messages) { list.addAll(messages.values) } + } + if (types.contains(MessageType.GROUP_MESSAGE)) { + synchronized(groupMessages) { list.addAll(groupMessages.values) } } // sort by timestamp desc, then take up to min(seenCapacity, fit capacity) list.sortByDescending { it.timestamp.toLong() } @@ -245,12 +287,22 @@ class GossipSyncManager( val takeN = minOf(nMax, cap, list.size) if (takeN <= 0) { val p0 = GCSFilter.deriveP(fpr) - return RequestSyncPacket(p = p0, m = 1, data = ByteArray(0)).encode() + return RequestSyncPacket( + p = p0, + m = 1, + data = ByteArray(0), + types = types + ).encode() } val ids = list.take(takeN).map { pkt -> PacketIdUtil.computeIdBytes(pkt) } val params = GCSFilter.buildFilter(ids, maxBytes, fpr) val mVal = if (params.m <= 0L) 1 else params.m - return RequestSyncPacket(p = params.p, m = mVal, data = params.data).encode() + return RequestSyncPacket( + p = params.p, + m = mVal, + data = params.data, + types = types + ).encode() } // Periodically remove stale announcements and all their messages @@ -306,6 +358,15 @@ class GossipSyncManager( } } } + + synchronized(groupMessages) { + for ((id, message) in groupMessages) { + val sender = message.senderID.joinToString("") { b -> "%02x".format(b) } + if (sender == key) { + idsToRemove.add(id) + } + } + } // Now remove the collected IDs synchronized(messages) { @@ -313,6 +374,11 @@ class GossipSyncManager( messages.remove(id) } } + synchronized(groupMessages) { + for (id in idsToRemove) { + groupMessages.remove(id) + } + } if (idsToRemove.isNotEmpty()) { Log.d(TAG, "Pruned ${idsToRemove.size} messages with senders without announcements") diff --git a/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt b/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt new file mode 100644 index 00000000..e5a6fa0f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt @@ -0,0 +1,77 @@ +package com.bitchat.android.sync + +import com.bitchat.android.protocol.MessageType + +/** + * Packet types covered by one REQUEST_SYNC filter. + * + * The wire format matches iOS: a minimal little-endian bitfield. Unknown bits + * are discarded because they do not map to a packet type this build can serve. + */ +class SyncTypeFlags private constructor(val rawValue: Long) { + fun contains(type: MessageType): Boolean { + val bit = bitIndex(type) ?: return false + return (rawValue and (1L shl bit)) != 0L + } + + fun union(other: SyncTypeFlags): SyncTypeFlags = + fromRawValue(rawValue or other.rawValue) + + fun encoded(): ByteArray? { + if (rawValue == 0L) return null + var remaining = rawValue + val bytes = mutableListOf() + while (remaining != 0L && bytes.size < Long.SIZE_BYTES) { + bytes += remaining.toByte() + remaining = remaining ushr 8 + } + return bytes.toByteArray() + } + + override fun equals(other: Any?): Boolean = + other is SyncTypeFlags && rawValue == other.rawValue + + override fun hashCode(): Int = rawValue.hashCode() + + companion object { + val PUBLIC_MESSAGES = of(MessageType.ANNOUNCE, MessageType.MESSAGE) + val GROUP_MESSAGE = of(MessageType.GROUP_MESSAGE) + + fun of(vararg types: MessageType): SyncTypeFlags { + var raw = 0L + types.forEach { type -> + bitIndex(type)?.let { raw = raw or (1L shl it) } + } + return fromRawValue(raw) + } + + fun decode(data: ByteArray): SyncTypeFlags? { + if (data.size !in 1..Long.SIZE_BYTES) return null + var raw = 0L + data.forEachIndexed { index, byte -> + raw = raw or ((byte.toLong() and 0xffL) shl (index * 8)) + } + return fromRawValue(raw) + } + + private fun fromRawValue(rawValue: Long): SyncTypeFlags { + var knownMask = 0L + MessageType.entries.forEach { type -> + bitIndex(type)?.let { knownMask = knownMask or (1L shl it) } + } + return SyncTypeFlags(rawValue and knownMask) + } + + private fun bitIndex(type: MessageType): Int? = when (type) { + MessageType.ANNOUNCE -> 0 + MessageType.MESSAGE -> 1 + MessageType.LEAVE -> 2 + MessageType.NOISE_HANDSHAKE -> 3 + MessageType.NOISE_ENCRYPTED -> 4 + MessageType.FRAGMENT -> 5 + MessageType.REQUEST_SYNC -> 6 + MessageType.FILE_TRANSFER -> 7 + MessageType.GROUP_MESSAGE -> 10 + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 4eb32739..fc720fd5 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -30,6 +30,13 @@ import com.bitchat.android.noise.NoiseSession import com.bitchat.android.services.ContactDirectory import com.bitchat.android.services.ContactIdentityResolver import com.bitchat.android.util.hexEncodedString +import com.bitchat.android.groups.BitchatGroup +import com.bitchat.android.groups.GroupCommandResult +import com.bitchat.android.groups.GroupCoordinator +import com.bitchat.android.groups.GroupCoordinatorContext +import com.bitchat.android.groups.GroupIds +import com.bitchat.android.groups.GroupPeerIdentity +import com.bitchat.android.groups.GroupStore /** * Refactored ChatViewModel - Main coordinator for bitchat functionality @@ -51,6 +58,8 @@ class ChatViewModel( companion object { private const val TAG = "ChatViewModel" + private const val GROUP_COMMAND_USAGE = + "usage: /group create · invite @name · remove @name · leave · list" } fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { @@ -101,6 +110,8 @@ class ChatViewModel( private val identityManager by lazy { SecureIdentityStateManager(getApplication()) } private val messageManager = MessageManager(state) private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope) + private val groupStore = GroupStore(application.applicationContext) + val groups: StateFlow> = groupStore.groups // Create Noise session delegate for clean dependency injection private val noiseSessionDelegate = object : NoiseSessionDelegate { @@ -117,6 +128,143 @@ class ChatViewModel( NotificationIntervalManager() ) + private val groupCoordinator = GroupCoordinator(object : GroupCoordinatorContext { + override val groupStore: GroupStore + get() = this@ChatViewModel.groupStore + override val nickname: String + get() = state.getNicknameValue() + override val myPeerID: String + get() = mesh.myPeerID + override val selectedConversationID: String? + get() = state.getSelectedPrivateChatPeerValue() + + override fun myNoiseFingerprint(): String = mesh.getIdentityFingerprint() + override fun mySigningPublicKey(): ByteArray? = mesh.getSigningPublicKey() + override fun sign(data: ByteArray): ByteArray? = mesh.signData(data) + + override fun peerIDForNickname(nickname: String): String? = + mesh.getPeerNicknames().entries.firstOrNull { + it.value.equals(nickname, ignoreCase = true) + }?.key + + override fun isPeerConnected(peerID: String): Boolean = + mesh.getPeerInfo(peerID)?.isConnected == true && mesh.hasEstablishedSession(peerID) + + override fun peerNickname(peerID: String): String? = + mesh.getPeerNicknames()[peerID] + + override fun peerIdentity(peerID: String): GroupPeerIdentity? { + val info = mesh.getPeerInfo(peerID) ?: return null + if (info.signingPublicKey?.size != 32) return null + val liveFingerprint = mesh.getPeerFingerprint(peerID) ?: return null + val announcedNoiseKey = info.noisePublicKey ?: return null + val announcedFingerprint = ContactIdentityResolver.fingerprintHex(announcedNoiseKey) + if (!liveFingerprint.equals(announcedFingerprint, ignoreCase = true)) return null + return GroupPeerIdentity( + liveFingerprint.lowercase(), + info.signingPublicKey!!.copyOf() + ) + } + + override fun connectedPeerID(fingerprint: String): String? = + mesh.getPeerNicknames().keys.firstOrNull { peerID -> + mesh.getPeerInfo(peerID)?.isConnected == true && + mesh.hasEstablishedSession(peerID) && + mesh.getPeerFingerprint(peerID).equals(fingerprint, ignoreCase = true) + } + + override fun isFingerprintBlocked(fingerprint: String): Boolean = + dataManager.isUserBlocked(fingerprint) + + override fun sendGroupInvite(payload: ByteArray, peerID: String) { + mesh.sendGroupInvite(payload, peerID) + } + + override fun sendGroupKeyUpdate(payload: ByteArray, peerID: String) { + mesh.sendGroupKeyUpdate(payload, peerID) + } + + override fun broadcastGroupMessage(payload: ByteArray) { + mesh.broadcastGroupMessage(payload) + } + + override fun appendGroupMessage( + groupPeerID: String, + message: BitchatMessage + ): Boolean { + val existing = state.getPrivateChatsValue()[groupPeerID].orEmpty() + if (existing.any { it.id == message.id }) return false + messageManager.addPrivateMessage(groupPeerID, message) + if (state.getSelectedPrivateChatPeerValue() == groupPeerID) { + try { + com.bitchat.android.services.SeenMessageStore + .getInstance(getApplication()) + .markRead(message.id) + } catch (_: Exception) { + } + } + return true + } + + override fun markGroupUnread(groupPeerID: String) { + state.setUnreadPrivateMessages( + state.getUnreadPrivateMessagesValue() + groupPeerID + ) + } + + override fun removeGroupConversation(groupPeerID: String) { + messageManager.removePrivateChat(groupPeerID) + } + + override fun openGroupConversation(groupPeerID: String) { + privateChatManager.startPrivateChat(groupPeerID, mesh) + showPrivateChatSheet(groupPeerID) + } + + override fun closeGroupConversation() { + endPrivateChat() + } + + override fun addSystemMessage(message: String) { + messageManager.addSystemMessage(message) + } + + override fun addGroupSystemMessage(groupPeerID: String, message: String) { + messageManager.addPrivateMessage( + groupPeerID, + BitchatMessage( + sender = "system", + content = message, + timestamp = Date(), + isPrivate = true, + recipientNickname = groupStore.group(groupPeerID)?.name + ) + ) + } + + override fun notifyGroupMessage( + groupPeerID: String, + sender: String, + message: String + ) { + notificationManager.showPrivateMessageNotification(groupPeerID, sender, message) + } + }) + + fun handleGroupCommand(arguments: List): GroupCommandResult { + val subcommand = arguments.firstOrNull()?.lowercase() + ?: return GroupCommandResult(false, GROUP_COMMAND_USAGE) + val value = arguments.drop(1).joinToString(" ") + return when (subcommand) { + "create" -> groupCoordinator.createGroup(value) + "invite" -> groupCoordinator.inviteMember(value) + "remove" -> groupCoordinator.removeMember(value) + "leave" -> groupCoordinator.leaveGroup() + "list" -> groupCoordinator.listGroups() + else -> GroupCommandResult(false, GROUP_COMMAND_USAGE) + } + } + private val verificationHandler = VerificationHandler( context = application.applicationContext, scope = viewModelScope, @@ -529,6 +677,10 @@ class ChatViewModel( val currentChannelValue = state.getCurrentChannelValue() if (selectedPeer != null) { + if (GroupIds.isGroup(selectedPeer)) { + groupCoordinator.sendMessage(content, selectedPeer) + return + } // If the selected peer is a temporary Nostr alias or a noise-hex identity, resolve to a canonical target selectedPeer = ContactDirectory.canonicalConversationId( com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( @@ -938,6 +1090,26 @@ class ChatViewModel( override fun didResolvePrivateMediaPolicy(peerID: String) { mediaSendingManager.retryPendingPrivateMedia(peerID) } + + override fun didReceiveGroupInvite( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + payload: ByteArray + ) { + groupCoordinator.handleInvite(peerID, authenticatedRemoteStaticKey, payload) + } + + override fun didReceiveGroupKeyUpdate( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + payload: ByteArray + ) { + groupCoordinator.handleKeyUpdate(peerID, authenticatedRemoteStaticKey, payload) + } + + override fun didReceiveGroupMessage(payload: ByteArray, timestampMs: Long) { + groupCoordinator.handleMessage(payload, timestampMs) + } override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel) @@ -964,6 +1136,11 @@ class ChatViewModel( messageManager.clearAllMessages() channelManager.clearAllChannels() privateChatManager.clearAllPrivateChats() + try { + com.bitchat.android.services.AppStateStore.clear() + } catch (_: Exception) { + } + groupStore.wipe() dataManager.clearAllData() // Clear seen message store diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index a92d2d60..1983b04c 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -1,5 +1,6 @@ package com.bitchat.android.ui +import com.bitchat.android.geohash.ChannelID import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import java.util.Date @@ -19,6 +20,7 @@ class CommandProcessor( CommandSuggestion("/block", emptyList(), "[nickname]", "block or list blocked peers"), CommandSuggestion("/channels", emptyList(), null, "show all discovered channels"), CommandSuggestion("/clear", emptyList(), null, "clear chat messages"), + CommandSuggestion("/group", emptyList(), "", "manage private groups"), CommandSuggestion("/hug", emptyList(), "", "send someone a warm hug"), CommandSuggestion("/j", listOf("/join"), "", "join or create a channel"), CommandSuggestion("/m", listOf("/msg"), " [message]", "send private message"), @@ -45,11 +47,49 @@ class CommandProcessor( "/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage, viewModel) "/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage, viewModel) "/channels" -> handleChannelsCommand() + "/group" -> handleGroupCommand(parts, viewModel) else -> handleUnknownCommand(cmd) } return true } + + private fun handleGroupCommand(parts: List, viewModel: ChatViewModel?) { + if (viewModel == null) { + addCommandOutput("private groups are unavailable") + return + } + val selectedPeer = state.getSelectedPrivateChatPeerValue() + if (viewModel.selectedLocationChannel.value is ChannelID.Location || + selectedPeer?.startsWith("nostr_") == true || + selectedPeer?.startsWith("nostr:") == true + ) { + addCommandOutput("groups are only for mesh peers in #mesh") + return + } + + val result = viewModel.handleGroupCommand( + parts.drop(1).filter(String::isNotBlank) + ) + addCommandOutput(result.message) + } + + private fun addCommandOutput(message: String) { + val destination = state.getSelectedPrivateChatPeerValue() + if (destination != null) { + messageManager.addPrivateMessage( + destination, + BitchatMessage( + sender = "system", + content = message, + timestamp = Date(), + isPrivate = true + ) + ) + } else { + messageManager.addSystemMessage(message) + } + } private fun handleJoinCommand(parts: List, myPeerID: String) { if (parts.size > 1) { @@ -403,7 +443,17 @@ class CommandProcessor( emptyList() } - return baseCommands + channelCommands + val selectedPeer = state.getSelectedPrivateChatPeerValue() + val isGeohashContext = + state.selectedLocationChannel.value is ChannelID.Location || + selectedPeer?.startsWith("nostr_") == true || + selectedPeer?.startsWith("nostr:") == true + val contextualBase = if (isGeohashContext) { + baseCommands.filterNot { it.command == "/group" } + } else { + baseCommands + } + return contextualBase + channelCommands } private fun filterCommands(commands: List, input: String): List { diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index ec137437..32cce662 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -20,6 +20,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -34,6 +36,8 @@ import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.favorites.FavoriteRelationship import com.bitchat.android.favorites.FavoritesPersistenceService import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.groups.BitchatGroup +import com.bitchat.android.groups.GroupIds import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.ui.theme.BASE_FONT_SIZE import com.bitchat.android.nostr.GeohashAliasRegistry @@ -65,6 +69,8 @@ fun MeshPeerListSheet( val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() val nickname by viewModel.nickname.collectAsStateWithLifecycle() val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle() + val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() + val groups by viewModel.groups.collectAsStateWithLifecycle() val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() @@ -182,6 +188,21 @@ fun MeshPeerListSheet( } } } + + if (selectedLocationChannel !is ChannelID.Location && groups.isNotEmpty()) { + item(key = "groups_section") { + GroupSection( + groups = groups, + myFingerprint = viewModel.getMyFingerprint(), + unreadConversationIDs = unreadPrivateMessages, + colorScheme = colorScheme, + onGroupClick = { groupPeerID -> + viewModel.showPrivateChatSheet(groupPeerID) + onDismiss() + } + ) + } + } } // TopBar (animated) @@ -213,6 +234,103 @@ fun MeshPeerListSheet( } } +@Composable +private fun GroupSection( + groups: List, + myFingerprint: String, + unreadConversationIDs: Set, + colorScheme: ColorScheme, + onGroupClick: (String) -> Unit +) { + Column(modifier = Modifier.padding(top = 16.dp)) { + Text( + text = stringResource(R.string.groups).uppercase(), + style = MaterialTheme.typography.labelLarge, + color = colorScheme.onSurface.copy(alpha = 0.7f), + fontWeight = FontWeight.Bold, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 8.dp, bottom = 4.dp) + ) + + groups.forEach { group -> + val isCreator = group.creatorFingerprint.equals(myFingerprint, ignoreCase = true) + val hasUnread = group.peerID in unreadConversationIDs + val memberCountLabel = stringResource(R.string.group_member_count, group.members.size) + val creatorLabel = stringResource(R.string.group_creator) + val unreadLabel = stringResource(R.string.cd_unread_private_messages) + val accessibilityDescription = buildList { + add(group.name) + add(memberCountLabel) + if (isCreator) add(creatorLabel) + if (hasUnread) add(unreadLabel) + }.joinToString() + + Surface( + onClick = { onGroupClick(group.peerID) }, + color = Color.Transparent, + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 2.dp) + .semantics(mergeDescendants = true) { + contentDescription = accessibilityDescription + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 48.dp) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Filled.Groups, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = colorScheme.primary + ) + Text( + text = "#${group.name}", + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace + ), + color = colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = memberCountLabel, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + color = colorScheme.onSurfaceVariant + ) + if (isCreator) { + Icon( + imageVector = Icons.Filled.WorkspacePremium, + contentDescription = creatorLabel, + modifier = Modifier.size(14.dp), + tint = Color(0xFFFFD700) + ) + } + Spacer(modifier = Modifier.weight(1f)) + if (hasUnread) { + Icon( + imageVector = Icons.Filled.Mail, + contentDescription = unreadLabel, + modifier = Modifier.size(16.dp), + tint = Color(0xFFFF9500) + ) + } + } + } + } + } +} + @Composable private fun ChannelRow( channel: String, @@ -762,6 +880,9 @@ fun PrivateChatSheet( val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle() val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() + val groups by viewModel.groups.collectAsStateWithLifecycle() + val group = remember(peerID, groups) { groups.firstOrNull { it.peerID == peerID } } + val isGroupConversation = GroupIds.isGroup(peerID) val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle() @@ -788,6 +909,7 @@ fun PrivateChatSheet( val isConnected = activeMeshPeerID?.let { connectedPeers.contains(it) } == true || connectedPeers.contains(peerID) || isDirect val isNostrReachableFavorite = !isConnected && favoriteRelationship?.isMutual == true && favoriteRelationship.peerNostrPublicKey != null + val privateGroupLabel = stringResource(R.string.private_group) // Compute display name and title text reactively val displayName = remember(peerID, peerNicknames, favoriteRelationship) { @@ -797,8 +919,10 @@ fun PrivateChatSheet( ?: favoriteRelationship?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } ?: viewModel.resolvePeerDisplayNameForFingerprint(peerID) } - val titleText = remember(peerID, peerNicknames, favoriteRelationship) { - if (isNostrPeer) { + val titleText = remember(peerID, peerNicknames, favoriteRelationship, group) { + if (isGroupConversation) { + group?.let { "#${it.name} (${it.members.size})" } ?: privateGroupLabel + } else if (isNostrPeer) { val gh = GeohashConversationRegistry.get(peerID) ?: "geohash" val fullPubkey = GeohashAliasRegistry.get(peerID) ?: "" val name = if (fullPubkey.isNotEmpty()) { @@ -812,21 +936,28 @@ fun PrivateChatSheet( } } - val conversationID = contactResolution.conversationID + val conversationID = if (isGroupConversation) peerID else contactResolution.conversationID val messages = privateChats[conversationID] ?: privateChats[peerID] ?: emptyList() val sessionState = activeMeshPeerID?.let { peerSessionStates[it] } ?: peerSessionStates[peerID] val fingerprint = activeMeshPeerID?.let { peerFingerprints[it] } ?: peerFingerprints[peerID] ?: ContactIdentityResolver.fingerprintFromContactConversationId(peerID) - val isFavorite = remember(favoritePeers, fingerprint, peerID, favoriteRelationship) { - if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID) + val isFavorite = remember( + favoritePeers, + fingerprint, + peerID, + favoriteRelationship, + isGroupConversation + ) { + !isGroupConversation && + if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID) } val isVerified = remember(peerID, verifiedFingerprints) { viewModel.isPeerVerified(peerID, verifiedFingerprints) } - val securityModifier = if (!isNostrPeer && !isNostrReachableFavorite) { + val securityModifier = if (!isGroupConversation && !isNostrPeer && !isNostrReachableFavorite) { Modifier.clickable { viewModel.showSecurityVerificationSheet() } } else { Modifier @@ -869,6 +1000,30 @@ fun PrivateChatSheet( HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) // Input section + if (isGroupConversation) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .semantics(mergeDescendants = true) {}, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = Color(0xFFFF9500) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(R.string.group_encryption_caption), + style = MaterialTheme.typography.labelSmall, + color = Color(0xFFFF9500) + ) + } + } + var messageText by remember { mutableStateOf( androidx.compose.ui.text.input.TextFieldValue( @@ -909,7 +1064,7 @@ fun PrivateChatSheet( currentChannel = null, nickname = nickname, colorScheme = colorScheme, - showMediaButtons = true + showMediaButtons = !isGroupConversation ) } @@ -940,6 +1095,14 @@ fun PrivateChatSheet( horizontalArrangement = Arrangement.spacedBy(6.dp) ) { when { + isGroupConversation -> { + Icon( + imageVector = Icons.Filled.Groups, + contentDescription = stringResource(R.string.cd_group_chat), + modifier = Modifier.size(16.dp), + tint = colorScheme.primary + ) + } isNostrPeer || isNostrReachableFavorite -> { Icon( imageVector = Icons.Filled.Public, @@ -987,14 +1150,14 @@ fun PrivateChatSheet( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.then(securityModifier) ) { - if (!isNostrPeer && !isNostrReachableFavorite) { + if (!isGroupConversation && !isNostrPeer && !isNostrReachableFavorite) { NoiseSessionIcon( sessionState = sessionState, modifier = Modifier.size(14.dp) ) } - if (isVerified) { + if (!isGroupConversation && isVerified) { Spacer(modifier = Modifier.width(4.dp)) Icon( imageVector = Icons.Filled.Verified, @@ -1005,16 +1168,18 @@ fun PrivateChatSheet( } } - IconButton( - onClick = { viewModel.toggleFavorite(peerID) }, - modifier = Modifier.size(28.dp) - ) { - Icon( - imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, - contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite), - modifier = Modifier.size(16.dp), - tint = if (isFavorite) Color(0xFFFFD700) else colorScheme.onSurface.copy(alpha = 0.6f) - ) + if (!isGroupConversation) { + IconButton( + onClick = { viewModel.toggleFavorite(peerID) }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, + contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite), + modifier = Modifier.size(16.dp), + tint = if (isFavorite) Color(0xFFFFD700) else colorScheme.onSurface.copy(alpha = 0.6f) + ) + } } } } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index e93ef39a..89a930a6 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -143,6 +143,22 @@ class MessageManager(private val state: ChatState) { updatedChats[conversationID] = emptyList() state.setPrivateChats(updatedChats) } + + fun removePrivateChat(peerID: String) { + val conversationID = ContactDirectory.canonicalConversationId(peerID) + val updatedChats = state.getPrivateChatsValue().toMutableMap() + updatedChats.remove(peerID) + updatedChats.remove(conversationID) + state.setPrivateChats(updatedChats) + val unread = state.getUnreadPrivateMessagesValue().toMutableSet() + unread.remove(peerID) + unread.remove(conversationID) + state.setUnreadPrivateMessages(unread) + try { + com.bitchat.android.services.AppStateStore.removePrivateConversation(conversationID) + } catch (_: Exception) { + } + } fun initializePrivateChat(peerID: String) { val conversationID = ContactDirectory.canonicalConversationId(peerID) diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index 1af48a04..c6fefc8b 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -1,6 +1,7 @@ package com.bitchat.android.ui import com.bitchat.android.favorites.FavoritesPersistenceService +import com.bitchat.android.groups.GroupIds import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.mesh.PeerFingerprintManager @@ -375,6 +376,11 @@ class PrivateChatManager( meshService: MeshService ) { val canonicalConversationID = ContactDirectory.canonicalConversationId(conversationID) + if (GroupIds.isGroup(canonicalConversationID)) { + unreadReceivedMessages.remove(canonicalConversationID) + messageManager.clearPrivateUnreadMessages(canonicalConversationID) + return + } // Collect candidate messages: all incoming messages from this peer in the conversation val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap>() } diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index eab371e7..9906018e 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -1525,6 +1525,18 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor meshCore.sendVerifyResponse(peerID, noiseKeyHex, nonceA) } + override fun sendGroupInvite(payload: ByteArray, recipientPeerID: String) { + meshCore.sendGroupInvite(payload, recipientPeerID) + } + + override fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String) { + meshCore.sendGroupKeyUpdate(payload, recipientPeerID) + } + + override fun broadcastGroupMessage(payload: ByteArray) { + meshCore.broadcastGroupMessage(payload) + } + /** * Broadcasts a file (TLV payload) to all peers. Uses protocol version 2 to support * large payloads and generates a deterministic transferId (sha256 of payload) for UI/state. @@ -1641,6 +1653,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor override fun getStaticNoisePublicKey(): ByteArray? = meshCore.getStaticNoisePublicKey() + override fun getSigningPublicKey(): ByteArray? = meshCore.getSigningPublicKey() + + override fun signData(data: ByteArray): ByteArray? = meshCore.signData(data) + /** * @return true if the UI should show an “encrypted” indicator for this peer. */ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ece95a7..2d510a30 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,6 +14,12 @@ Back People Channels + Groups + Private group + %1$d members + Creator + Encrypted to group members + Private group chat Online Users No one connected Triple tap to clear all data diff --git a/app/src/test/kotlin/com/bitchat/android/groups/GroupCoordinatorTest.kt b/app/src/test/kotlin/com/bitchat/android/groups/GroupCoordinatorTest.kt new file mode 100644 index 00000000..679d7520 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/groups/GroupCoordinatorTest.kt @@ -0,0 +1,330 @@ +package com.bitchat.android.groups + +import com.bitchat.android.model.BitchatMessage +import java.security.MessageDigest +import java.util.Date +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GroupCoordinatorTest { + @Test + fun `creator invite rotates epoch and sends creator-signed state`() { + val localKey = privateKey(0x11) + val inviteeKey = privateKey(0x22) + val context = FakeGroupContext(localKey, "11".repeat(32)) + context.peerIDs["alice"] = "22".repeat(8) + context.connected += "22".repeat(8) + context.peerNames["22".repeat(8)] = "alice" + context.identities["22".repeat(8)] = GroupPeerIdentity( + "22".repeat(32), + inviteeKey.generatePublicKey().encoded + ) + + val created = GroupCoordinator(context).createGroup("trail crew") + assertTrue(created.success) + val original = context.groupStore.groups.value.single() + val originalKey = context.groupStore.key(original.groupID)!! + + val result = GroupCoordinator(context).inviteMember("@alice") + assertTrue(result.success) + val updated = context.groupStore.group(original.groupID)!! + val updatedKey = context.groupStore.key(updated.groupID)!! + assertEquals(2, updated.epoch) + assertEquals(2, updated.members.size) + assertFalse(originalKey.contentEquals(updatedKey)) + assertEquals(1, context.invites.size) + + val state = GroupStatePayload.decode(context.invites.single().second)!! + assertTrue(state.verifyCreatorSignature()) + assertEquals(updated, state.asGroup()) + assertArrayEquals(updatedKey, state.key) + } + + @Test + fun `invite is accepted only from authenticated creator`() { + val creatorKey = privateKey(0x31) + val localKey = privateKey(0x32) + val creatorStatic = ByteArray(32) { 0x41 } + val creatorFingerprint = fingerprint(creatorStatic) + val localFingerprint = "52".repeat(32) + val context = FakeGroupContext(localKey, localFingerprint) + val group = incomingGroup( + creatorKey, + creatorFingerprint, + localKey, + localFingerprint + ) + val payload = signedState(group, creatorKey, ByteArray(32) { 0x61 }) + val coordinator = GroupCoordinator(context) + + coordinator.handleInvite("creator", ByteArray(32) { 0x7f }, payload) + assertTrue(context.groupStore.groups.value.isEmpty()) + + coordinator.handleInvite("creator", creatorStatic, payload) + assertEquals(group, context.groupStore.groups.value.single()) + assertTrue(group.peerID in context.unread) + assertEquals(1, context.notifications.size) + } + + @Test + fun `creator removal state drops key conversation and membership`() { + val creatorKey = privateKey(0x71) + val localKey = privateKey(0x72) + val creatorStatic = ByteArray(32) { 0x73 } + val creatorFingerprint = fingerprint(creatorStatic) + val localFingerprint = "74".repeat(32) + val context = FakeGroupContext(localKey, localFingerprint) + val original = incomingGroup( + creatorKey, + creatorFingerprint, + localKey, + localFingerprint + ) + assertTrue(context.groupStore.upsert(original, ByteArray(32) { 0x75 })) + context.selected = original.peerID + + val removedState = original.copy( + epoch = original.epoch + 1, + members = listOf(original.members.first()) + ) + val payload = signedState(removedState, creatorKey, ByteArray(32)) + GroupCoordinator(context).handleKeyUpdate("creator", creatorStatic, payload) + + assertNull(context.groupStore.group(original.groupID)) + assertNull(context.groupStore.key(original.groupID)) + assertTrue(original.peerID in context.removedConversations) + assertNull(context.selected) + assertTrue(context.systemMessages.single().contains("removed")) + } + + @Test + fun `group message requires a roster sender and deduplicates`() { + val creatorKey = privateKey(0x21) + val localKey = privateKey(0x22) + val creatorFingerprint = "81".repeat(32) + val localFingerprint = "82".repeat(32) + val context = FakeGroupContext(localKey, localFingerprint) + val group = incomingGroup( + creatorKey, + creatorFingerprint, + localKey, + localFingerprint + ) + val key = ByteArray(32) { 0x33 } + assertTrue(context.groupStore.upsert(group, key)) + val coordinator = GroupCoordinator(context) + val payload = sealedMessage(group, key, creatorKey, "message-1", "hello") + + coordinator.handleMessage(payload, System.currentTimeMillis()) + coordinator.handleMessage(payload, System.currentTimeMillis()) + + val messages = context.messages[group.peerID].orEmpty() + assertEquals(1, messages.size) + assertEquals("hello", messages.single().content) + assertEquals("creator", messages.single().sender) + assertTrue(group.peerID in context.unread) + assertEquals(1, context.notifications.size) + + context.blocked += creatorFingerprint + coordinator.handleMessage( + sealedMessage(group, key, creatorKey, "message-2", "blocked"), + System.currentTimeMillis() + ) + assertEquals(1, context.messages[group.peerID].orEmpty().size) + } + + private fun incomingGroup( + creatorKey: Ed25519PrivateKeyParameters, + creatorFingerprint: String, + localKey: Ed25519PrivateKeyParameters, + localFingerprint: String + ) = BitchatGroup( + groupID = ByteArray(16) { it.toByte() }, + name = "ops", + epoch = 1, + members = listOf( + GroupMember( + creatorFingerprint, + creatorKey.generatePublicKey().encoded, + "creator" + ), + GroupMember( + localFingerprint, + localKey.generatePublicKey().encoded, + "local" + ) + ), + creatorFingerprint = creatorFingerprint + ) + + private fun signedState( + group: BitchatGroup, + creatorKey: Ed25519PrivateKeyParameters, + key: ByteArray + ): ByteArray = + GroupStatePayload.makeSigned(group, key) { sign(creatorKey, it) } + ?.encode() + ?: error("state should encode") + + private fun sealedMessage( + group: BitchatGroup, + key: ByteArray, + senderKey: Ed25519PrivateKeyParameters, + messageID: String, + content: String + ): ByteArray = GroupCrypto.sealMessage( + content = content, + messageID = messageID, + senderNickname = "untrusted nickname", + senderSigningKey = senderKey.generatePublicKey().encoded, + timestampMs = System.currentTimeMillis(), + groupID = group.groupID, + epoch = group.epoch, + key = key + ) { sign(senderKey, it) } + + private fun privateKey(seed: Int) = + Ed25519PrivateKeyParameters(ByteArray(32) { seed.toByte() }, 0) + + private fun sign( + privateKey: Ed25519PrivateKeyParameters, + data: ByteArray + ): ByteArray { + val signer = Ed25519Signer() + signer.init(true, privateKey) + signer.update(data, 0, data.size) + return signer.generateSignature() + } + + private fun fingerprint(noiseKey: ByteArray): String = + MessageDigest.getInstance("SHA-256") + .digest(noiseKey) + .joinToString("") { "%02x".format(it) } +} + +private class FakeGroupContext( + private val localKey: Ed25519PrivateKeyParameters, + private val localFingerprint: String +) : GroupCoordinatorContext { + override val groupStore = GroupStore(TestGroupKeys(), testOnly = true) + override val nickname = "local" + override val myPeerID = localFingerprint.take(16) + override val selectedConversationID: String? + get() = selected + + var selected: String? = null + val peerIDs = mutableMapOf() + val connected = mutableSetOf() + val peerNames = mutableMapOf() + val identities = mutableMapOf() + val connectedFingerprints = mutableMapOf() + val blocked = mutableSetOf() + val invites = mutableListOf>() + val updates = mutableListOf>() + val broadcasts = mutableListOf() + val messages = mutableMapOf>() + val unread = mutableSetOf() + val removedConversations = mutableSetOf() + val systemMessages = mutableListOf() + val notifications = mutableListOf>() + + override fun myNoiseFingerprint() = localFingerprint + override fun mySigningPublicKey(): ByteArray = localKey.generatePublicKey().encoded + override fun sign(data: ByteArray): ByteArray { + val signer = Ed25519Signer() + signer.init(true, localKey) + signer.update(data, 0, data.size) + return signer.generateSignature() + } + + override fun peerIDForNickname(nickname: String) = peerIDs[nickname] + override fun isPeerConnected(peerID: String) = peerID in connected + override fun peerNickname(peerID: String) = peerNames[peerID] + override fun peerIdentity(peerID: String) = identities[peerID] + override fun connectedPeerID(fingerprint: String) = connectedFingerprints[fingerprint] + override fun isFingerprintBlocked(fingerprint: String) = fingerprint in blocked + + override fun sendGroupInvite(payload: ByteArray, peerID: String) { + invites += peerID to payload + } + + override fun sendGroupKeyUpdate(payload: ByteArray, peerID: String) { + updates += peerID to payload + } + + override fun broadcastGroupMessage(payload: ByteArray) { + broadcasts += payload + } + + override fun appendGroupMessage( + groupPeerID: String, + message: BitchatMessage + ): Boolean { + val conversation = messages.getOrPut(groupPeerID) { mutableListOf() } + if (conversation.any { it.id == message.id }) return false + conversation += message + return true + } + + override fun markGroupUnread(groupPeerID: String) { + unread += groupPeerID + } + + override fun removeGroupConversation(groupPeerID: String) { + removedConversations += groupPeerID + messages.remove(groupPeerID) + unread.remove(groupPeerID) + } + + override fun openGroupConversation(groupPeerID: String) { + selected = groupPeerID + } + + override fun closeGroupConversation() { + selected = null + } + + override fun addSystemMessage(message: String) { + systemMessages += message + } + + override fun addGroupSystemMessage(groupPeerID: String, message: String) { + appendGroupMessage( + groupPeerID, + BitchatMessage( + sender = "system", + content = message, + timestamp = Date(), + isPrivate = true + ) + ) + } + + override fun notifyGroupMessage( + groupPeerID: String, + sender: String, + message: String + ) { + notifications += Triple(groupPeerID, sender, message) + } +} + +private class TestGroupKeys : GroupKeyStorage { + private val values = mutableMapOf() + + override fun get(key: String): ByteArray? = values[key]?.copyOf() + + override fun put(key: String, value: ByteArray): Boolean { + values[key] = value.copyOf() + return true + } + + override fun remove(key: String): Boolean = values.remove(key) != null +} diff --git a/app/src/test/kotlin/com/bitchat/android/groups/GroupProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/groups/GroupProtocolTest.kt new file mode 100644 index 00000000..97933236 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/groups/GroupProtocolTest.kt @@ -0,0 +1,263 @@ +package com.bitchat.android.groups + +import java.security.MessageDigest +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class GroupProtocolTest { + private val creatorPrivate = Ed25519PrivateKeyParameters(ByteArray(32) { 0x11 }, 0) + private val memberPrivate = Ed25519PrivateKeyParameters(ByteArray(32) { 0x22 }, 0) + private val creator = member(creatorPrivate, "creator", 0x31) + private val member = member(memberPrivate, "alice", 0x41) + private val group = BitchatGroup( + groupID = ByteArray(16) { it.toByte() }, + name = "hike", + epoch = 7, + members = listOf(creator, member), + creatorFingerprint = creator.fingerprint + ) + private val groupKey = ByteArray(32) { (it + 1).toByte() } + + @Test + fun `creator-signed state round trips and verifies`() { + val payload = GroupStatePayload.makeSigned(group, groupKey) { sign(creatorPrivate, it) }!! + val decoded = GroupStatePayload.decode(payload.encode()!!)!! + + assertEquals(payload, decoded) + assertTrue(decoded.verifyCreatorSignature()) + assertEquals(group, decoded.asGroup()) + } + + @Test + fun `creator signature covers name epoch key and roster`() { + val payload = GroupStatePayload.makeSigned(group, groupKey) { sign(creatorPrivate, it) }!! + + assertFalse(copyState(payload, name = "ops").verifyCreatorSignature()) + assertFalse(copyState(payload, epoch = payload.epoch + 1).verifyCreatorSignature()) + assertFalse(copyState(payload, key = ByteArray(32) { 9 }).verifyCreatorSignature()) + assertFalse( + copyState( + payload, + members = payload.members.map { + if (it == member) it.copy(nickname = "mallory") else it + } + ).verifyCreatorSignature() + ) + } + + @Test + fun `state rejects creator missing from roster and oversized roster`() { + val noCreator = copyState( + GroupStatePayload.makeSigned(group, groupKey) { sign(creatorPrivate, it) }!!, + members = listOf(member) + ) + assertFalse(noCreator.verifyCreatorSignature()) + + val tooMany = List(BitchatGroup.MAX_MEMBERS + 1) { index -> + member(memberPrivate, "m$index", index) + } + assertNull(GroupRosterCoding.encode(tooMany)) + } + + @Test + fun `roster nickname truncation remains valid UTF-8`() { + val longNickname = "€".repeat(40) + val encoded = GroupRosterCoding.encode(listOf(creator.copy(nickname = longNickname)))!! + val decoded = GroupRosterCoding.decode(encoded)!! + + assertTrue(decoded.single().nickname.toByteArray(Charsets.UTF_8).size <= 64) + assertTrue(decoded.single().nickname.all { it != '\uFFFD' }) + } + + @Test + fun `group message seals opens and verifies sender`() { + val encoded = GroupCrypto.sealMessage( + content = "meet at the ridge", + messageID = "message-1", + senderNickname = member.nickname, + senderSigningKey = member.signingKey, + timestampMs = 1_725_000_000_123, + groupID = group.groupID, + epoch = group.epoch, + key = groupKey + ) { sign(memberPrivate, it) } + + val envelope = GroupMessageEnvelope.decode(encoded)!! + val opened = GroupCrypto.openMessage(envelope, groupKey) + + assertEquals("message-1", opened.messageID) + assertEquals("meet at the ridge", opened.content) + assertEquals(member.nickname, opened.senderNickname) + assertArrayEquals(member.signingKey, opened.senderSigningKey) + } + + @Test + fun `message cannot move between keys groups or epochs`() { + val encoded = sealedMessage() + val envelope = GroupMessageEnvelope.decode(encoded)!! + + assertThrows(GroupCryptoException.DecryptionFailed::class.java) { + GroupCrypto.openMessage(envelope, ByteArray(32) { 0x7f }) + } + assertThrows(GroupCryptoException.DecryptionFailed::class.java) { + GroupCrypto.openMessage( + GroupMessageEnvelope( + envelope.groupID, + envelope.epoch + 1, + envelope.nonce, + envelope.ciphertext + ), + groupKey + ) + } + assertThrows(GroupCryptoException.DecryptionFailed::class.java) { + GroupCrypto.openMessage( + GroupMessageEnvelope( + ByteArray(16) { 0x55 }, + envelope.epoch, + envelope.nonce, + envelope.ciphertext + ), + groupKey + ) + } + } + + @Test + fun `bad sender signature is rejected after valid AEAD`() { + val encoded = GroupCrypto.sealMessage( + content = "forged", + messageID = "message-2", + senderNickname = member.nickname, + senderSigningKey = member.signingKey, + timestampMs = 42, + groupID = group.groupID, + epoch = group.epoch, + key = groupKey + ) { ByteArray(64) } + + assertThrows(GroupCryptoException.BadSenderSignature::class.java) { + GroupCrypto.openMessage(GroupMessageEnvelope.decode(encoded)!!, groupKey) + } + } + + @Test + fun `malformed envelopes and state are rejected`() { + assertNull(GroupMessageEnvelope.decode(byteArrayOf(1, 0))) + assertNull(GroupStatePayload.decode(byteArrayOf(1, 0))) + + val envelope = GroupMessageEnvelope.decode(sealedMessage())!! + val tampered = envelope.ciphertext.copyOf().also { + it[it.lastIndex] = (it.last().toInt() xor 1).toByte() + } + assertThrows(GroupCryptoException.DecryptionFailed::class.java) { + GroupCrypto.openMessage( + GroupMessageEnvelope(envelope.groupID, envelope.epoch, envelope.nonce, tampered), + groupKey + ) + } + } + + @Test + fun `group IDs use iOS virtual conversation form`() { + val peerID = GroupIds.peerID(group.groupID) + assertEquals("group_000102030405060708090a0b0c0d0e0f", peerID) + assertTrue(GroupIds.isGroup(peerID)) + assertArrayEquals(group.groupID, GroupIds.groupID(peerID)) + assertNull(GroupIds.groupID("group_not-hex")) + } + + @Test + fun `message signing content covers epoch`() { + val first = GroupCrypto.messageSigningContent(group.groupID, 1, "id", 9, "hello") + val second = GroupCrypto.messageSigningContent(group.groupID, 2, "id", 9, "hello") + assertFalse(MessageDigest.isEqual(first, second)) + } + + @Test + fun `opens deterministic vectors emitted by iOS CryptoKit`() { + val state = GroupStatePayload.decode(IOS_STATE_VECTOR.hexBytes()) + assertNotNull(state) + assertEquals("hike", state!!.name) + assertEquals(7, state.epoch) + assertTrue(state.verifyCreatorSignature()) + + val plaintext = GroupCrypto.openMessage( + GroupMessageEnvelope.decode(IOS_MESSAGE_VECTOR.hexBytes())!!, + ByteArray(32) { (it + 1).toByte() } + ) + assertEquals("ios-vector", plaintext.messageID) + assertEquals("alice", plaintext.senderNickname) + assertEquals("meet at ridge", plaintext.content) + assertEquals(1_725_000_000_123, plaintext.timestampMs) + assertArrayEquals(IOS_MEMBER_PUBLIC_KEY.hexBytes(), plaintext.senderSigningKey) + } + + private fun sealedMessage(): ByteArray = GroupCrypto.sealMessage( + content = "hello", + messageID = "message", + senderNickname = member.nickname, + senderSigningKey = member.signingKey, + timestampMs = 1234, + groupID = group.groupID, + epoch = group.epoch, + key = groupKey + ) { sign(memberPrivate, it) } + + private fun member( + privateKey: Ed25519PrivateKeyParameters, + nickname: String, + fingerprintSeed: Int + ): GroupMember = GroupMember( + fingerprint = ByteArray(32) { fingerprintSeed.toByte() } + .joinToString("") { "%02x".format(it) }, + signingKey = privateKey.generatePublicKey().encoded, + nickname = nickname + ) + + private fun sign(privateKey: Ed25519PrivateKeyParameters, data: ByteArray): ByteArray { + val signer = Ed25519Signer() + signer.init(true, privateKey) + signer.update(data, 0, data.size) + return signer.generateSignature() + } + + private fun copyState( + source: GroupStatePayload, + groupID: ByteArray = source.groupID, + name: String = source.name, + key: ByteArray = source.key, + epoch: Long = source.epoch, + members: List = source.members, + creatorFingerprint: String = source.creatorFingerprint, + signature: ByteArray = source.signature + ): GroupStatePayload = GroupStatePayload( + groupID, + name, + key, + epoch, + members, + creatorFingerprint, + signature + ) + + private fun String.hexBytes(): ByteArray = + chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + companion object { + private const val IOS_MEMBER_PUBLIC_KEY = + "a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0" + private const val IOS_STATE_VECTOR = + "010010000102030405060708090a0b0c0d0e0f02000468696b650300200102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f200400040000000705008f023131313131313131313131313131313131313131313131313131313131313131d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c97787370763726561746f724141414141414141414141414141414141414141414141414141414141414141a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f005616c69636506002031313131313131313131313131313131313131313131313131313131313131310700407e7dcb5210e48a2edc346f1364c0e3f3a7939a3006f318bd591e94f11b7c6d5a928042f04278bf2e5d612da02258acd686d11a8f8f310b2cc0f8b886bb13ac05" + private const val IOS_MESSAGE_VECTOR = + "010010000102030405060708090a0b0c0d0e0f0200040000000703000c000102030405060708090a0b0400a6e1abbfb19fd03920bbd627b82b5d575bd8ec48fc57c70d8f7f7c3af33375ae8ac1ed189e8e17acbe8f4c77cda97e44b8084234d2d8e7825ddcfdb492ed01a4eba676a9c28fcae940b33a80a756f27af8758e6dfca33c4184cd5fa2b82fe527de32c79c9a52d8ddcd596c7e3dcc29003184adf571489a8a9a9d0b7301cdf85b45b4521e55aebf6238031af50c81d6dd639b6210e82c0ab70e19ba4d1b04c323c70f6e09b54585" + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/groups/GroupStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/groups/GroupStoreTest.kt new file mode 100644 index 00000000..46f3ea96 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/groups/GroupStoreTest.kt @@ -0,0 +1,113 @@ +package com.bitchat.android.groups + +import java.io.File +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class GroupStoreTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `create read rotate and remove`() { + val keys = MemoryGroupKeys() + val store = GroupStore(keys, testOnly = true) + val group = store.createGroup("hike", creator())!! + val originalKey = store.key(group.groupID)!! + + assertEquals(1, group.epoch) + assertEquals(group, store.group(group.groupID)) + assertArrayEquals(originalKey, store.key(group.groupID)) + + val newMember = GroupMember("22".repeat(32), ByteArray(32) { 0x33 }, "alice") + val (rotated, newKey) = store.rotateKey(group.groupID, listOf(creator(), newMember))!! + + assertEquals(2, rotated.epoch) + assertEquals(2, rotated.members.size) + assertFalse(originalKey.contentEquals(newKey)) + assertArrayEquals(newKey, store.key(group.groupID)) + + store.removeGroup(group.groupID) + assertNull(store.group(group.groupID)) + assertNull(store.key(group.groupID)) + } + + @Test + fun `persistence reloads metadata only when key survives`() { + val keys = MemoryGroupKeys() + val file = File(temporaryFolder.root, "groups.json") + val first = GroupStore(keys, file, testOnly = true) + val group = first.createGroup("ops", creator())!! + + val reloaded = GroupStore(keys, file, testOnly = true) + assertEquals(listOf(group), reloaded.groups.value) + + keys.remove("groupKey-${group.groupID.joinToString("") { "%02x".format(it) }}") + val withoutKey = GroupStore(keys, file, testOnly = true) + assertTrue(withoutKey.groups.value.isEmpty()) + } + + @Test + fun `creator and roster cap are enforced`() { + val store = GroupStore(MemoryGroupKeys(), testOnly = true) + val missingCreator = BitchatGroup( + groupID = ByteArray(16), + name = "bad", + epoch = 1, + members = listOf(GroupMember("22".repeat(32), ByteArray(32), "member")), + creatorFingerprint = "11".repeat(32) + ) + assertFalse(store.upsert(missingCreator, ByteArray(32))) + + val tooMany = BitchatGroup( + groupID = ByteArray(16), + name = "full", + epoch = 1, + members = List(17) { index -> + GroupMember("%02x".format(index).repeat(32), ByteArray(32) { index.toByte() }, "m$index") + }, + creatorFingerprint = "00".repeat(32) + ) + assertFalse(store.upsert(tooMany, ByteArray(32))) + } + + @Test + fun `panic wipe clears keys metadata and memory`() { + val keys = MemoryGroupKeys() + val file = File(temporaryFolder.root, "groups.json") + val store = GroupStore(keys, file, testOnly = true) + val group = store.createGroup("gone", creator())!! + assertTrue(file.exists()) + assertNotNull(store.key(group.groupID)) + + store.wipe() + + assertTrue(store.groups.value.isEmpty()) + assertNull(store.key(group.groupID)) + assertFalse(file.exists()) + } + + private fun creator() = + GroupMember("11".repeat(32), ByteArray(32) { 0x44 }, "creator") +} + +private class MemoryGroupKeys : GroupKeyStorage { + private val values = mutableMapOf() + + override fun get(key: String): ByteArray? = values[key]?.copyOf() + + override fun put(key: String, value: ByteArray): Boolean { + values[key] = value.copyOf() + return true + } + + override fun remove(key: String): Boolean = values.remove(key) != null +} diff --git a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt index 78d72bee..0a0f4aeb 100644 --- a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt @@ -59,17 +59,15 @@ class IdentityAnnouncementTest { } @Test - fun `local announcement send advertises private media`() { + fun `local announcement send advertises private media and groups`() { val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!! assertArrayEquals( - byteArrayOf(0x05, 0x02, 0x00, 0x01), + byteArrayOf(0x05, 0x02, 0x08, 0x01), encoded.takeLast(4).toByteArray() ) - assertTrue( - IdentityAnnouncement.decode(encoded)!! - .capabilities!! - .contains(PeerCapabilities.PRIVATE_MEDIA) - ) + val capabilities = IdentityAnnouncement.decode(encoded)!!.capabilities!! + assertTrue(capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) + assertTrue(capabilities.contains(PeerCapabilities.GROUPS)) } } diff --git a/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncGroupTest.kt b/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncGroupTest.kt new file mode 100644 index 00000000..c73cba0d --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncGroupTest.kt @@ -0,0 +1,116 @@ +package com.bitchat.android.sync + +import com.bitchat.android.model.RequestSyncPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import com.bitchat.android.protocol.SpecialRecipients +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GossipSyncGroupTest { + @Test + fun `typed group request serves only opaque group packets`() { + val fixture = fixture() + val publicPacket = packet(MessageType.MESSAGE, byteArrayOf(1)) + val groupPacket = packet(MessageType.GROUP_MESSAGE, byteArrayOf(2)) + fixture.manager.onPublicPacketSeen(publicPacket) + fixture.manager.onPublicPacketSeen(groupPacket) + + fixture.manager.handleRequestSync( + PEER_ID, + RequestSyncPacket( + p = 5, + m = 1, + data = ByteArray(0), + types = SyncTypeFlags.GROUP_MESSAGE + ) + ) + + assertEquals(listOf(MessageType.GROUP_MESSAGE), fixture.delegate.sentTypes) + fixture.scope.cancel() + } + + @Test + fun `initial request combines public and group sync bits`() { + val fixture = fixture() + fixture.manager.scheduleInitialSyncToPeer(PEER_ID, delayMs = 0) + + assertTrue(fixture.delegate.requestLatch.await(2, TimeUnit.SECONDS)) + val request = RequestSyncPacket.decode(fixture.delegate.lastRequestPayload!!)!! + val types = requireNotNull(request.types) + assertTrue(types.contains(MessageType.ANNOUNCE)) + assertTrue(types.contains(MessageType.MESSAGE)) + assertTrue(types.contains(MessageType.GROUP_MESSAGE)) + fixture.scope.cancel() + } + + private fun fixture(): Fixture { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val manager = GossipSyncManager( + myPeerID = MY_ID, + scope = scope, + configProvider = object : GossipSyncManager.ConfigProvider { + override fun seenCapacity() = 100 + override fun gcsMaxBytes() = 400 + override fun gcsTargetFpr() = 0.01 + } + ) + val delegate = RecordingSyncDelegate() + manager.delegate = delegate + return Fixture(scope, manager, delegate) + } + + private fun packet(type: MessageType, payload: ByteArray) = BitchatPacket( + type = type.value, + senderID = ByteArray(8) { 0x11 }, + recipientID = SpecialRecipients.BROADCAST, + timestamp = 1u, + payload = payload, + ttl = 1u + ) + + private data class Fixture( + val scope: CoroutineScope, + val manager: GossipSyncManager, + val delegate: RecordingSyncDelegate + ) + + companion object { + private const val MY_ID = "0011223344556677" + private const val PEER_ID = "8899aabbccddeeff" + } +} + +private class RecordingSyncDelegate : GossipSyncManager.Delegate { + val sentTypes = mutableListOf() + val requestLatch = CountDownLatch(1) + @Volatile + var lastRequestPayload: ByteArray? = null + + override fun sendPacket(packet: BitchatPacket) { + record(packet) + } + + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + record(packet) + } + + override fun signPacketForBroadcast(packet: BitchatPacket) = packet + + private fun record(packet: BitchatPacket) { + val type = MessageType.fromValue(packet.type) ?: return + if (type == MessageType.REQUEST_SYNC) { + lastRequestPayload = packet.payload + requestLatch.countDown() + } else { + sentTypes += type + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/sync/SyncTypeFlagsGroupTest.kt b/app/src/test/kotlin/com/bitchat/android/sync/SyncTypeFlagsGroupTest.kt new file mode 100644 index 00000000..fe42e1d0 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/sync/SyncTypeFlagsGroupTest.kt @@ -0,0 +1,38 @@ +package com.bitchat.android.sync + +import com.bitchat.android.model.RequestSyncPacket +import com.bitchat.android.protocol.MessageType +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SyncTypeFlagsGroupTest { + @Test + fun `group bit ten widens little endian flags to two bytes`() { + assertArrayEquals( + byteArrayOf(0x00, 0x04), + SyncTypeFlags.GROUP_MESSAGE.encoded() + ) + assertTrue(SyncTypeFlags.decode(byteArrayOf(0x00, 0x04))!!.contains(MessageType.GROUP_MESSAGE)) + } + + @Test + fun `unknown sync bits are ignored`() { + val decoded = SyncTypeFlags.decode(byteArrayOf(0x00, 0x0c))!! + assertTrue(decoded.contains(MessageType.GROUP_MESSAGE)) + assertArrayEquals(byteArrayOf(0x00, 0x04), decoded.encoded()) + } + + @Test + fun `request sync round trips group types and legacy omission`() { + val typed = RequestSyncPacket(5, 100, byteArrayOf(1, 2), SyncTypeFlags.GROUP_MESSAGE) + val decoded = RequestSyncPacket.decode(typed.encode())!! + val types = requireNotNull(decoded.types) + assertTrue(types.contains(MessageType.GROUP_MESSAGE)) + assertFalse(types.contains(MessageType.MESSAGE)) + + val legacy = RequestSyncPacket(5, 100, byteArrayOf(1, 2)) + assertTrue(RequestSyncPacket.decode(legacy.encode())!!.types == null) + } +}