fix: make private groups converge reliably

This commit is contained in:
callebtc 2026-07-27 01:17:03 +02:00
parent 67669d536e
commit c26489f47a
5 changed files with 686 additions and 79 deletions

View File

@ -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<PendingEvent>()
fun createGroup(rawName: String): GroupCommandResult {
if (!context.groupStore.isReady) return loadingError()
val name = rawName.trim()
if (name.isEmpty()) return error("usage: /group create <name>")
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 <nickname>")
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 <nickname>")
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}$")
}
}

View File

@ -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<StoredGroup>,
val departures: Map<String, Long>
)
private val lock = Any()
private val gson = Gson()
private val random = SecureRandom()
private val random by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { SecureRandom() }
private val _groups = MutableStateFlow<List<BitchatGroup>>(emptyList())
private val departures = mutableMapOf<String, Long>()
private var keyStorage: GroupKeyStorage? = null
private var metadataStorage: GroupMetadataStorage? = null
@Volatile
private var initialized = false
val groups: StateFlow<List<BitchatGroup>> = _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<GroupMember>
): Pair<BitchatGroup, ByteArray>? = 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<BitchatGroup>,
departures: Map<String, Long>
): 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<List<StoredGroup>>() {}.type
gson.fromJson<List<StoredGroup>>(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<List<StoredGroup>>() {}.type
StoredState(groups = gson.fromJson(json, type), departures = emptyMap())
} else {
val objectValue = json.asJsonObject
val groupType = object : TypeToken<List<StoredGroup>>() {}.type
val departureType = object : TypeToken<Map<String, Long>>() {}.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}$")
}
}

View File

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

View File

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

View File

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