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..7e9fadaa --- /dev/null +++ b/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt @@ -0,0 +1,695 @@ +package com.bitchat.android.groups + +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.model.PeerCapabilities +import java.util.ArrayDeque +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 +) + +enum class PeerGroupCapability { + SUPPORTED, + UNSUPPORTED, + UNKNOWN; + + companion object { + fun fromPeerState( + capabilities: PeerCapabilities?, + hasVerifiedAnnouncement: Boolean + ): PeerGroupCapability = when { + capabilities?.contains(PeerCapabilities.GROUPS) == true -> SUPPORTED + capabilities != null || hasVerifiedAnnouncement -> UNSUPPORTED + else -> UNKNOWN + } + } +} + +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 peerIDsForNickname(nickname: String): List + fun isPeerConnected(peerID: String): Boolean + fun peerGroupCapability(peerID: String): PeerGroupCapability + 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) { + private data class MemberSelector( + val nickname: String, + val identitySuffix: String? + ) + + private sealed class PendingEvent { + data class Invite( + val peerID: String, + val authenticatedRemoteStaticKey: ByteArray, + val payload: ByteArray + ) : PendingEvent() + + data class KeyUpdate( + val peerID: String, + val authenticatedRemoteStaticKey: ByteArray, + val payload: ByteArray + ) : PendingEvent() + + data class Message( + val payload: ByteArray, + val receivedAtMs: Long + ) : PendingEvent() + + data class PeerAuthenticated(val peerID: String) : PendingEvent() + } + + private data class FutureMessage( + val groupID: ByteArray, + val epoch: Long, + val payload: ByteArray, + val queuedAtMs: Long + ) + + private val lifecycleLock = Any() + private val pendingLock = Any() + private val pendingEvents = ArrayDeque() + private val futureMessages = ArrayDeque() + @Volatile + private var acceptsInboundEvents = true + @Volatile + private var inboundGeneration = 0L + + fun createGroup(rawName: String): GroupCommandResult { + if (!context.groupStore.isReady) return loadingError() + 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 { + if (!context.groupStore.isReady) return loadingError() + val selector = parseMemberSelector(rawNickname) + ?: return error("usage: /group invite [#identity-suffix]") + val nickname = selector.nickname + val group = selectedGroup() ?: return error("open a private group first") + if (!isCreator(group)) return error("only the group creator can change members") + val peerID = resolvePeer(selector) ?: return ambiguousPeerError(selector) + if (!context.isPeerConnected(peerID)) return error("$nickname is not connected") + when (context.peerGroupCapability(peerID)) { + PeerGroupCapability.SUPPORTED -> Unit + PeerGroupCapability.UNSUPPORTED -> + return error("$nickname does not support private groups") + PeerGroupCapability.UNKNOWN -> + return error("private-group support for $nickname is not confirmed yet; try again") + } + 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 { + if (!context.groupStore.isReady) return loadingError() + val selector = parseMemberSelector(rawNickname) + ?: return error("usage: /group remove [#identity-suffix]") + val nickname = selector.nickname + val group = selectedGroup() ?: return error("open a private group first") + if (!isCreator(group)) return error("only the group creator can change members") + val matchingMembers = group.members.filter { + it.nickname.equals(nickname, ignoreCase = true) + }.let { members -> + selector.identitySuffix?.let { suffix -> + members.filter { it.fingerprint.endsWith(suffix, ignoreCase = true) } + } ?: members + } + val member = matchingMembers.singleOrNull() ?: return when { + matchingMembers.isEmpty() -> error("$nickname is not in this group") + else -> error( + "multiple members are named '$nickname'; use ${memberChoices(matchingMembers)}" + ) + } + 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 { + if (!context.groupStore.isReady) return loadingError() + val group = selectedGroup() ?: return error("open a private group first") + if (isCreator(group) && group.members.size > 1) { + return error("remove all other members before leaving this group") + } + val removed = if (isCreator(group)) { + context.groupStore.removeGroup(group.groupID) + } else { + context.groupStore.departGroup(group.groupID, group.epoch) + } + if (!removed) return error("could not leave group") + synchronized(lifecycleLock) { + dropFutureMessages(group.groupID) + } + context.closeGroupConversation() + context.removeGroupConversation(group.peerID) + return success("left #${group.name}") + } + + fun listGroups(): GroupCommandResult { + if (!context.groupStore.isReady) return loadingError() + 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 (!context.groupStore.isReady) { + context.addGroupSystemMessage(groupPeerID, "private groups are still loading") + return + } + 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) + } + + fun handleMessage(payload: ByteArray, receivedAtMs: Long) { + val generation = inboundGeneration + if (!acceptsInboundEvents) return + synchronized(lifecycleLock) { + if (!acceptsInboundEvents || generation != inboundGeneration) return + if (deferIfLoading(PendingEvent.Message(payload.copyOf(), receivedAtMs))) return + processMessage(payload) + } + } + + private fun processMessage(payload: ByteArray, queueFutureEpoch: Boolean = true) { + val envelope = GroupMessageEnvelope.decode(payload) ?: return + val group = context.groupStore.group(envelope.groupID) ?: return + if (envelope.epoch != group.epoch) { + if (queueFutureEpoch && envelope.epoch > group.epoch) { + queueFutureMessage(envelope, payload) + } + 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 + ) { + val generation = inboundGeneration + if (!acceptsInboundEvents) return + synchronized(lifecycleLock) { + if (!acceptsInboundEvents || generation != inboundGeneration) return + if ( + deferIfLoading( + PendingEvent.Invite( + peerID, + authenticatedRemoteStaticKey.copyOf(), + payload.copyOf() + ) + ) + ) { + return + } + applyState(peerID, authenticatedRemoteStaticKey, payload, isInvite = true) + } + } + + fun handleKeyUpdate( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + payload: ByteArray + ) { + val generation = inboundGeneration + if (!acceptsInboundEvents) return + synchronized(lifecycleLock) { + if (!acceptsInboundEvents || generation != inboundGeneration) return + if ( + deferIfLoading( + PendingEvent.KeyUpdate( + peerID, + authenticatedRemoteStaticKey.copyOf(), + payload.copyOf() + ) + ) + ) { + return + } + applyState(peerID, authenticatedRemoteStaticKey, payload, isInvite = false) + } + } + + /** + * Replays the current creator-signed state after an authenticated peer + * reconnects. This uses the existing iOS GROUP_KEY_UPDATE payload and + * repairs updates that could not be delivered while the member was offline. + */ + fun handlePeerAuthenticated(peerID: String) { + val generation = inboundGeneration + if (!acceptsInboundEvents) return + synchronized(lifecycleLock) { + if (!acceptsInboundEvents || generation != inboundGeneration) return + if (deferIfLoading(PendingEvent.PeerAuthenticated(peerID))) return + if (!context.isPeerConnected(peerID)) return + if (context.peerGroupCapability(peerID) != PeerGroupCapability.SUPPORTED) return + val identity = context.peerIdentity(peerID) ?: return + val ownFingerprint = context.myNoiseFingerprint() + context.groupStore.groups.value.forEach { group -> + if (group.creatorFingerprint != ownFingerprint || + !group.isMember(identity.fingerprint) + ) { + return@forEach + } + val key = context.groupStore.key(group.groupID) ?: return@forEach + val payload = signedStatePayload(group, key) ?: return@forEach + context.sendGroupKeyUpdate(payload, peerID) + } + } + } + + /** + * Drains packets received during asynchronous store initialization. + */ + fun onStoreReady() { + val generation = inboundGeneration + if (!acceptsInboundEvents) return + synchronized(lifecycleLock) { + if (!acceptsInboundEvents || + generation != inboundGeneration || + !context.groupStore.isReady + ) { + return + } + while (true) { + val event = synchronized(pendingLock) { + pendingEvents.pollFirst() + } ?: return + when (event) { + is PendingEvent.Invite -> applyState( + event.peerID, + event.authenticatedRemoteStaticKey, + event.payload, + isInvite = true + ) + is PendingEvent.KeyUpdate -> applyState( + event.peerID, + event.authenticatedRemoteStaticKey, + event.payload, + isInvite = false + ) + is PendingEvent.Message -> processMessage(event.payload) + is PendingEvent.PeerAuthenticated -> + handlePeerAuthenticated(event.peerID) + } + } + } + } + + fun suspendForPanic() { + synchronized(lifecycleLock) { + acceptsInboundEvents = false + inboundGeneration += 1 + synchronized(pendingLock) { + pendingEvents.clear() + } + futureMessages.clear() + } + } + + fun resumeAfterPanic() { + synchronized(lifecycleLock) { + acceptsInboundEvents = true + } + } + + private fun applyState( + peerID: String, + authenticatedRemoteStaticKey: ByteArray, + payload: ByteArray, + isInvite: Boolean + ) { + 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) + // Reject stale state before interpreting a missing-self roster as a + // removal. Otherwise an old, valid removal notice could delete a + // membership restored by a later creator-signed re-invite. + if (existing != null && state.epoch < existing.epoch) return + val departureEpoch = context.groupStore.departureEpoch(state.groupID) + if (departureEpoch != null && + (!isInvite || state.epoch <= departureEpoch) + ) { + return + } + if (state.members.none { it.fingerprint == ownFingerprint }) { + val removed = context.groupStore.removeGroupForState(state.groupID, state.epoch) + ?: return + dropFutureMessages(removed.groupID) + if (context.selectedConversationID == removed.peerID) { + context.closeGroupConversation() + } + context.removeGroupConversation(removed.peerID) + context.addSystemMessage("you were removed from #${removed.name}") + return + } + val stored = if (isInvite && departureEpoch != null) { + context.groupStore.acceptInvite(state.asGroup(), state.key) + } else { + context.groupStore.upsert(state.asGroup(), state.key) + } + if (!stored) return + retryFutureMessages(state.groupID) + + 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 parseMemberSelector(raw: String): MemberSelector? { + val normalized = raw.trim().removePrefix("@") + if (normalized.isEmpty()) return null + val separator = normalized.lastIndexOf('#') + if (separator < 0) return MemberSelector(normalized, null) + if (separator == 0 || separator == normalized.lastIndex) return null + val suffix = normalized.substring(separator + 1) + if (!IDENTITY_SUFFIX.matches(suffix)) return null + return MemberSelector( + nickname = normalized.substring(0, separator), + identitySuffix = suffix.lowercase() + ) + } + + private fun resolvePeer(selector: MemberSelector): String? { + val matches = context.peerIDsForNickname(selector.nickname).distinct() + val narrowed = selector.identitySuffix?.let { suffix -> + matches.filter { peerID -> + peerID.endsWith(suffix, ignoreCase = true) || + context.peerIdentity(peerID) + ?.fingerprint + ?.endsWith(suffix, ignoreCase = true) == true + } + } ?: matches + return narrowed.singleOrNull() + } + + private fun ambiguousPeerError(selector: MemberSelector): GroupCommandResult { + val matches = context.peerIDsForNickname(selector.nickname).distinct() + if (matches.isEmpty() || selector.identitySuffix != null) { + return error("user '${selector.nickname}' was not found") + } + return error( + "multiple users are named '${selector.nickname}'; use " + + matches.joinToString(" or ") { peerID -> + val suffix = context.peerIdentity(peerID) + ?.fingerprint + ?.takeLast(8) + ?: peerID.takeLast(8) + "@${selector.nickname}#$suffix" + } + ) + } + + private fun memberChoices(members: List): String = + members.joinToString(" or ") { "@${it.nickname}#${it.fingerprint.takeLast(8)}" } + + private fun queueFutureMessage(envelope: GroupMessageEnvelope, payload: ByteArray) { + val now = System.currentTimeMillis() + pruneExpiredFutureMessages(now) + if (futureMessages.any { + it.epoch == envelope.epoch && + it.groupID.contentEquals(envelope.groupID) && + it.payload.contentEquals(payload) + } + ) { + return + } + if (futureMessages.size >= MAX_FUTURE_MESSAGES) futureMessages.pollFirst() + futureMessages.addLast( + FutureMessage( + envelope.groupID.copyOf(), + envelope.epoch, + payload.copyOf(), + now + ) + ) + } + + private fun retryFutureMessages(groupID: ByteArray) { + val current = context.groupStore.group(groupID) ?: return + val now = System.currentTimeMillis() + val ready = mutableListOf() + val iterator = futureMessages.iterator() + while (iterator.hasNext()) { + val message = iterator.next() + if (now - message.queuedAtMs > FUTURE_MESSAGE_TTL_MS) { + iterator.remove() + } else if (message.groupID.contentEquals(groupID) && + message.epoch <= current.epoch + ) { + iterator.remove() + if (message.epoch == current.epoch) ready += message.payload + } + } + ready.forEach { processMessage(it, queueFutureEpoch = false) } + } + + private fun dropFutureMessages(groupID: ByteArray) { + val iterator = futureMessages.iterator() + while (iterator.hasNext()) { + if (iterator.next().groupID.contentEquals(groupID)) iterator.remove() + } + } + + private fun pruneExpiredFutureMessages(now: Long) { + val iterator = futureMessages.iterator() + while (iterator.hasNext()) { + if (now - iterator.next().queuedAtMs > FUTURE_MESSAGE_TTL_MS) iterator.remove() + } + } + + private fun deferIfLoading(event: PendingEvent): Boolean = + synchronized(pendingLock) { + if (context.groupStore.isReady) return@synchronized false + if (pendingEvents.size >= MAX_PENDING_EVENTS) pendingEvents.pollFirst() + pendingEvents.addLast(event) + true + } + + private fun loadingError() = + error("private groups are still loading; try again") + + 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 const val MAX_PENDING_EVENTS = 64 + private const val MAX_FUTURE_MESSAGES = 32 + private const val FUTURE_MESSAGE_TTL_MS = 2 * 60_000L + private val FINGERPRINT = Regex("^[0-9a-fA-F]{64}$") + private val IDENTITY_SUFFIX = Regex("^[0-9a-fA-F]{4,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..2f8a25de --- /dev/null +++ b/app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt @@ -0,0 +1,663 @@ +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 java.util.UUID +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 { + if (!isCanonicalMessageID(messageID)) { + throw GroupCryptoException.MalformedPayload() + } + 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(::isCanonicalMessageID) + ?: 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 + ) + } + + private fun isCanonicalMessageID(messageID: String): Boolean { + val encoded = messageID.toByteArray(Charsets.UTF_8) + if (encoded.size != UUID_TEXT_LENGTH || !UUID_PATTERN.matches(messageID)) return false + return try { + UUID.fromString(messageID) + true + } catch (_: IllegalArgumentException) { + false + } + } + + @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) + } + + private const val UUID_TEXT_LENGTH = 36 + private val UUID_PATTERN = Regex( + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + + "[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + ) +} + +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..0f63ee9a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/groups/GroupStore.kt @@ -0,0 +1,552 @@ +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.JsonParser +import com.google.gson.reflect.TypeToken +import java.io.File +import java.io.FileOutputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +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 + fun clear(): Boolean +} + +internal interface GroupMetadataStorage { + fun read(): String? + fun write(contents: String): Boolean + fun delete(): 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 + } + + override fun clear(): Boolean = try { + preferences.edit().clear().commit() && preferences.all.isEmpty() + } catch (_: Exception) { + false + } +} + +private class FileGroupMetadataStorage(private val file: File) : GroupMetadataStorage { + override fun read(): String? = try { + file.takeIf(File::exists)?.readText(Charsets.UTF_8) + } catch (_: Exception) { + null + } + + override fun write(contents: String): Boolean { + var temporary: File? = null + return try { + val parent = file.parentFile + if (parent != null && !parent.exists() && !parent.mkdirs()) return false + temporary = File(parent, "${file.name}.tmp") + FileOutputStream(temporary).use { output -> + output.write(contents.toByteArray(Charsets.UTF_8)) + output.fd.sync() + } + try { + Files.move( + temporary.toPath(), + file.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + temporary.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } + true + } catch (_: Exception) { + temporary?.delete() + false + } + } + + override fun delete(): Boolean = try { + val deleted = !file.exists() || file.delete() + if (deleted) { + file.parentFile + ?.takeIf { it.listFiles().isNullOrEmpty() } + ?.delete() + } + deleted + } 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. + * + * The Android constructor is intentionally inert. [initialize] performs + * Keystore and disk access and must be called from a background dispatcher. + */ +class GroupStore private constructor( + private val keyStorageFactory: () -> GroupKeyStorage, + private val metadataStorageFactory: () -> GroupMetadataStorage?, + autoInitialize: Boolean +) { + 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 data class StoredState( + val version: Int = 1, + val groups: List, + val departures: Map + ) + + private val lock = Any() + private val gson = Gson() + private val random by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { SecureRandom() } + private val _groups = MutableStateFlow>(emptyList()) + private val departures = mutableMapOf() + private var keyStorage: GroupKeyStorage? = null + private var metadataStorage: GroupMetadataStorage? = null + + @Volatile + private var initialized = false + + val groups: StateFlow> = _groups.asStateFlow() + val isReady: Boolean + get() = initialized + + constructor(context: Context) : this( + keyStorageFactory = { + EncryptedPreferencesGroupKeyStorage(context.applicationContext) + }, + metadataStorageFactory = { + FileGroupMetadataStorage( + File(context.applicationContext.noBackupFilesDir, "groups/groups.json") + ) + }, + autoInitialize = false + ) + + internal constructor( + keyStorage: GroupKeyStorage, + metadataFile: File? = null, + testOnly: Boolean, + autoInitialize: Boolean = true + ) : this( + keyStorageFactory = { keyStorage }, + metadataStorageFactory = { + metadataFile?.let(::FileGroupMetadataStorage) + }, + autoInitialize = autoInitialize + ) { + require(testOnly) + } + + internal constructor( + keyStorage: GroupKeyStorage, + metadataStorage: GroupMetadataStorage, + testOnly: Boolean, + autoInitialize: Boolean = true + ) : this( + keyStorageFactory = { keyStorage }, + metadataStorageFactory = { metadataStorage }, + autoInitialize = autoInitialize + ) { + require(testOnly) + } + + init { + if (autoInitialize) initialize() + } + + /** + * Initializes encrypted key storage and loads metadata. Callers using the + * Android constructor must invoke this away from the main thread. + */ + fun initialize(): Boolean = synchronized(lock) { + if (initialized) return@synchronized true + val keys = try { + keyStorageFactory() + } catch (error: Exception) { + Log.e(TAG, "Failed to initialize private-group key storage", error) + return@synchronized false + } + val metadata = try { + metadataStorageFactory() + } catch (error: Exception) { + Log.e(TAG, "Failed to initialize private-group metadata storage", error) + return@synchronized false + } + keyStorage = keys + metadataStorage = metadata + loadLocked(keys, metadata) + initialized = true + true + } + + fun group(groupID: ByteArray): BitchatGroup? = synchronized(lock) { + _groups.value.firstOrNull { it.groupID.contentEquals(groupID) }?.deepCopy() + } + + fun group(peerID: String): BitchatGroup? = + GroupIds.groupID(peerID)?.let(::group) + + fun key(groupID: ByteArray): ByteArray? = synchronized(lock) { + keyStorage + ?.get(keyName(groupID)) + ?.takeIf { it.size == BitchatGroup.KEY_LENGTH } + ?.copyOf() + } + + fun departureEpoch(groupID: ByteArray): Long? = synchronized(lock) { + departures[groupID.hexEncodedString()] + } + + fun createGroup(name: String, creator: GroupMember): BitchatGroup? { + if (!isReady) return null + 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) { + upsertLocked(group, key, clearDeparture = false) + } + + fun acceptInvite(group: BitchatGroup, key: ByteArray): Boolean = synchronized(lock) { + upsertLocked(group, key, clearDeparture = true) + } + + fun rotateKey( + groupID: ByteArray, + members: List + ): Pair? = synchronized(lock) { + if (!initialized) return@synchronized null + 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 (!upsertLocked(rotated, newKey, clearDeparture = false)) { + return@synchronized null + } + rotated.deepCopy() to newKey.copyOf() + } + + fun removeGroup(groupID: ByteArray): Boolean = synchronized(lock) { + removeLocked(groupID, departureEpoch = null) + } + + fun removeGroupForState(groupID: ByteArray, stateEpoch: Long): BitchatGroup? = + synchronized(lock) { + if (stateEpoch !in 0..BitchatGroup.MAX_EPOCH) return@synchronized null + val existing = _groups.value.firstOrNull { it.groupID.contentEquals(groupID) } + ?: return@synchronized null + if (stateEpoch < existing.epoch) return@synchronized null + if (!removeLocked(groupID, departureEpoch = null)) return@synchronized null + existing.deepCopy() + } + + fun departGroup(groupID: ByteArray, epoch: Long): Boolean = synchronized(lock) { + if (epoch !in 0..BitchatGroup.MAX_EPOCH) return@synchronized false + removeLocked(groupID, departureEpoch = epoch) + } + + fun wipe(): Boolean = synchronized(lock) { + if (!initialized && !initialize()) return@synchronized false + val keys = keyStorage ?: return@synchronized false + val keysCleared = keys.clear() + val metadataDeleted = metadataStorage?.delete() ?: true + _groups.value = emptyList() + departures.clear() + keysCleared && metadataDeleted + } + + private fun upsertLocked( + group: BitchatGroup, + key: ByteArray, + clearDeparture: Boolean + ): Boolean { + val keys = keyStorage ?: return false + if (!initialized || !isValid(group, key)) return false + + val updatedGroups = _groups.value.toMutableList() + val index = updatedGroups.indexOfFirst { it.groupID.contentEquals(group.groupID) } + if (index >= 0 && group.epoch < updatedGroups[index].epoch) return false + + if (index >= 0) { + updatedGroups[index] = group.deepCopy() + } else { + updatedGroups += group.deepCopy() + } + val updatedDepartures = departures.toMutableMap() + val groupID = group.groupID.hexEncodedString() + val departureEpoch = updatedDepartures[groupID] + if (clearDeparture) { + if (departureEpoch != null && group.epoch <= departureEpoch) return false + updatedDepartures.remove(groupID) + } else if (departureEpoch != null) { + return false + } + + val name = keyName(group.groupID) + val previousKey = keys.get(name)?.copyOf() + if (!keys.put(name, key.copyOf())) { + Log.e(TAG, "Failed to store private-group epoch key") + return false + } + if (!persistLocked(updatedGroups, updatedDepartures)) { + restoreKey(keys, name, previousKey) + return false + } + + departures.clear() + departures.putAll(updatedDepartures) + _groups.value = updatedGroups.map { it.deepCopy() } + return true + } + + private fun removeLocked(groupID: ByteArray, departureEpoch: Long?): Boolean { + val keys = keyStorage ?: return false + if (!initialized) return false + + val updatedGroups = _groups.value.filterNot { it.groupID.contentEquals(groupID) } + val updatedDepartures = departures.toMutableMap() + if (departureEpoch != null) { + val id = groupID.hexEncodedString() + updatedDepartures[id] = maxOf(updatedDepartures[id] ?: -1, departureEpoch) + } + + val name = keyName(groupID) + val previousKey = keys.get(name)?.copyOf() + if (previousKey != null && !keys.remove(name)) { + Log.e(TAG, "Failed to remove private-group epoch key") + return false + } + if (!persistLocked(updatedGroups, updatedDepartures)) { + restoreKey(keys, name, previousKey) + return false + } + + departures.clear() + departures.putAll(updatedDepartures) + _groups.value = updatedGroups.map { it.deepCopy() } + return true + } + + private fun restoreKey( + storage: GroupKeyStorage, + name: String, + previousKey: ByteArray? + ) { + val restored = if (previousKey == null) { + storage.remove(name) + } else { + storage.put(name, previousKey) + } + if (!restored && previousKey != null) { + Log.e(TAG, "Failed to restore private-group epoch key after metadata failure") + } + } + + 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( + groups: List, + departures: Map + ): Boolean { + val storage = metadataStorage ?: return true + if (groups.isEmpty() && departures.isEmpty()) return storage.delete() + + val state = StoredState( + groups = groups.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 + ) + }, + departures = departures.toSortedMap() + ) + val persisted = storage.write(gson.toJson(state)) + if (!persisted) Log.e(TAG, "Failed to persist private-group metadata") + return persisted + } + + private fun loadLocked( + keys: GroupKeyStorage, + metadata: GroupMetadataStorage? + ) { + val raw = metadata?.read() ?: return + val parsed = try { + val json = JsonParser.parseString(raw) + if (json.isJsonArray) { + val type = object : TypeToken>() {}.type + StoredState(groups = gson.fromJson(json, type), departures = emptyMap()) + } else { + val objectValue = json.asJsonObject + val groupType = object : TypeToken>() {}.type + val departureType = object : TypeToken>() {}.type + StoredState( + version = objectValue.get("version")?.asInt ?: 1, + groups = objectValue.get("groups")?.let { + gson.fromJson(it, groupType) + } ?: emptyList(), + departures = objectValue.get("departures")?.let { + gson.fromJson(it, departureType) + } ?: emptyMap() + ) + } + } catch (_: Exception) { + null + } ?: return + + departures.clear() + parsed.departures.forEach { (groupID, epoch) -> + if (GROUP_ID_HEX.matches(groupID) && epoch in 0..BitchatGroup.MAX_EPOCH) { + departures[groupID.lowercase()] = epoch + } + } + + _groups.value = parsed.groups.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 + ) + if (departures.containsKey(group.groupID.hexEncodedString())) { + keys.remove(keyName(group.groupID)) + return@mapNotNull null + } + val storedKey = keys.get(keyName(group.groupID)) + ?.takeIf { it.size == BitchatGroup.KEY_LENGTH } + ?: return@mapNotNull null + group.takeIf { isValid(it, storedKey) }?.deepCopy() + } 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" + private val GROUP_ID_HEX = Regex("^[0-9a-fA-F]{32}$") + } +} 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 07f46d44..e0ae3743 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -557,6 +557,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 @@ -636,6 +656,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic override fun handleVoiceFrame(routed: RoutedPacket): Boolean = messageHandler.handlePublicVoiceFrame(routed) + 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) } @@ -1227,6 +1251,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 { @@ -1689,6 +1749,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 aac2a880..c2edbb44 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -437,6 +437,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 { @@ -491,6 +511,10 @@ class MeshCore( override fun handleVoiceFrame(routed: RoutedPacket): Boolean = messageHandler.handlePublicVoiceFrame(routed) + 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) } @@ -800,6 +824,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 { @@ -993,6 +1049,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 65d88ba0..96408df9 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 sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) @@ -63,6 +66,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 ae890316..e935b21b 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -199,6 +199,20 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro com.bitchat.android.model.NoisePayloadType.VERIFY_RESPONSE -> { 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) { @@ -256,6 +270,13 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro consecutiveDecryptFailures[peerID] = failures } } + + fun handleGroupMessage(routed: RoutedPacket) { + delegate?.onGroupMessageReceived( + routed.packet.payload, + routed.packet.timestamp.toLong() + ) + } /** * Send delivery ACK for a received private message - exactly like iOS @@ -770,4 +791,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 039c08e1..51dc40ff 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -153,6 +153,7 @@ class PacketProcessor(private val myPeerID: String) { MessageType.MESSAGE -> handleMessage(routed) MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler MessageType.VOICE_FRAME -> validPacket = delegate?.handleVoiceFrame(routed) ?: false + MessageType.GROUP_MESSAGE -> handleGroupMessage(routed) MessageType.LEAVE -> handleLeave(routed) MessageType.FRAGMENT -> handleFragment(routed) MessageType.REQUEST_SYNC -> handleRequestSync(routed) @@ -212,6 +213,12 @@ class PacketProcessor(private val myPeerID: String) { private suspend fun handleMessage(routed: RoutedPacket) { 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 @@ -316,6 +323,7 @@ interface PacketProcessorDelegate { suspend fun handleAnnounce(routed: RoutedPacket): Boolean fun handleMessage(routed: RoutedPacket) fun handleVoiceFrame(routed: RoutedPacket): Boolean = false + 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 054ca25e..66650ec1 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -166,6 +166,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) @@ -343,6 +367,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) } @@ -429,6 +459,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 aacdafd4..ca38f3fa 100644 --- a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt +++ b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt @@ -22,6 +22,8 @@ enum class NoisePayloadType(val value: UByte) { READ_RECEIPT(0x02u), // Message was read DELIVERED(0x03u), // Message was delivered VOICE_FRAME(0x08u), // Ephemeral live push-to-talk frame + 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 e64fc390..a60ce71b 100644 --- a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt +++ b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt @@ -47,7 +47,7 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable { val NON_DESTRUCTIVE_NOISE_REPLACEMENT = PeerCapabilities(1L shl 10) /** 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/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index ac31f76b..8a4427ab 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -245,6 +245,8 @@ class NostrDirectMessageHandler( NoisePayloadType.VERIFY_CHALLENGE, NoisePayloadType.VERIFY_RESPONSE, NoisePayloadType.VOICE_FRAME, + 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 aea072e4..abd5da7e 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -20,7 +20,8 @@ enum class MessageType(val value: UByte) { FRAGMENT(0x20u), // Fragmentation for large packets REQUEST_SYNC(0x21u), // GCS-based sync request FILE_TRANSFER(0x22u), // New: File transfer packet (BLE voice notes, etc.) - VOICE_FRAME(0x29u); // Ephemeral live push-to-talk frame; never added to gossip sync + VOICE_FRAME(0x29u), // Ephemeral live push-to-talk frame; never added to gossip sync + 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 d229da3e..4f4857f8 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -408,6 +408,19 @@ object AppStateStore { messageID in seenMessageIds } + 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 eed9a505..614619f9 100644 --- a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt +++ b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt @@ -52,6 +52,7 @@ class GossipSyncManager( // Stored packets for sync: // - broadcast messages: keep up to seenCapacity() most recent, keyed by packetId private val messages = LinkedHashMap() + private val groupMessages = LinkedHashMap() private val fragments = LinkedHashMap() private val archiveFile = context?.applicationContext?.filesDir?.let { File(it, ARCHIVE_FILE) } private var restoringArchive = false @@ -97,6 +98,7 @@ class GossipSyncManager( messages.clear() } synchronized(fragments) { fragments.clear() } + synchronized(groupMessages) { groupMessages.clear() } latestAnnouncementByPeer.clear() archiveFile?.delete() Log.d(TAG, "Cleared all gossip sync messages and announcements") @@ -120,17 +122,29 @@ class GossipSyncManager( // 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 isBroadcastFile = mt == MessageType.FILE_TRANSFER && (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) val isAnnouncement = (mt == MessageType.ANNOUNCE) val isFragment = (mt == MessageType.FRAGMENT || isBroadcastFile) && (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) - if (!isBroadcastMessage && !isAnnouncement && !isFragment) return + if (!isBroadcastMessage && !isAnnouncement && !isFragment && !isGroupMessage) return val idBytes = PacketIdUtil.computeIdBytes(packet) val id = idBytes.joinToString("") { b -> "%02x".format(b) } - if (isBroadcastMessage) { + if (isGroupMessage) { + val age = System.currentTimeMillis() - packet.timestamp.toLong() + if (age !in -PUBLIC_PACKET_FUTURE_SKEW_MS..PUBLIC_MESSAGE_MAX_AGE_MS) return + synchronized(groupMessages) { + groupMessages[id] = packet + while (groupMessages.size > 200) { + val iterator = groupMessages.entries.iterator() + iterator.next(); iterator.remove() + } + } + } else if (isBroadcastMessage) { val now = System.currentTimeMillis() val age = now - packet.timestamp.toLong() if (age !in -PUBLIC_PACKET_FUTURE_SKEW_MS..PUBLIC_MESSAGE_MAX_AGE_MS) return @@ -178,7 +192,8 @@ class GossipSyncManager( listOf( SyncTypeFlags.PUBLIC_MESSAGES, SyncTypeFlags.FRAGMENT, - SyncTypeFlags.FILE_TRANSFER + SyncTypeFlags.FILE_TRANSFER, + SyncTypeFlags.GROUP_MESSAGE ).forEach { types -> val payload = buildGcsPayload(types) val packet = BitchatPacket( @@ -194,7 +209,7 @@ class GossipSyncManager( } private fun sendRequestSyncToPeer(peerID: String) { - val types = SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES) + val types = SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES).union(SyncTypeFlags.GROUP_MESSAGE) val payload = buildGcsPayload(types) val packet = BitchatPacket( @@ -249,6 +264,14 @@ class GossipSyncManager( } } + if (requestedTypes.contains(MessageType.GROUP_MESSAGE)) { + synchronized(groupMessages) { groupMessages.values.toList() }.forEach { packet -> + if ((sinceTimestamp == null || packet.timestamp >= sinceTimestamp) && + !mightContain(PacketIdUtil.computeIdBytes(packet))) { + delegate?.sendPacketToPeer(fromPeerID, packet.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)) + } + } + } val toSendFragments = synchronized(fragments) { fragments.values.toList() } for (pkt in toSendFragments) { val type = MessageType.fromValue(pkt.type) ?: continue @@ -289,6 +312,9 @@ class GossipSyncManager( internal fun buildGcsPayload(types: SyncTypeFlags): ByteArray { // Collect only the packet types represented by this filter. val list = ArrayList() + if (types.contains(MessageType.GROUP_MESSAGE)) { + synchronized(groupMessages) { list.addAll(groupMessages.values) } + } if (types.contains(MessageType.ANNOUNCE)) { for ((_, pair) in latestAnnouncementByPeer) { list.add(pair.second) diff --git a/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt b/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt index ab521be7..09eb11cd 100644 --- a/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt +++ b/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt @@ -6,7 +6,10 @@ import com.bitchat.android.protocol.MessageType @JvmInline value class SyncTypeFlags private constructor(val rawValue: ULong) { companion object { - private const val KNOWN_TYPE_MASK: ULong = 0xffu + private const val KNOWN_TYPE_MASK: ULong = 0x4ffu + + val GROUP_MESSAGE = fromMessageTypes(MessageType.GROUP_MESSAGE) + fun of(vararg types: MessageType) = fromMessageTypes(*types) val ANNOUNCE = fromMessageTypes(MessageType.ANNOUNCE) val MESSAGE = fromMessageTypes(MessageType.MESSAGE) @@ -43,6 +46,7 @@ value class SyncTypeFlags private constructor(val rawValue: ULong) { MessageType.FRAGMENT -> 5 MessageType.REQUEST_SYNC -> 6 MessageType.FILE_TRANSFER -> 7 + MessageType.GROUP_MESSAGE -> 10 MessageType.COURIER_ENVELOPE, MessageType.VOICE_FRAME -> null } @@ -55,6 +59,8 @@ value class SyncTypeFlags private constructor(val rawValue: ULong) { fun union(other: SyncTypeFlags): SyncTypeFlags = fromRawValue(rawValue or other.rawValue) + fun encoded(): ByteArray? = encode() + fun encode(): ByteArray? { if (rawValue == 0uL) return null var remaining = rawValue 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 3a4012e2..cdad8afa 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -43,6 +43,15 @@ import com.bitchat.android.features.voice.LiveVoicePreferences import com.bitchat.android.features.voice.LiveVoiceTarget import com.bitchat.android.features.voice.VoiceRecorder +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 +import com.bitchat.android.groups.PeerGroupCapability + private data class ConversationLiveIdentityState( val connectedPeerIDs: List, val peerNicknames: Map, @@ -70,6 +79,9 @@ class ChatViewModel( companion object { private const val TAG = "ChatViewModel" private const val CONVERSATION_DISCONNECT_GRACE_MS = 3_000L + private const val GROUP_COMMAND_USAGE = + "usage: /group create · invite @name[#identity] · " + + "remove @name[#identity] · leave · list" } fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { @@ -151,6 +163,8 @@ class ChatViewModel( .select(com.bitchat.android.geohash.ChannelID.Mesh) } ) + private val groupStore = GroupStore(application.applicationContext) + val groups: StateFlow> = groupStore.groups // Create Noise session delegate for clean dependency injection private val noiseSessionDelegate = object : NoiseSessionDelegate { @@ -183,6 +197,149 @@ class ChatViewModel( NotificationManagerCompat.from(application.applicationContext) ) + 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 peerIDsForNickname(nickname: String): List = + mesh.getPeerNicknames().entries.filter { + it.value.equals(nickname, ignoreCase = true) + }.map { it.key } + + override fun isPeerConnected(peerID: String): Boolean = + mesh.getPeerInfo(peerID)?.isConnected == true && mesh.hasEstablishedSession(peerID) + + override fun peerGroupCapability(peerID: String): PeerGroupCapability { + val peerInfo = mesh.getPeerInfo(peerID) ?: return PeerGroupCapability.UNKNOWN + return PeerGroupCapability.fromPeerState( + peerInfo.capabilities, + peerInfo.hasVerifiedAnnouncement + ) + } + + 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.AppStateStore.markPrivateMessageRead(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, @@ -425,6 +582,11 @@ class ChatViewModel( init { observeConversationPresenceWithDisconnectGrace() + viewModelScope.launch(Dispatchers.IO) { + if (groupStore.initialize()) { + groupCoordinator.onStoreReady() + } + } // Note: Mesh service delegate is now set by MainActivity loadAndInitialize() ContactDirectory.initialize(getApplication()) { mesh } @@ -976,6 +1138,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( @@ -1426,6 +1592,27 @@ class ChatViewModel( override fun didResolvePrivateMediaPolicy(peerID: String) { mediaSendingManager.retryPendingPrivateMedia(peerID) + groupCoordinator.handlePeerAuthenticated(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? { @@ -1457,7 +1644,8 @@ class ChatViewModel( } private suspend fun performPanicClearAllData() { - Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data") + groupCoordinator.suspendForPanic() + Log.w(TAG, "Panic wipe started") try { com.bitchat.android.geohash.LocationChannelManager .getInstance(getApplication()) @@ -1480,6 +1668,7 @@ class ChatViewModel( messageManager.clearAllMessages() channelManager.clearAllChannels() privateChatManager.clearAllPrivateChats() + val groupsCleared = groupStore.wipe() dataManager.clearAllData() conversationListPreferences.clearAll() @@ -1523,7 +1712,7 @@ class ChatViewModel( state.setNickname(newNickname) dataManager.saveNickname(newNickname) - if (!conversationsCleared) { + if (!conversationsCleared || !groupsCleared) { // Privacy wins over availability: keep private-message admission and transports // stopped if SQLite could not prove that the conversation history was erased. Log.e(TAG, "🚨 PANIC MODE INCOMPLETE - conversation database wipe failed") @@ -1534,6 +1723,7 @@ class ChatViewModel( com.bitchat.android.services.AppStateStore .resumePrivateConversationsAfterPanic() recreateMeshServiceAfterPanic() + groupCoordinator.resumeAfterPanic() Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}") } 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 1cccfb19..f7c970bf 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 @@ -23,6 +24,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"), @@ -51,11 +53,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) { @@ -517,7 +557,9 @@ class CommandProcessor( state.getSelectedPrivateChatPeerValue() == null && state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location return (baseCommands + channelCommands).filterNot { - isPublicGeohash && it.command == "/pay" + (isPublicGeohash && it.command == "/pay") || + (it.command == "/group" && (state.selectedLocationChannel.value is ChannelID.Location || + state.getSelectedPrivateChatPeerValue()?.startsWith("nostr") == true)) } } 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 c6233e1c..233e9a47 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -60,6 +61,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.model.BitchatMessageType import com.bitchat.android.ui.theme.BASE_FONT_SIZE @@ -96,6 +99,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() @@ -447,6 +452,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) @@ -568,6 +588,103 @@ fun MeshPeerListSheet( private val PeerRowIconSize = 22.dp private const val CONVERSATION_SEARCH_THRESHOLD = 8 +@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, @@ -1687,6 +1804,9 @@ fun PrivateChatSheet( val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() val peerFavoritedUs by viewModel.peerFavoritedUs.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() @@ -1713,6 +1833,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) { @@ -1722,8 +1843,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()) { @@ -1737,7 +1860,7 @@ fun PrivateChatSheet( } } - val conversationID = contactResolution.conversationID + val conversationID = if (isGroupConversation) peerID else contactResolution.conversationID val messages = privateChats[conversationID] ?: privateChats[peerID] ?: emptyList() val sessionState = resolveConversationSessionState( conversationID = peerID, @@ -1747,8 +1870,15 @@ fun PrivateChatSheet( 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 theyFavoritedUs = remember(peerFavoritedUs, fingerprint, favoriteRelationship) { (fingerprint != null && peerFavoritedUs.contains(fingerprint)) || @@ -1834,8 +1964,33 @@ fun PrivateChatSheet( onImageClick = { _, _, _ -> /* handle image click */ } ) - // Input section. No divider here: ChatInputSection draws its own fade and - // hairline. + 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(peerID) { mutableStateOf( androidx.compose.ui.text.input.TextFieldValue( @@ -1885,7 +2040,7 @@ fun PrivateChatSheet( currentChannel = null, nickname = nickname, colorScheme = colorScheme, - showMediaButtons = true + showMediaButtons = !isGroupConversation ) } @@ -1910,6 +2065,7 @@ fun PrivateChatSheet( }, title = titleText ) { + if (!isGroupConversation) { ConversationHeaderAction( onClick = { viewModel.toggleFavorite(peerID) }, contentDescription = if (isFavorite) { @@ -1938,7 +2094,9 @@ fun PrivateChatSheet( ) } - if (isVerified) { + } + + if (!isGroupConversation && isVerified) { ConversationHeaderStatus { Icon( imageVector = Icons.Filled.Verified, @@ -1953,7 +2111,7 @@ fun PrivateChatSheet( // Keep the lock nearest the close action: from right to left the security // cluster reads close, encryption, verification, then favorite. - if (!isNostrPeer && !isNostrReachableFavorite) { + if (!isGroupConversation && !isNostrPeer && !isNostrReachableFavorite) { ConversationHeaderAction( onClick = { viewModel.showSecurityVerificationSheet() }, contentDescription = stringResource(R.string.verify_title) 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 70ef0b53..fc685b53 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -196,6 +196,22 @@ class MessageManager(private val state: ChatState) { state.setPrivateChats(updatedChats) clearPrivateUnreadMessages(conversationID) } + + 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 12678625..32dc9a81 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 @@ -474,6 +475,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 4b6061d0..d7fe4904 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 @@ -1404,6 +1404,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. @@ -1524,6 +1536,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 a46d596f..cc13eeac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,6 +19,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..c4586e4c --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/groups/GroupCoordinatorTest.kt @@ -0,0 +1,724 @@ +package com.bitchat.android.groups + +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.PeerCapabilities +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 `peer group capability distinguishes unknown unsupported and supported state`() { + assertEquals( + PeerGroupCapability.UNKNOWN, + PeerGroupCapability.fromPeerState(null, hasVerifiedAnnouncement = false) + ) + assertEquals( + PeerGroupCapability.UNSUPPORTED, + PeerGroupCapability.fromPeerState(null, hasVerifiedAnnouncement = true) + ) + assertEquals( + PeerGroupCapability.UNSUPPORTED, + PeerGroupCapability.fromPeerState( + PeerCapabilities.PRIVATE_MEDIA, + hasVerifiedAnnouncement = false + ) + ) + assertEquals( + PeerGroupCapability.SUPPORTED, + PeerGroupCapability.fromPeerState( + PeerCapabilities.GROUPS, + hasVerifiedAnnouncement = false + ) + ) + } + + @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 `duplicate nicknames require an identity suffix`() { + val localKey = privateKey(0x23) + val firstKey = privateKey(0x24) + val secondKey = privateKey(0x25) + val firstPeerID = "24".repeat(8) + val secondPeerID = "25".repeat(8) + val context = FakeGroupContext(localKey, "23".repeat(32)) + context.peerIDs["alice"] = firstPeerID + context.additionalPeerIDs.getOrPut("alice") { mutableListOf() } += secondPeerID + context.connected += firstPeerID + context.connected += secondPeerID + context.peerNames[firstPeerID] = "alice" + context.peerNames[secondPeerID] = "alice" + context.identities[firstPeerID] = GroupPeerIdentity( + "24".repeat(32), + firstKey.generatePublicKey().encoded + ) + context.identities[secondPeerID] = GroupPeerIdentity( + "25".repeat(32), + secondKey.generatePublicKey().encoded + ) + val coordinator = GroupCoordinator(context) + assertTrue(coordinator.createGroup("trail crew").success) + + val ambiguous = coordinator.inviteMember("@alice") + + assertFalse(ambiguous.success) + assertTrue(ambiguous.message.contains("multiple users")) + assertTrue(context.invites.isEmpty()) + assertEquals(1, context.groupStore.groups.value.single().epoch) + + val resolved = coordinator.inviteMember("@alice#${secondPeerID.takeLast(8)}") + + assertTrue(resolved.success) + assertEquals(secondPeerID, context.invites.single().first) + assertEquals("25".repeat(32), context.groupStore.groups.value.single().members.last().fingerprint) + + assertTrue(coordinator.inviteMember("@alice#${firstPeerID.takeLast(8)}").success) + val epochBeforeAmbiguousRemoval = context.groupStore.groups.value.single().epoch + + val ambiguousRemoval = coordinator.removeMember("@alice") + + assertFalse(ambiguousRemoval.success) + assertTrue(ambiguousRemoval.message.contains("multiple members")) + assertEquals(epochBeforeAmbiguousRemoval, context.groupStore.groups.value.single().epoch) + + val resolvedRemoval = + coordinator.removeMember("@alice#${firstPeerID.takeLast(8)}") + + assertTrue(resolvedRemoval.success) + assertFalse( + context.groupStore.groups.value.single().members.any { + it.fingerprint == "24".repeat(32) + } + ) + } + + @Test + fun `invite requires confirmed group capability`() { + val localKey = privateKey(0x12) + val inviteeKey = privateKey(0x13) + val peerID = "13".repeat(8) + val context = FakeGroupContext(localKey, "12".repeat(32)) + context.peerIDs["alice"] = peerID + context.connected += peerID + context.peerNames[peerID] = "alice" + context.identities[peerID] = GroupPeerIdentity( + "13".repeat(32), + inviteeKey.generatePublicKey().encoded + ) + val coordinator = GroupCoordinator(context) + assertTrue(coordinator.createGroup("trail crew").success) + val original = context.groupStore.groups.value.single() + + context.groupCapabilities[peerID] = PeerGroupCapability.UNSUPPORTED + val unsupported = coordinator.inviteMember("@alice") + assertFalse(unsupported.success) + assertTrue(unsupported.message.contains("does not support")) + assertEquals(original, context.groupStore.groups.value.single()) + assertTrue(context.invites.isEmpty()) + + context.groupCapabilities[peerID] = PeerGroupCapability.UNKNOWN + val unknown = coordinator.inviteMember("@alice") + assertFalse(unknown.success) + assertTrue(unknown.message.contains("not confirmed")) + assertEquals(original, context.groupStore.groups.value.single()) + assertTrue(context.invites.isEmpty()) + } + + @Test + fun `authenticated reconnect replays current signed state to retained member`() { + val localKey = privateKey(0x16) + val memberKey = privateKey(0x17) + val peerID = "17".repeat(8) + val memberFingerprint = "17".repeat(32) + val context = FakeGroupContext(localKey, "16".repeat(32)) + context.peerIDs["alice"] = peerID + context.connected += peerID + context.peerNames[peerID] = "alice" + context.identities[peerID] = GroupPeerIdentity( + memberFingerprint, + memberKey.generatePublicKey().encoded + ) + val coordinator = GroupCoordinator(context) + assertTrue(coordinator.createGroup("trail crew").success) + assertTrue(coordinator.inviteMember("@alice").success) + val current = context.groupStore.groups.value.single() + val currentKey = context.groupStore.key(current.groupID)!! + context.updates.clear() + + coordinator.handlePeerAuthenticated(peerID) + + assertEquals(1, context.updates.size) + val (recipient, bytes) = context.updates.single() + assertEquals(peerID, recipient) + val state = GroupStatePayload.decode(bytes)!! + assertTrue(state.verifyCreatorSignature()) + assertEquals(current, state.asGroup()) + assertArrayEquals(currentKey, state.key) + + context.updates.clear() + context.groupCapabilities[peerID] = PeerGroupCapability.UNSUPPORTED + coordinator.handlePeerAuthenticated(peerID) + assertTrue(context.updates.isEmpty()) + } + + @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 `stale removal state cannot delete a newer membership`() { + val creatorKey = privateKey(0x76) + val localKey = privateKey(0x77) + val creatorStatic = ByteArray(32) { 0x78 } + val creatorFingerprint = fingerprint(creatorStatic) + val localFingerprint = "79".repeat(32) + val context = FakeGroupContext(localKey, localFingerprint) + val current = incomingGroup( + creatorKey, + creatorFingerprint, + localKey, + localFingerprint + ).copy(epoch = 3) + assertTrue(context.groupStore.upsert(current, ByteArray(32) { 0x7a })) + context.selected = current.peerID + + val staleRemoval = current.copy( + epoch = 2, + members = listOf(current.members.first()) + ) + val payload = signedState(staleRemoval, creatorKey, ByteArray(32)) + GroupCoordinator(context).handleKeyUpdate("creator", creatorStatic, payload) + + assertEquals(current, context.groupStore.group(current.groupID)) + assertNotNull(context.groupStore.key(current.groupID)) + assertEquals(current.peerID, context.selected) + assertTrue(context.removedConversations.isEmpty()) + assertTrue(context.systemMessages.isEmpty()) + } + + @Test + fun `creator cannot leave while other members remain`() { + val localKey = privateKey(0x14) + val memberKey = privateKey(0x15) + val context = FakeGroupContext(localKey, "14".repeat(32)) + val coordinator = GroupCoordinator(context) + assertTrue(coordinator.createGroup("trail crew").success) + val created = context.groupStore.groups.value.single() + val withMember = created.copy( + members = created.members + GroupMember( + "15".repeat(32), + memberKey.generatePublicKey().encoded, + "alice" + ) + ) + assertTrue( + context.groupStore.upsert( + withMember, + context.groupStore.key(created.groupID)!! + ) + ) + + val result = coordinator.leaveGroup() + + assertFalse(result.success) + assertTrue(result.message.contains("remove all other members")) + assertEquals(withMember, context.groupStore.group(created.groupID)) + assertEquals(withMember.peerID, context.selected) + assertTrue(context.removedConversations.isEmpty()) + } + + @Test + fun `voluntary leave ignores key updates until a newer explicit invite`() { + val creatorKey = privateKey(0x18) + val localKey = privateKey(0x19) + val creatorStatic = ByteArray(32) { 0x1a } + val creatorFingerprint = fingerprint(creatorStatic) + val localFingerprint = "19".repeat(32) + val context = FakeGroupContext(localKey, localFingerprint) + val original = incomingGroup( + creatorKey, + creatorFingerprint, + localKey, + localFingerprint + ) + assertTrue(context.groupStore.upsert(original, ByteArray(32) { 0x1b })) + context.selected = original.peerID + val coordinator = GroupCoordinator(context) + + assertTrue(coordinator.leaveGroup().success) + assertNull(context.groupStore.group(original.groupID)) + assertEquals(original.epoch, context.groupStore.departureEpoch(original.groupID)) + + val nextState = original.copy(epoch = original.epoch + 1) + coordinator.handleKeyUpdate( + "creator", + creatorStatic, + signedState(nextState, creatorKey, ByteArray(32) { 0x1c }) + ) + assertNull(context.groupStore.group(original.groupID)) + + coordinator.handleInvite( + "creator", + creatorStatic, + signedState(original, creatorKey, ByteArray(32) { 0x1d }) + ) + assertNull(context.groupStore.group(original.groupID)) + + coordinator.handleInvite( + "creator", + creatorStatic, + signedState(nextState, creatorKey, ByteArray(32) { 0x1e }) + ) + assertEquals(nextState, context.groupStore.group(original.groupID)) + assertNull(context.groupStore.departureEpoch(original.groupID)) + } + + @Test + fun `packets wait for asynchronous group store initialization`() { + val creatorKey = privateKey(0x1f) + val localKey = privateKey(0x20) + val creatorStatic = ByteArray(32) { 0x21 } + val creatorFingerprint = fingerprint(creatorStatic) + val localFingerprint = "20".repeat(32) + val store = GroupStore( + TestGroupKeys(), + testOnly = true, + autoInitialize = false + ) + val context = FakeGroupContext(localKey, localFingerprint, store) + val group = incomingGroup( + creatorKey, + creatorFingerprint, + localKey, + localFingerprint + ) + val coordinator = GroupCoordinator(context) + + val command = coordinator.createGroup("too early") + assertFalse(command.success) + assertTrue(command.message.contains("still loading")) + coordinator.handleInvite( + "creator", + creatorStatic, + signedState(group, creatorKey, ByteArray(32) { 0x22 }) + ) + assertTrue(store.groups.value.isEmpty()) + + assertTrue(store.initialize()) + coordinator.onStoreReady() + + assertEquals(group, store.group(group.groupID)) + } + + @Test + fun `panic discards packets queued during store initialization`() { + val creatorKey = privateKey(0x26) + val localKey = privateKey(0x27) + val creatorStatic = ByteArray(32) { 0x28 } + val creatorFingerprint = fingerprint(creatorStatic) + val localFingerprint = "27".repeat(32) + val store = GroupStore( + TestGroupKeys(), + testOnly = true, + autoInitialize = false + ) + val context = FakeGroupContext(localKey, localFingerprint, store) + val group = incomingGroup( + creatorKey, + creatorFingerprint, + localKey, + localFingerprint + ) + val coordinator = GroupCoordinator(context) + coordinator.handleInvite( + "creator", + creatorStatic, + signedState(group, creatorKey, ByteArray(32) { 0x29 }) + ) + + coordinator.suspendForPanic() + assertTrue(store.initialize()) + assertTrue(store.wipe()) + coordinator.onStoreReady() + coordinator.resumeAfterPanic() + coordinator.onStoreReady() + + assertTrue(store.groups.value.isEmpty()) + assertNull(store.key(group.groupID)) + assertTrue(context.notifications.isEmpty()) + } + + @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_ID_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_ID_2, "blocked"), + System.currentTimeMillis() + ) + assertEquals(1, context.messages[group.peerID].orEmpty().size) + } + + @Test + fun `future epoch message is retried after matching state arrives`() { + val creatorKey = privateKey(0x2a) + val localKey = privateKey(0x2b) + val creatorStatic = ByteArray(32) { 0x2c } + val creatorFingerprint = fingerprint(creatorStatic) + val localFingerprint = "2b".repeat(32) + val context = FakeGroupContext(localKey, localFingerprint) + val current = incomingGroup( + creatorKey, + creatorFingerprint, + localKey, + localFingerprint + ) + val currentKey = ByteArray(32) { 0x2d } + val nextKey = ByteArray(32) { 0x2e } + val next = current.copy(epoch = current.epoch + 1) + assertTrue(context.groupStore.upsert(current, currentKey)) + val coordinator = GroupCoordinator(context) + val futureMessage = sealedMessage( + next, + nextKey, + creatorKey, + MESSAGE_ID_3, + "after rotation" + ) + + coordinator.handleMessage(futureMessage, System.currentTimeMillis()) + assertTrue(context.messages[current.peerID].orEmpty().isEmpty()) + + coordinator.handleKeyUpdate( + "creator", + creatorStatic, + signedState(next, creatorKey, nextKey) + ) + + val delivered = context.messages[current.peerID].orEmpty().single() + assertEquals(MESSAGE_ID_3, delivered.id) + assertEquals("after rotation", delivered.content) + } + + 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) } + + companion object { + private const val MESSAGE_ID_1 = "123e4567-e89b-12d3-a456-426614174010" + private const val MESSAGE_ID_2 = "123e4567-e89b-12d3-a456-426614174011" + private const val MESSAGE_ID_3 = "123e4567-e89b-12d3-a456-426614174012" + } +} + +private class FakeGroupContext( + private val localKey: Ed25519PrivateKeyParameters, + private val localFingerprint: String, + override val groupStore: GroupStore = GroupStore(TestGroupKeys(), testOnly = true) +) : GroupCoordinatorContext { + override val nickname = "local" + override val myPeerID = localFingerprint.take(16) + override val selectedConversationID: String? + get() = selected + + var selected: String? = null + val peerIDs = mutableMapOf() + val additionalPeerIDs = mutableMapOf>() + val connected = mutableSetOf() + val groupCapabilities = mutableMapOf() + 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 peerIDsForNickname(nickname: String): List = + listOfNotNull(peerIDs[nickname]) + additionalPeerIDs[nickname].orEmpty() + override fun isPeerConnected(peerID: String) = peerID in connected + override fun peerGroupCapability(peerID: String) = + groupCapabilities[peerID] ?: PeerGroupCapability.SUPPORTED + 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 + + override fun clear(): Boolean { + values.clear() + return true + } +} 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..c4f5c495 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/groups/GroupProtocolTest.kt @@ -0,0 +1,291 @@ +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_ID_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_ID_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_ID_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())!!, + groupKey + ) + assertEquals(MESSAGE_ID_1, 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) + } + + @Test + fun `rejects message IDs that are not canonical UUID text`() { + assertThrows(GroupCryptoException.MalformedPayload::class.java) { + GroupCrypto.sealMessage( + content = "legacy", + messageID = "ios-vector", + senderNickname = member.nickname, + senderSigningKey = member.signingKey, + timestampMs = 1_725_000_000_123, + groupID = group.groupID, + epoch = group.epoch, + key = groupKey + ) { sign(memberPrivate, it) } + } + assertThrows(GroupCryptoException.MalformedPayload::class.java) { + GroupCrypto.openMessage( + GroupMessageEnvelope.decode(IOS_LEGACY_MESSAGE_VECTOR.hexBytes())!!, + groupKey + ) + } + } + + private fun sealedMessage(): ByteArray = GroupCrypto.sealMessage( + content = "hello", + messageID = MESSAGE_ID_3, + 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 { + // Swift UUID().uuidString uses uppercase hexadecimal. + private const val MESSAGE_ID_1 = "123E4567-E89B-12D3-A456-426614174000" + private const val MESSAGE_ID_2 = "123e4567-e89b-12d3-a456-426614174001" + private const val MESSAGE_ID_3 = "123e4567-e89b-12d3-a456-426614174002" + private const val IOS_MEMBER_PUBLIC_KEY = + "a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0" + private const val IOS_STATE_VECTOR = + "010010000102030405060708090a0b0c0d0e0f02000468696b650300200102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f200400040000000705008f023131313131313131313131313131313131313131313131313131313131313131d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c97787370763726561746f724141414141414141414141414141414141414141414141414141414141414141a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f005616c69636506002031313131313131313131313131313131313131313131313131313131313131310700407e7dcb5210e48a2edc346f1364c0e3f3a7939a3006f318bd591e94f11b7c6d5a928042f04278bf2e5d612da02258acd686d11a8f8f310b2cc0f8b886bb13ac05" + private const val IOS_MESSAGE_VECTOR = + "010010000102030405060708090a0b0c0d0e0f0200040000000703000c000102030405060708090a0b0400c0e1ab91e9c2905162eb8364fa1c676e395547df4c1e8d153b65b3423fdd6eb2af5015b28900b064d89d132e6f38df9a2f52c21e4ae1763d15f2e9195b70238bde5d30c0b876ffb07072bea3c627b364f8728c0cfaab7c89274110f42c275139065b22d09730c49d982f67b4f868aaa93aef81e8f02e8424b2e2d9e1b80fbe5bfe10f689609e764cbbaf05afdac99f2ed379b742536241f16a655da05efc3995411fdc649202073bc4d6bc1c0e54edebd4926cdcd83df8aa3c9d452eb7a1d7c324" + private const val IOS_LEGACY_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..35982cda --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/groups/GroupStoreTest.kt @@ -0,0 +1,246 @@ +package com.bitchat.android.groups + +import com.google.gson.JsonParser +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) + + val legacyGroups = JsonParser.parseString(file.readText()) + .asJsonObject + .getAsJsonArray("groups") + file.writeText(legacyGroups.toString()) + val reloadedFromLegacyMetadata = GroupStore(keys, file, testOnly = true) + assertEquals(listOf(group), reloadedFromLegacyMetadata.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 `metadata failure rolls back epoch key and in-memory state`() { + val keys = MemoryGroupKeys() + val metadata = MemoryGroupMetadata() + val store = GroupStore(keys, metadata, testOnly = true) + val group = store.createGroup("ops", creator())!! + val originalKey = store.key(group.groupID)!! + val originalMetadata = metadata.contents + val newMember = GroupMember("22".repeat(32), ByteArray(32) { 0x33 }, "alice") + + metadata.failWrites = true + val rotation = store.rotateKey(group.groupID, group.members + newMember) + + assertNull(rotation) + assertEquals(group, store.group(group.groupID)) + assertArrayEquals(originalKey, store.key(group.groupID)) + assertEquals(originalMetadata, metadata.contents) + + metadata.failWrites = false + val reloaded = GroupStore(keys, metadata, testOnly = true) + assertEquals(group, reloaded.group(group.groupID)) + assertArrayEquals(originalKey, reloaded.key(group.groupID)) + } + + @Test + fun `stale state cannot overwrite or remove a newer epoch`() { + val store = GroupStore(MemoryGroupKeys(), testOnly = true) + val original = store.createGroup("ops", creator())!! + val newest = original.copy(epoch = 3) + val newestKey = ByteArray(32) { 0x61 } + assertTrue(store.upsert(newest, newestKey)) + + val stale = original.copy(epoch = 2) + assertFalse(store.upsert(stale, ByteArray(32) { 0x62 })) + assertNull(store.removeGroupForState(original.groupID, stateEpoch = 2)) + + assertEquals(newest, store.group(original.groupID)) + assertArrayEquals(newestKey, store.key(original.groupID)) + } + + @Test + fun `voluntary departure survives restart until a newer invite is accepted`() { + val keys = MemoryGroupKeys() + val file = File(temporaryFolder.root, "groups.json") + val store = GroupStore(keys, file, testOnly = true) + val group = store.createGroup("ops", creator())!! + + assertTrue(store.departGroup(group.groupID, group.epoch)) + assertNull(store.group(group.groupID)) + assertNull(store.key(group.groupID)) + assertEquals(group.epoch, store.departureEpoch(group.groupID)) + + val reloaded = GroupStore(keys, file, testOnly = true) + assertNull(reloaded.group(group.groupID)) + assertEquals(group.epoch, reloaded.departureEpoch(group.groupID)) + + val reinvited = group.copy(epoch = group.epoch + 1) + val newKey = ByteArray(32) { 0x55 } + assertTrue(reloaded.acceptInvite(reinvited, newKey)) + assertEquals(reinvited, reloaded.group(group.groupID)) + assertArrayEquals(newKey, reloaded.key(group.groupID)) + assertNull(reloaded.departureEpoch(group.groupID)) + } + + @Test + fun `android-style store remains inert until background initialization`() { + val keys = MemoryGroupKeys() + val file = File(temporaryFolder.root, "groups.json") + val seeded = GroupStore(keys, file, testOnly = true) + val group = seeded.createGroup("ops", creator())!! + val deferred = GroupStore( + keys, + file, + testOnly = true, + autoInitialize = false + ) + + assertFalse(deferred.isReady) + assertTrue(deferred.groups.value.isEmpty()) + assertNull(deferred.createGroup("too early", creator())) + + assertTrue(deferred.initialize()) + assertTrue(deferred.isReady) + assertEquals(group, deferred.group(group.groupID)) + } + + @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()) + } + + @Test + fun `panic wipe clears keys that have no recoverable metadata`() { + val keys = MemoryGroupKeys() + val orphanedGroupID = ByteArray(16) { 0x71 } + val orphanedKeyName = + "groupKey-${orphanedGroupID.joinToString("") { "%02x".format(it) }}" + keys.put(orphanedKeyName, ByteArray(32) { 0x72 }) + val store = GroupStore(keys, testOnly = true) + assertNotNull(store.key(orphanedGroupID)) + + assertTrue(store.wipe()) + + assertNull(store.key(orphanedGroupID)) + assertTrue(store.groups.value.isEmpty()) + } + + 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 + + override fun clear(): Boolean { + values.clear() + return true + } +} + +private class MemoryGroupMetadata : GroupMetadataStorage { + var contents: String? = null + var failWrites = false + + override fun read(): String? = contents + + override fun write(contents: String): Boolean { + if (failWrites) return false + this.contents = contents + return true + } + + override fun delete(): Boolean { + contents = null + return true + } +} 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 892f6565..cfc31d02 100644 --- a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt @@ -88,18 +88,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) - ) - assertEquals(PeerCapabilities.PRIVATE_MEDIA, PeerCapabilities.LOCAL_SUPPORTED) + 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) + } +}