mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-22 07:06:05 +00:00
fix: make private groups converge reliably
This commit is contained in:
parent
67669d536e
commit
c26489f47a
@ -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}$")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}$")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user