From c26489f47a55a5a4f1f928e32c99e8ac80eb71a7 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:17:03 +0200 Subject: [PATCH] fix: make private groups converge reliably --- .../android/groups/GroupCoordinator.kt | 156 ++++++- .../com/bitchat/android/groups/GroupStore.kt | 382 ++++++++++++++---- .../com/bitchat/android/ui/ChatViewModel.kt | 7 + .../android/groups/GroupCoordinatorTest.kt | 124 +++++- .../bitchat/android/groups/GroupStoreTest.kt | 96 +++++ 5 files changed, 686 insertions(+), 79 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt b/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt index 12b69dcf..d5710bc8 100644 --- a/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt @@ -3,6 +3,7 @@ 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 @@ -69,7 +70,32 @@ interface GroupCoordinatorContext { * Creator-managed private-group state machine matching iOS v1. */ class GroupCoordinator(private val context: GroupCoordinatorContext) { + 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 val pendingLock = Any() + private val pendingEvents = ArrayDeque() + 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) { @@ -88,6 +114,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { } fun inviteMember(rawNickname: String): GroupCommandResult { + if (!context.groupStore.isReady) return loadingError() val nickname = normalizeNickname(rawNickname) if (nickname.isEmpty()) return error("usage: /group invite ") val group = selectedGroup() ?: return error("open a private group first") @@ -127,6 +154,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { } fun removeMember(rawNickname: String): GroupCommandResult { + if (!context.groupStore.isReady) return loadingError() val nickname = normalizeNickname(rawNickname) if (nickname.isEmpty()) return error("usage: /group remove ") val group = selectedGroup() ?: return error("open a private group first") @@ -149,17 +177,24 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { } 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") context.closeGroupConversation() context.removeGroupConversation(group.peerID) - context.groupStore.removeGroup(group.groupID) 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() @@ -171,6 +206,10 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { } 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 ) { @@ -223,8 +262,13 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { context.broadcastGroupMessage(payload) } - @Suppress("UNUSED_PARAMETER") fun handleMessage(payload: ByteArray, receivedAtMs: Long) { + if (deferIfLoading(PendingEvent.Message(payload.copyOf(), receivedAtMs))) return + processMessage(payload) + } + + @Suppress("UNUSED_PARAMETER") + private fun processMessage(payload: ByteArray) { val envelope = GroupMessageEnvelope.decode(payload) ?: return val group = context.groupStore.group(envelope.groupID) ?: return if (envelope.epoch != group.epoch) return @@ -266,7 +310,18 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { authenticatedRemoteStaticKey: ByteArray, payload: ByteArray ) { - applyState(peerID, authenticatedRemoteStaticKey, payload) + if ( + deferIfLoading( + PendingEvent.Invite( + peerID, + authenticatedRemoteStaticKey.copyOf(), + payload.copyOf() + ) + ) + ) { + return + } + applyState(peerID, authenticatedRemoteStaticKey, payload, isInvite = true) } fun handleKeyUpdate( @@ -274,13 +329,77 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { authenticatedRemoteStaticKey: ByteArray, payload: ByteArray ) { - applyState(peerID, authenticatedRemoteStaticKey, payload) + 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) { + 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() { + if (!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) + } + } } private fun applyState( peerID: String, authenticatedRemoteStaticKey: ByteArray, - payload: ByteArray + payload: ByteArray, + isInvite: Boolean ) { val state = GroupStatePayload.decode(payload) ?: return val senderFingerprint = sha256(authenticatedRemoteStaticKey).toHex() @@ -293,18 +412,29 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { // 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 }) { if (existing != null) { + if (!context.groupStore.removeGroup(existing.groupID)) return if (context.selectedConversationID == existing.peerID) { context.closeGroupConversation() } context.removeGroupConversation(existing.peerID) - context.groupStore.removeGroup(existing.groupID) context.addSystemMessage("you were removed from #${existing.name}") } return } - if (!context.groupStore.upsert(state.asGroup(), state.key)) 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 if (existing == null) { val inviter = state.members.firstOrNull { @@ -353,6 +483,17 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { private fun normalizeNickname(raw: String): String = raw.trim().removePrefix("@") + 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) @@ -363,6 +504,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { 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 val FINGERPRINT = Regex("^[0-9a-fA-F]{64}$") } } diff --git a/app/src/main/java/com/bitchat/android/groups/GroupStore.kt b/app/src/main/java/com/bitchat/android/groups/GroupStore.kt index 46f3dbe7..61fea4cf 100644 --- a/app/src/main/java/com/bitchat/android/groups/GroupStore.kt +++ b/app/src/main/java/com/bitchat/android/groups/GroupStore.kt @@ -9,9 +9,13 @@ 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 @@ -23,6 +27,12 @@ internal interface GroupKeyStorage { fun remove(key: String): 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 @@ -65,15 +75,70 @@ private class EncryptedPreferencesGroupKeyStorage(context: Context) : GroupKeySt } } +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 keyStorage: GroupKeyStorage, - private val metadataFile: File? + private val keyStorageFactory: () -> GroupKeyStorage, + private val metadataStorageFactory: () -> GroupMetadataStorage?, + autoInitialize: Boolean ) { private data class StoredMember( val fingerprint: String, @@ -89,40 +154,116 @@ class GroupStore private constructor( 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 = SecureRandom() + 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( - EncryptedPreferencesGroupKeyStorage(context.applicationContext), - File(context.noBackupFilesDir, "groups/groups.json") + keyStorageFactory = { + EncryptedPreferencesGroupKeyStorage(context.applicationContext) + }, + metadataStorageFactory = { + FileGroupMetadataStorage( + File(context.applicationContext.noBackupFilesDir, "groups/groups.json") + ) + }, + autoInitialize = false ) internal constructor( keyStorage: GroupKeyStorage, metadataFile: File? = null, - testOnly: Boolean - ) : this(keyStorage, metadataFile) { + 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 { - loadFromDisk() + 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) } + _groups.value.firstOrNull { it.groupID.contentEquals(groupID) }?.deepCopy() } fun group(peerID: String): BitchatGroup? = GroupIds.groupID(peerID)?.let(::group) - fun key(groupID: ByteArray): ByteArray? = - keyStorage.get(keyName(groupID))?.takeIf { it.size == BitchatGroup.KEY_LENGTH } + fun 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( @@ -136,27 +277,18 @@ class GroupStore private constructor( } fun upsert(group: BitchatGroup, key: ByteArray): Boolean = synchronized(lock) { - if (!isValid(group, key)) return@synchronized false - if (!keyStorage.put(keyName(group.groupID), key.copyOf())) { - Log.e(TAG, "Failed to store private-group epoch key") - return@synchronized false - } - val updated = _groups.value.toMutableList() - val index = updated.indexOfFirst { it.groupID.contentEquals(group.groupID) } - if (index >= 0) { - updated[index] = group.deepCopy() - } else { - updated += group.deepCopy() - } - _groups.value = updated - persistLocked() - true + 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) @@ -164,23 +296,105 @@ class GroupStore private constructor( epoch = (existing.epoch + 1) and BitchatGroup.MAX_EPOCH, members = members.map { it.deepCopy() } ) - if (!upsert(rotated, newKey)) return@synchronized null - rotated to newKey + if (!upsertLocked(rotated, newKey, clearDeparture = false)) { + return@synchronized null + } + rotated.deepCopy() to newKey.copyOf() } - fun removeGroup(groupID: ByteArray) = synchronized(lock) { - _groups.value = _groups.value.filterNot { it.groupID.contentEquals(groupID) } - keyStorage.remove(keyName(groupID)) - persistLocked() + fun removeGroup(groupID: ByteArray): Boolean = synchronized(lock) { + removeLocked(groupID, departureEpoch = null) + } + + 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() = synchronized(lock) { - _groups.value.forEach { keyStorage.remove(keyName(it.groupID)) } + if (!initialized && !initialize()) return@synchronized + val keys = keyStorage ?: return@synchronized + _groups.value.forEach { keys.remove(keyName(it.groupID)) } _groups.value = emptyList() - try { - metadataFile?.delete() - metadataFile?.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete() - } catch (_: Exception) { + departures.clear() + metadataStorage?.delete() + } + + 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) { + updatedGroups[index] = group.deepCopy() + } else { + updatedGroups += group.deepCopy() + } + val updatedDepartures = departures.toMutableMap() + if (clearDeparture) updatedDepartures.remove(group.groupID.hexEncodedString()) + + 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") } } @@ -196,15 +410,15 @@ class GroupStore private constructor( it.signingKey.size == 32 } - private fun persistLocked() { - val file = metadataFile ?: return - try { - if (_groups.value.isEmpty()) { - file.delete() - return - } - file.parentFile?.mkdirs() - val stored = _groups.value.map { group -> + 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, @@ -212,38 +426,59 @@ class GroupStore private constructor( members = group.members.map { member -> StoredMember( fingerprint = member.fingerprint, - signingKey = Base64.encodeToString(member.signingKey, Base64.NO_WRAP), + signingKey = Base64.encodeToString( + member.signingKey, + Base64.NO_WRAP + ), nickname = member.nickname ) }, creatorFingerprint = group.creatorFingerprint ) - } - val temporary = File(file.parentFile, "${file.name}.tmp") - FileOutputStream(temporary).use { output -> - output.write(gson.toJson(stored).toByteArray(Charsets.UTF_8)) - output.fd.sync() - } - if (!temporary.renameTo(file)) { - temporary.copyTo(file, overwrite = true) - temporary.delete() - } - } catch (error: Exception) { - Log.e(TAG, "Failed to persist private-group metadata: ${error.message}") - } + }, + 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 loadFromDisk() = synchronized(lock) { - val file = metadataFile ?: return@synchronized - val stored = try { - if (!file.exists()) return@synchronized - val type = object : TypeToken>() {}.type - gson.fromJson>(file.readText(Charsets.UTF_8), type) + 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@synchronized + } ?: return - _groups.value = stored.mapNotNull { item -> + 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), @@ -258,8 +493,14 @@ class GroupStore private constructor( }, creatorFingerprint = item.creatorFingerprint ) - val storedKey = key(group.groupID) ?: return@mapNotNull null - group.takeIf { isValid(it, storedKey) } + 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 } @@ -279,5 +520,6 @@ class GroupStore private constructor( 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/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 565119b4..9f364e21 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -19,6 +19,7 @@ import com.bitchat.android.nostr.NostrIdentityBridge import com.bitchat.android.protocol.BitchatPacket +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import com.bitchat.android.util.NotificationIntervalManager import kotlinx.coroutines.delay @@ -373,6 +374,11 @@ class ChatViewModel( } init { + viewModelScope.launch(Dispatchers.IO) { + if (groupStore.initialize()) { + groupCoordinator.onStoreReady() + } + } // Note: Mesh service delegate is now set by MainActivity loadAndInitialize() ContactDirectory.initialize(getApplication()) { mesh } @@ -1098,6 +1104,7 @@ class ChatViewModel( override fun didResolvePrivateMediaPolicy(peerID: String) { mediaSendingManager.retryPendingPrivateMedia(peerID) + groupCoordinator.handlePeerAuthenticated(peerID) } override fun didReceiveGroupInvite( diff --git a/app/src/test/kotlin/com/bitchat/android/groups/GroupCoordinatorTest.kt b/app/src/test/kotlin/com/bitchat/android/groups/GroupCoordinatorTest.kt index c7d6b24c..bf219319 100644 --- a/app/src/test/kotlin/com/bitchat/android/groups/GroupCoordinatorTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/groups/GroupCoordinatorTest.kt @@ -106,6 +106,43 @@ class GroupCoordinatorTest { 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) @@ -225,6 +262,89 @@ class GroupCoordinatorTest { 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 `group message requires a roster sender and deduplicates`() { val creatorKey = privateKey(0x21) @@ -332,9 +452,9 @@ class GroupCoordinatorTest { private class FakeGroupContext( private val localKey: Ed25519PrivateKeyParameters, - private val localFingerprint: String + private val localFingerprint: String, + override val groupStore: GroupStore = GroupStore(TestGroupKeys(), testOnly = true) ) : GroupCoordinatorContext { - override val groupStore = GroupStore(TestGroupKeys(), testOnly = true) override val nickname = "local" override val myPeerID = localFingerprint.take(16) override val selectedConversationID: String? diff --git a/app/src/test/kotlin/com/bitchat/android/groups/GroupStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/groups/GroupStoreTest.kt index 46f3ea96..274904e2 100644 --- a/app/src/test/kotlin/com/bitchat/android/groups/GroupStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/groups/GroupStoreTest.kt @@ -1,5 +1,6 @@ package com.bitchat.android.groups +import com.google.gson.JsonParser import java.io.File import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals @@ -50,11 +51,88 @@ class GroupStoreTest { 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 `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) @@ -111,3 +189,21 @@ private class MemoryGroupKeys : GroupKeyStorage { override fun remove(key: String): Boolean = values.remove(key) != null } + +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 + } +}