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 d5710bc8..7e9fadaa 100644 --- a/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt @@ -44,7 +44,7 @@ interface GroupCoordinatorContext { fun mySigningPublicKey(): ByteArray? fun sign(data: ByteArray): ByteArray? - fun peerIDForNickname(nickname: String): String? + fun peerIDsForNickname(nickname: String): List fun isPeerConnected(peerID: String): Boolean fun peerGroupCapability(peerID: String): PeerGroupCapability fun peerNickname(peerID: String): String? @@ -70,6 +70,11 @@ interface GroupCoordinatorContext { * Creator-managed private-group state machine matching iOS v1. */ class GroupCoordinator(private val context: GroupCoordinatorContext) { + private data class MemberSelector( + val nickname: String, + val identitySuffix: String? + ) + private sealed class PendingEvent { data class Invite( val peerID: String, @@ -91,8 +96,21 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { data class PeerAuthenticated(val peerID: String) : PendingEvent() } + private data class FutureMessage( + val groupID: ByteArray, + val epoch: Long, + val payload: ByteArray, + val queuedAtMs: Long + ) + + private val lifecycleLock = Any() private val pendingLock = Any() private val pendingEvents = ArrayDeque() + private val futureMessages = ArrayDeque() + @Volatile + private var acceptsInboundEvents = true + @Volatile + private var inboundGeneration = 0L fun createGroup(rawName: String): GroupCommandResult { if (!context.groupStore.isReady) return loadingError() @@ -115,12 +133,12 @@ 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 selector = parseMemberSelector(rawNickname) + ?: return error("usage: /group invite [#identity-suffix]") + val nickname = selector.nickname val group = selectedGroup() ?: return error("open a private group first") if (!isCreator(group)) return error("only the group creator can change members") - val peerID = context.peerIDForNickname(nickname) - ?: return error("user '$nickname' was not found") + val peerID = resolvePeer(selector) ?: return ambiguousPeerError(selector) if (!context.isPeerConnected(peerID)) return error("$nickname is not connected") when (context.peerGroupCapability(peerID)) { PeerGroupCapability.SUPPORTED -> Unit @@ -155,13 +173,24 @@ 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 selector = parseMemberSelector(rawNickname) + ?: return error("usage: /group remove [#identity-suffix]") + val nickname = selector.nickname val group = selectedGroup() ?: return error("open a private group first") if (!isCreator(group)) return error("only the group creator can change members") - val member = group.members.firstOrNull { + val matchingMembers = group.members.filter { it.nickname.equals(nickname, ignoreCase = true) - } ?: return error("$nickname is not in this group") + }.let { members -> + selector.identitySuffix?.let { suffix -> + members.filter { it.fingerprint.endsWith(suffix, ignoreCase = true) } + } ?: members + } + val member = matchingMembers.singleOrNull() ?: return when { + matchingMembers.isEmpty() -> error("$nickname is not in this group") + else -> error( + "multiple members are named '$nickname'; use ${memberChoices(matchingMembers)}" + ) + } if (member.fingerprint == group.creatorFingerprint) { return error("the creator cannot remove themselves") } @@ -188,6 +217,9 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { context.groupStore.departGroup(group.groupID, group.epoch) } if (!removed) return error("could not leave group") + synchronized(lifecycleLock) { + dropFutureMessages(group.groupID) + } context.closeGroupConversation() context.removeGroupConversation(group.peerID) return success("left #${group.name}") @@ -263,15 +295,24 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { } fun handleMessage(payload: ByteArray, receivedAtMs: Long) { - if (deferIfLoading(PendingEvent.Message(payload.copyOf(), receivedAtMs))) return - processMessage(payload) + val generation = inboundGeneration + if (!acceptsInboundEvents) return + synchronized(lifecycleLock) { + if (!acceptsInboundEvents || generation != inboundGeneration) return + if (deferIfLoading(PendingEvent.Message(payload.copyOf(), receivedAtMs))) return + processMessage(payload) + } } - @Suppress("UNUSED_PARAMETER") - private fun processMessage(payload: ByteArray) { + private fun processMessage(payload: ByteArray, queueFutureEpoch: Boolean = true) { val envelope = GroupMessageEnvelope.decode(payload) ?: return val group = context.groupStore.group(envelope.groupID) ?: return - if (envelope.epoch != group.epoch) return + if (envelope.epoch != group.epoch) { + if (queueFutureEpoch && envelope.epoch > group.epoch) { + queueFutureMessage(envelope, payload) + } + return + } val key = context.groupStore.key(group.groupID) ?: return val plaintext = try { GroupCrypto.openMessage(envelope, key) @@ -310,18 +351,23 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { authenticatedRemoteStaticKey: ByteArray, payload: ByteArray ) { - if ( - deferIfLoading( - PendingEvent.Invite( - peerID, - authenticatedRemoteStaticKey.copyOf(), - payload.copyOf() + val generation = inboundGeneration + if (!acceptsInboundEvents) return + synchronized(lifecycleLock) { + if (!acceptsInboundEvents || generation != inboundGeneration) return + if ( + deferIfLoading( + PendingEvent.Invite( + peerID, + authenticatedRemoteStaticKey.copyOf(), + payload.copyOf() + ) ) - ) - ) { - return + ) { + return + } + applyState(peerID, authenticatedRemoteStaticKey, payload, isInvite = true) } - applyState(peerID, authenticatedRemoteStaticKey, payload, isInvite = true) } fun handleKeyUpdate( @@ -329,18 +375,23 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { authenticatedRemoteStaticKey: ByteArray, payload: ByteArray ) { - if ( - deferIfLoading( - PendingEvent.KeyUpdate( - peerID, - authenticatedRemoteStaticKey.copyOf(), - payload.copyOf() + val generation = inboundGeneration + if (!acceptsInboundEvents) return + synchronized(lifecycleLock) { + if (!acceptsInboundEvents || generation != inboundGeneration) return + if ( + deferIfLoading( + PendingEvent.KeyUpdate( + peerID, + authenticatedRemoteStaticKey.copyOf(), + payload.copyOf() + ) ) - ) - ) { - return + ) { + return + } + applyState(peerID, authenticatedRemoteStaticKey, payload, isInvite = false) } - applyState(peerID, authenticatedRemoteStaticKey, payload, isInvite = false) } /** @@ -349,20 +400,25 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { * 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 generation = inboundGeneration + if (!acceptsInboundEvents) return + synchronized(lifecycleLock) { + if (!acceptsInboundEvents || generation != inboundGeneration) return + if (deferIfLoading(PendingEvent.PeerAuthenticated(peerID))) return + if (!context.isPeerConnected(peerID)) return + if (context.peerGroupCapability(peerID) != PeerGroupCapability.SUPPORTED) return + val identity = context.peerIdentity(peerID) ?: return + val ownFingerprint = context.myNoiseFingerprint() + context.groupStore.groups.value.forEach { group -> + if (group.creatorFingerprint != ownFingerprint || + !group.isMember(identity.fingerprint) + ) { + return@forEach + } + val key = context.groupStore.key(group.groupID) ?: return@forEach + val payload = signedStatePayload(group, key) ?: return@forEach + context.sendGroupKeyUpdate(payload, peerID) } - val key = context.groupStore.key(group.groupID) ?: return@forEach - val payload = signedStatePayload(group, key) ?: return@forEach - context.sendGroupKeyUpdate(payload, peerID) } } @@ -370,28 +426,54 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { * 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) + val generation = inboundGeneration + if (!acceptsInboundEvents) return + synchronized(lifecycleLock) { + if (!acceptsInboundEvents || + generation != inboundGeneration || + !context.groupStore.isReady + ) { + return } + while (true) { + val event = synchronized(pendingLock) { + pendingEvents.pollFirst() + } ?: return + when (event) { + is PendingEvent.Invite -> applyState( + event.peerID, + event.authenticatedRemoteStaticKey, + event.payload, + isInvite = true + ) + is PendingEvent.KeyUpdate -> applyState( + event.peerID, + event.authenticatedRemoteStaticKey, + event.payload, + isInvite = false + ) + is PendingEvent.Message -> processMessage(event.payload) + is PendingEvent.PeerAuthenticated -> + handlePeerAuthenticated(event.peerID) + } + } + } + } + + fun suspendForPanic() { + synchronized(lifecycleLock) { + acceptsInboundEvents = false + inboundGeneration += 1 + synchronized(pendingLock) { + pendingEvents.clear() + } + futureMessages.clear() + } + } + + fun resumeAfterPanic() { + synchronized(lifecycleLock) { + acceptsInboundEvents = true } } @@ -419,14 +501,14 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { 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.addSystemMessage("you were removed from #${existing.name}") + val removed = context.groupStore.removeGroupForState(state.groupID, state.epoch) + ?: return + dropFutureMessages(removed.groupID) + if (context.selectedConversationID == removed.peerID) { + context.closeGroupConversation() } + context.removeGroupConversation(removed.peerID) + context.addSystemMessage("you were removed from #${removed.name}") return } val stored = if (isInvite && departureEpoch != null) { @@ -435,6 +517,7 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { context.groupStore.upsert(state.asGroup(), state.key) } if (!stored) return + retryFutureMessages(state.groupID) if (existing == null) { val inviter = state.members.firstOrNull { @@ -480,8 +563,107 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { private fun isCreator(group: BitchatGroup): Boolean = group.creatorFingerprint == context.myNoiseFingerprint() - private fun normalizeNickname(raw: String): String = - raw.trim().removePrefix("@") + private fun parseMemberSelector(raw: String): MemberSelector? { + val normalized = raw.trim().removePrefix("@") + if (normalized.isEmpty()) return null + val separator = normalized.lastIndexOf('#') + if (separator < 0) return MemberSelector(normalized, null) + if (separator == 0 || separator == normalized.lastIndex) return null + val suffix = normalized.substring(separator + 1) + if (!IDENTITY_SUFFIX.matches(suffix)) return null + return MemberSelector( + nickname = normalized.substring(0, separator), + identitySuffix = suffix.lowercase() + ) + } + + private fun resolvePeer(selector: MemberSelector): String? { + val matches = context.peerIDsForNickname(selector.nickname).distinct() + val narrowed = selector.identitySuffix?.let { suffix -> + matches.filter { peerID -> + peerID.endsWith(suffix, ignoreCase = true) || + context.peerIdentity(peerID) + ?.fingerprint + ?.endsWith(suffix, ignoreCase = true) == true + } + } ?: matches + return narrowed.singleOrNull() + } + + private fun ambiguousPeerError(selector: MemberSelector): GroupCommandResult { + val matches = context.peerIDsForNickname(selector.nickname).distinct() + if (matches.isEmpty() || selector.identitySuffix != null) { + return error("user '${selector.nickname}' was not found") + } + return error( + "multiple users are named '${selector.nickname}'; use " + + matches.joinToString(" or ") { peerID -> + val suffix = context.peerIdentity(peerID) + ?.fingerprint + ?.takeLast(8) + ?: peerID.takeLast(8) + "@${selector.nickname}#$suffix" + } + ) + } + + private fun memberChoices(members: List): String = + members.joinToString(" or ") { "@${it.nickname}#${it.fingerprint.takeLast(8)}" } + + private fun queueFutureMessage(envelope: GroupMessageEnvelope, payload: ByteArray) { + val now = System.currentTimeMillis() + pruneExpiredFutureMessages(now) + if (futureMessages.any { + it.epoch == envelope.epoch && + it.groupID.contentEquals(envelope.groupID) && + it.payload.contentEquals(payload) + } + ) { + return + } + if (futureMessages.size >= MAX_FUTURE_MESSAGES) futureMessages.pollFirst() + futureMessages.addLast( + FutureMessage( + envelope.groupID.copyOf(), + envelope.epoch, + payload.copyOf(), + now + ) + ) + } + + private fun retryFutureMessages(groupID: ByteArray) { + val current = context.groupStore.group(groupID) ?: return + val now = System.currentTimeMillis() + val ready = mutableListOf() + val iterator = futureMessages.iterator() + while (iterator.hasNext()) { + val message = iterator.next() + if (now - message.queuedAtMs > FUTURE_MESSAGE_TTL_MS) { + iterator.remove() + } else if (message.groupID.contentEquals(groupID) && + message.epoch <= current.epoch + ) { + iterator.remove() + if (message.epoch == current.epoch) ready += message.payload + } + } + ready.forEach { processMessage(it, queueFutureEpoch = false) } + } + + private fun dropFutureMessages(groupID: ByteArray) { + val iterator = futureMessages.iterator() + while (iterator.hasNext()) { + if (iterator.next().groupID.contentEquals(groupID)) iterator.remove() + } + } + + private fun pruneExpiredFutureMessages(now: Long) { + val iterator = futureMessages.iterator() + while (iterator.hasNext()) { + if (now - iterator.next().queuedAtMs > FUTURE_MESSAGE_TTL_MS) iterator.remove() + } + } private fun deferIfLoading(event: PendingEvent): Boolean = synchronized(pendingLock) { @@ -505,6 +687,9 @@ class GroupCoordinator(private val context: GroupCoordinatorContext) { private const val MAX_MESSAGE_LENGTH = 60_000 private const val RECENT_NOTIFICATION_WINDOW_MS = 30_000L private const val MAX_PENDING_EVENTS = 64 + private const val MAX_FUTURE_MESSAGES = 32 + private const val FUTURE_MESSAGE_TTL_MS = 2 * 60_000L private val FINGERPRINT = Regex("^[0-9a-fA-F]{64}$") + private val IDENTITY_SUFFIX = Regex("^[0-9a-fA-F]{4,64}$") } } diff --git a/app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt b/app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt index a96c2c7f..2f8a25de 100644 --- a/app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt +++ b/app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt @@ -9,6 +9,7 @@ import java.nio.charset.CodingErrorAction import java.security.MessageDigest import java.security.SecureRandom import java.util.Arrays +import java.util.UUID import org.bouncycastle.crypto.InvalidCipherTextException import org.bouncycastle.crypto.modes.ChaCha20Poly1305 import org.bouncycastle.crypto.params.AEADParameters @@ -522,6 +523,9 @@ object GroupCrypto { key: ByteArray, sign: (ByteArray) -> ByteArray? ): ByteArray { + if (!isCanonicalMessageID(messageID)) { + throw GroupCryptoException.MalformedPayload() + } val signature = sign( messageSigningContent(groupID, epoch, messageID, timestampMs, content) ) @@ -588,7 +592,7 @@ object GroupCrypto { FIELD_SIGNATURE -> if (field.value.size == 64) signature = field.value } } - val decodedMessageID = messageID?.takeIf { it.isNotEmpty() } + val decodedMessageID = messageID?.takeIf(::isCanonicalMessageID) ?: throw GroupCryptoException.MalformedPayload() val decodedSigningKey = senderSigningKey ?: throw GroupCryptoException.MalformedPayload() val decodedNickname = senderNickname ?: throw GroupCryptoException.MalformedPayload() @@ -615,6 +619,17 @@ object GroupCrypto { ) } + private fun isCanonicalMessageID(messageID: String): Boolean { + val encoded = messageID.toByteArray(Charsets.UTF_8) + if (encoded.size != UUID_TEXT_LENGTH || !UUID_PATTERN.matches(messageID)) return false + return try { + UUID.fromString(messageID) + true + } catch (_: IllegalArgumentException) { + false + } + } + @Throws(InvalidCipherTextException::class) private fun crypt( encrypt: Boolean, @@ -630,6 +645,12 @@ object GroupCrypto { length += cipher.doFinal(output, length) return output.copyOf(length) } + + private const val UUID_TEXT_LENGTH = 36 + private val UUID_PATTERN = Regex( + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + + "[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + ) } internal fun sha256(data: ByteArray): ByteArray = 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 61fea4cf..0f63ee9a 100644 --- a/app/src/main/java/com/bitchat/android/groups/GroupStore.kt +++ b/app/src/main/java/com/bitchat/android/groups/GroupStore.kt @@ -25,6 +25,7 @@ internal interface GroupKeyStorage { fun get(key: String): ByteArray? fun put(key: String, value: ByteArray): Boolean fun remove(key: String): Boolean + fun clear(): Boolean } internal interface GroupMetadataStorage { @@ -73,6 +74,12 @@ private class EncryptedPreferencesGroupKeyStorage(context: Context) : GroupKeySt } catch (_: Exception) { false } + + override fun clear(): Boolean = try { + preferences.edit().clear().commit() && preferences.all.isEmpty() + } catch (_: Exception) { + false + } } private class FileGroupMetadataStorage(private val file: File) : GroupMetadataStorage { @@ -306,18 +313,29 @@ class GroupStore private constructor( removeLocked(groupID, departureEpoch = null) } + fun removeGroupForState(groupID: ByteArray, stateEpoch: Long): BitchatGroup? = + synchronized(lock) { + if (stateEpoch !in 0..BitchatGroup.MAX_EPOCH) return@synchronized null + val existing = _groups.value.firstOrNull { it.groupID.contentEquals(groupID) } + ?: return@synchronized null + if (stateEpoch < existing.epoch) return@synchronized null + if (!removeLocked(groupID, departureEpoch = null)) return@synchronized null + existing.deepCopy() + } + fun departGroup(groupID: ByteArray, epoch: Long): Boolean = synchronized(lock) { if (epoch !in 0..BitchatGroup.MAX_EPOCH) return@synchronized false removeLocked(groupID, departureEpoch = epoch) } - fun wipe() = synchronized(lock) { - if (!initialized && !initialize()) return@synchronized - val keys = keyStorage ?: return@synchronized - _groups.value.forEach { keys.remove(keyName(it.groupID)) } + fun wipe(): Boolean = synchronized(lock) { + if (!initialized && !initialize()) return@synchronized false + val keys = keyStorage ?: return@synchronized false + val keysCleared = keys.clear() + val metadataDeleted = metadataStorage?.delete() ?: true _groups.value = emptyList() departures.clear() - metadataStorage?.delete() + keysCleared && metadataDeleted } private fun upsertLocked( @@ -330,13 +348,22 @@ class GroupStore private constructor( val updatedGroups = _groups.value.toMutableList() val index = updatedGroups.indexOfFirst { it.groupID.contentEquals(group.groupID) } + if (index >= 0 && group.epoch < updatedGroups[index].epoch) return false + if (index >= 0) { updatedGroups[index] = group.deepCopy() } else { updatedGroups += group.deepCopy() } val updatedDepartures = departures.toMutableMap() - if (clearDeparture) updatedDepartures.remove(group.groupID.hexEncodedString()) + val groupID = group.groupID.hexEncodedString() + val departureEpoch = updatedDepartures[groupID] + if (clearDeparture) { + if (departureEpoch != null && group.epoch <= departureEpoch) return false + updatedDepartures.remove(groupID) + } else if (departureEpoch != null) { + return false + } val name = keyName(group.groupID) val previousKey = keys.get(name)?.copyOf() 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 9f364e21..335d2e76 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -61,7 +61,8 @@ class ChatViewModel( companion object { private const val TAG = "ChatViewModel" private const val GROUP_COMMAND_USAGE = - "usage: /group create · invite @name · remove @name · leave · list" + "usage: /group create · invite @name[#identity] · " + + "remove @name[#identity] · leave · list" } fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { @@ -144,10 +145,10 @@ class ChatViewModel( override fun mySigningPublicKey(): ByteArray? = mesh.getSigningPublicKey() override fun sign(data: ByteArray): ByteArray? = mesh.signData(data) - override fun peerIDForNickname(nickname: String): String? = - mesh.getPeerNicknames().entries.firstOrNull { + override fun peerIDsForNickname(nickname: String): List = + mesh.getPeerNicknames().entries.filter { it.value.equals(nickname, ignoreCase = true) - }?.key + }.map { it.key } override fun isPeerConnected(peerID: String): Boolean = mesh.getPeerInfo(peerID)?.isConnected == true && mesh.hasEstablishedSession(peerID) @@ -1143,65 +1144,71 @@ class ChatViewModel( fun panicClearAllData() { Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data") + groupCoordinator.suspendForPanic() + try { + // A pending one-shot downgrade confirmation must not survive panic or + // become actionable against the fresh post-wipe identity. + mediaSendingManager.clearPendingPrivateMediaConsent() - // A pending one-shot downgrade confirmation must not survive panic or - // become actionable against the fresh post-wipe identity. - mediaSendingManager.clearPendingPrivateMediaConsent() - - // Clear all UI managers - messageManager.clearAllMessages() - channelManager.clearAllChannels() - privateChatManager.clearAllPrivateChats() - try { - com.bitchat.android.services.AppStateStore.clear() - } catch (_: Exception) { - } - groupStore.wipe() - dataManager.clearAllData() - - // Clear seen message store - try { - com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() - } catch (_: Exception) { } - - // Clear all mesh service data - clearAllMeshServiceData() - - // Clear all cryptographic data - clearAllCryptographicData() - - // Clear all notifications - notificationManager.clearAllNotifications() - - // Clear all media files - com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication()) - - // Clear Nostr/geohash state, keys, connections, bookmarks, and reinitialize from scratch - try { - // Clear geohash bookmarks too (panic should remove everything) + // Clear all UI managers + messageManager.clearAllMessages() + channelManager.clearAllChannels() + privateChatManager.clearAllPrivateChats() try { - val store = com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(getApplication()) - store.clearAll() + com.bitchat.android.services.AppStateStore.clear() + } catch (_: Exception) { + } + if (!groupStore.wipe()) { + Log.e(TAG, "Private-group panic wipe could not be completed") + } + dataManager.clearAllData() + + // Clear seen message store + try { + com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() } catch (_: Exception) { } + // Clear all mesh service data + clearAllMeshServiceData() + + // Clear all cryptographic data + clearAllCryptographicData() + + // Clear all notifications + notificationManager.clearAllNotifications() + + // Clear all media files + com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication()) + + // Clear Nostr/geohash state, keys, connections, bookmarks, and reinitialize from scratch try { - val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) - locationManager.clearPersistedChannel() - } catch (_: Exception) { } + // Clear geohash bookmarks too (panic should remove everything) + try { + val store = com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(getApplication()) + store.clearAll() + } catch (_: Exception) { } - geohashViewModel.panicReset() - } catch (e: Exception) { - Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}") + try { + val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) + locationManager.clearPersistedChannel() + } catch (_: Exception) { } + + geohashViewModel.panicReset() + } catch (e: Exception) { + Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}") + } + + // Reset nickname + val newNickname = "anon${Random.nextInt(1000, 9999)}" + state.setNickname(newNickname) + dataManager.saveNickname(newNickname) + + // Recreate mesh service with fresh identity + recreateMeshServiceAfterPanic() + } finally { + groupCoordinator.resumeAfterPanic() } - // Reset nickname - val newNickname = "anon${Random.nextInt(1000, 9999)}" - state.setNickname(newNickname) - dataManager.saveNickname(newNickname) - - // Recreate mesh service with fresh identity - recreateMeshServiceAfterPanic() - Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}") } 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 bf219319..c4586e4c 100644 --- a/app/src/test/kotlin/com/bitchat/android/groups/GroupCoordinatorTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/groups/GroupCoordinatorTest.kt @@ -74,6 +74,64 @@ class GroupCoordinatorTest { assertArrayEquals(updatedKey, state.key) } + @Test + fun `duplicate nicknames require an identity suffix`() { + val localKey = privateKey(0x23) + val firstKey = privateKey(0x24) + val secondKey = privateKey(0x25) + val firstPeerID = "24".repeat(8) + val secondPeerID = "25".repeat(8) + val context = FakeGroupContext(localKey, "23".repeat(32)) + context.peerIDs["alice"] = firstPeerID + context.additionalPeerIDs.getOrPut("alice") { mutableListOf() } += secondPeerID + context.connected += firstPeerID + context.connected += secondPeerID + context.peerNames[firstPeerID] = "alice" + context.peerNames[secondPeerID] = "alice" + context.identities[firstPeerID] = GroupPeerIdentity( + "24".repeat(32), + firstKey.generatePublicKey().encoded + ) + context.identities[secondPeerID] = GroupPeerIdentity( + "25".repeat(32), + secondKey.generatePublicKey().encoded + ) + val coordinator = GroupCoordinator(context) + assertTrue(coordinator.createGroup("trail crew").success) + + val ambiguous = coordinator.inviteMember("@alice") + + assertFalse(ambiguous.success) + assertTrue(ambiguous.message.contains("multiple users")) + assertTrue(context.invites.isEmpty()) + assertEquals(1, context.groupStore.groups.value.single().epoch) + + val resolved = coordinator.inviteMember("@alice#${secondPeerID.takeLast(8)}") + + assertTrue(resolved.success) + assertEquals(secondPeerID, context.invites.single().first) + assertEquals("25".repeat(32), context.groupStore.groups.value.single().members.last().fingerprint) + + assertTrue(coordinator.inviteMember("@alice#${firstPeerID.takeLast(8)}").success) + val epochBeforeAmbiguousRemoval = context.groupStore.groups.value.single().epoch + + val ambiguousRemoval = coordinator.removeMember("@alice") + + assertFalse(ambiguousRemoval.success) + assertTrue(ambiguousRemoval.message.contains("multiple members")) + assertEquals(epochBeforeAmbiguousRemoval, context.groupStore.groups.value.single().epoch) + + val resolvedRemoval = + coordinator.removeMember("@alice#${firstPeerID.takeLast(8)}") + + assertTrue(resolvedRemoval.success) + assertFalse( + context.groupStore.groups.value.single().members.any { + it.fingerprint == "24".repeat(32) + } + ) + } + @Test fun `invite requires confirmed group capability`() { val localKey = privateKey(0x12) @@ -345,6 +403,44 @@ class GroupCoordinatorTest { assertEquals(group, store.group(group.groupID)) } + @Test + fun `panic discards packets queued during store initialization`() { + val creatorKey = privateKey(0x26) + val localKey = privateKey(0x27) + val creatorStatic = ByteArray(32) { 0x28 } + val creatorFingerprint = fingerprint(creatorStatic) + val localFingerprint = "27".repeat(32) + val store = GroupStore( + TestGroupKeys(), + testOnly = true, + autoInitialize = false + ) + val context = FakeGroupContext(localKey, localFingerprint, store) + val group = incomingGroup( + creatorKey, + creatorFingerprint, + localKey, + localFingerprint + ) + val coordinator = GroupCoordinator(context) + coordinator.handleInvite( + "creator", + creatorStatic, + signedState(group, creatorKey, ByteArray(32) { 0x29 }) + ) + + coordinator.suspendForPanic() + assertTrue(store.initialize()) + assertTrue(store.wipe()) + coordinator.onStoreReady() + coordinator.resumeAfterPanic() + coordinator.onStoreReady() + + assertTrue(store.groups.value.isEmpty()) + assertNull(store.key(group.groupID)) + assertTrue(context.notifications.isEmpty()) + } + @Test fun `group message requires a roster sender and deduplicates`() { val creatorKey = privateKey(0x21) @@ -361,7 +457,7 @@ class GroupCoordinatorTest { val key = ByteArray(32) { 0x33 } assertTrue(context.groupStore.upsert(group, key)) val coordinator = GroupCoordinator(context) - val payload = sealedMessage(group, key, creatorKey, "message-1", "hello") + val payload = sealedMessage(group, key, creatorKey, MESSAGE_ID_1, "hello") coordinator.handleMessage(payload, System.currentTimeMillis()) coordinator.handleMessage(payload, System.currentTimeMillis()) @@ -375,12 +471,53 @@ class GroupCoordinatorTest { context.blocked += creatorFingerprint coordinator.handleMessage( - sealedMessage(group, key, creatorKey, "message-2", "blocked"), + sealedMessage(group, key, creatorKey, MESSAGE_ID_2, "blocked"), System.currentTimeMillis() ) assertEquals(1, context.messages[group.peerID].orEmpty().size) } + @Test + fun `future epoch message is retried after matching state arrives`() { + val creatorKey = privateKey(0x2a) + val localKey = privateKey(0x2b) + val creatorStatic = ByteArray(32) { 0x2c } + val creatorFingerprint = fingerprint(creatorStatic) + val localFingerprint = "2b".repeat(32) + val context = FakeGroupContext(localKey, localFingerprint) + val current = incomingGroup( + creatorKey, + creatorFingerprint, + localKey, + localFingerprint + ) + val currentKey = ByteArray(32) { 0x2d } + val nextKey = ByteArray(32) { 0x2e } + val next = current.copy(epoch = current.epoch + 1) + assertTrue(context.groupStore.upsert(current, currentKey)) + val coordinator = GroupCoordinator(context) + val futureMessage = sealedMessage( + next, + nextKey, + creatorKey, + MESSAGE_ID_3, + "after rotation" + ) + + coordinator.handleMessage(futureMessage, System.currentTimeMillis()) + assertTrue(context.messages[current.peerID].orEmpty().isEmpty()) + + coordinator.handleKeyUpdate( + "creator", + creatorStatic, + signedState(next, creatorKey, nextKey) + ) + + val delivered = context.messages[current.peerID].orEmpty().single() + assertEquals(MESSAGE_ID_3, delivered.id) + assertEquals("after rotation", delivered.content) + } + private fun incomingGroup( creatorKey: Ed25519PrivateKeyParameters, creatorFingerprint: String, @@ -448,6 +585,12 @@ class GroupCoordinatorTest { MessageDigest.getInstance("SHA-256") .digest(noiseKey) .joinToString("") { "%02x".format(it) } + + companion object { + private const val MESSAGE_ID_1 = "123e4567-e89b-12d3-a456-426614174010" + private const val MESSAGE_ID_2 = "123e4567-e89b-12d3-a456-426614174011" + private const val MESSAGE_ID_3 = "123e4567-e89b-12d3-a456-426614174012" + } } private class FakeGroupContext( @@ -462,6 +605,7 @@ private class FakeGroupContext( var selected: String? = null val peerIDs = mutableMapOf() + val additionalPeerIDs = mutableMapOf>() val connected = mutableSetOf() val groupCapabilities = mutableMapOf() val peerNames = mutableMapOf() @@ -486,7 +630,8 @@ private class FakeGroupContext( return signer.generateSignature() } - override fun peerIDForNickname(nickname: String) = peerIDs[nickname] + override fun peerIDsForNickname(nickname: String): List = + listOfNotNull(peerIDs[nickname]) + additionalPeerIDs[nickname].orEmpty() override fun isPeerConnected(peerID: String) = peerID in connected override fun peerGroupCapability(peerID: String) = groupCapabilities[peerID] ?: PeerGroupCapability.SUPPORTED @@ -571,4 +716,9 @@ private class TestGroupKeys : GroupKeyStorage { } override fun remove(key: String): Boolean = values.remove(key) != null + + override fun clear(): Boolean { + values.clear() + return true + } } diff --git a/app/src/test/kotlin/com/bitchat/android/groups/GroupProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/groups/GroupProtocolTest.kt index 97933236..c4f5c495 100644 --- a/app/src/test/kotlin/com/bitchat/android/groups/GroupProtocolTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/groups/GroupProtocolTest.kt @@ -81,7 +81,7 @@ class GroupProtocolTest { fun `group message seals opens and verifies sender`() { val encoded = GroupCrypto.sealMessage( content = "meet at the ridge", - messageID = "message-1", + messageID = MESSAGE_ID_1, senderNickname = member.nickname, senderSigningKey = member.signingKey, timestampMs = 1_725_000_000_123, @@ -93,7 +93,7 @@ class GroupProtocolTest { val envelope = GroupMessageEnvelope.decode(encoded)!! val opened = GroupCrypto.openMessage(envelope, groupKey) - assertEquals("message-1", opened.messageID) + assertEquals(MESSAGE_ID_1, opened.messageID) assertEquals("meet at the ridge", opened.content) assertEquals(member.nickname, opened.senderNickname) assertArrayEquals(member.signingKey, opened.senderSigningKey) @@ -135,7 +135,7 @@ class GroupProtocolTest { fun `bad sender signature is rejected after valid AEAD`() { val encoded = GroupCrypto.sealMessage( content = "forged", - messageID = "message-2", + messageID = MESSAGE_ID_2, senderNickname = member.nickname, senderSigningKey = member.signingKey, timestampMs = 42, @@ -192,18 +192,40 @@ class GroupProtocolTest { val plaintext = GroupCrypto.openMessage( GroupMessageEnvelope.decode(IOS_MESSAGE_VECTOR.hexBytes())!!, - ByteArray(32) { (it + 1).toByte() } + groupKey ) - assertEquals("ios-vector", plaintext.messageID) + assertEquals(MESSAGE_ID_1, plaintext.messageID) assertEquals("alice", plaintext.senderNickname) assertEquals("meet at ridge", plaintext.content) assertEquals(1_725_000_000_123, plaintext.timestampMs) assertArrayEquals(IOS_MEMBER_PUBLIC_KEY.hexBytes(), plaintext.senderSigningKey) } + @Test + fun `rejects message IDs that are not canonical UUID text`() { + assertThrows(GroupCryptoException.MalformedPayload::class.java) { + GroupCrypto.sealMessage( + content = "legacy", + messageID = "ios-vector", + senderNickname = member.nickname, + senderSigningKey = member.signingKey, + timestampMs = 1_725_000_000_123, + groupID = group.groupID, + epoch = group.epoch, + key = groupKey + ) { sign(memberPrivate, it) } + } + assertThrows(GroupCryptoException.MalformedPayload::class.java) { + GroupCrypto.openMessage( + GroupMessageEnvelope.decode(IOS_LEGACY_MESSAGE_VECTOR.hexBytes())!!, + groupKey + ) + } + } + private fun sealedMessage(): ByteArray = GroupCrypto.sealMessage( content = "hello", - messageID = "message", + messageID = MESSAGE_ID_3, senderNickname = member.nickname, senderSigningKey = member.signingKey, timestampMs = 1234, @@ -253,11 +275,17 @@ class GroupProtocolTest { chunked(2).map { it.toInt(16).toByte() }.toByteArray() companion object { + // Swift UUID().uuidString uses uppercase hexadecimal. + private const val MESSAGE_ID_1 = "123E4567-E89B-12D3-A456-426614174000" + private const val MESSAGE_ID_2 = "123e4567-e89b-12d3-a456-426614174001" + private const val MESSAGE_ID_3 = "123e4567-e89b-12d3-a456-426614174002" private const val IOS_MEMBER_PUBLIC_KEY = "a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0" private const val IOS_STATE_VECTOR = "010010000102030405060708090a0b0c0d0e0f02000468696b650300200102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f200400040000000705008f023131313131313131313131313131313131313131313131313131313131313131d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c97787370763726561746f724141414141414141414141414141414141414141414141414141414141414141a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f005616c69636506002031313131313131313131313131313131313131313131313131313131313131310700407e7dcb5210e48a2edc346f1364c0e3f3a7939a3006f318bd591e94f11b7c6d5a928042f04278bf2e5d612da02258acd686d11a8f8f310b2cc0f8b886bb13ac05" private const val IOS_MESSAGE_VECTOR = + "010010000102030405060708090a0b0c0d0e0f0200040000000703000c000102030405060708090a0b0400c0e1ab91e9c2905162eb8364fa1c676e395547df4c1e8d153b65b3423fdd6eb2af5015b28900b064d89d132e6f38df9a2f52c21e4ae1763d15f2e9195b70238bde5d30c0b876ffb07072bea3c627b364f8728c0cfaab7c89274110f42c275139065b22d09730c49d982f67b4f868aaa93aef81e8f02e8424b2e2d9e1b80fbe5bfe10f689609e764cbbaf05afdac99f2ed379b742536241f16a655da05efc3995411fdc649202073bc4d6bc1c0e54edebd4926cdcd83df8aa3c9d452eb7a1d7c324" + private const val IOS_LEGACY_MESSAGE_VECTOR = "010010000102030405060708090a0b0c0d0e0f0200040000000703000c000102030405060708090a0b0400a6e1abbfb19fd03920bbd627b82b5d575bd8ec48fc57c70d8f7f7c3af33375ae8ac1ed189e8e17acbe8f4c77cda97e44b8084234d2d8e7825ddcfdb492ed01a4eba676a9c28fcae940b33a80a756f27af8758e6dfca33c4184cd5fa2b82fe527de32c79c9a52d8ddcd596c7e3dcc29003184adf571489a8a9a9d0b7301cdf85b45b4521e55aebf6238031af50c81d6dd639b6210e82c0ab70e19ba4d1b04c323c70f6e09b54585" } } diff --git a/app/src/test/kotlin/com/bitchat/android/groups/GroupStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/groups/GroupStoreTest.kt index 274904e2..35982cda 100644 --- a/app/src/test/kotlin/com/bitchat/android/groups/GroupStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/groups/GroupStoreTest.kt @@ -87,6 +87,22 @@ class GroupStoreTest { assertArrayEquals(originalKey, reloaded.key(group.groupID)) } + @Test + fun `stale state cannot overwrite or remove a newer epoch`() { + val store = GroupStore(MemoryGroupKeys(), testOnly = true) + val original = store.createGroup("ops", creator())!! + val newest = original.copy(epoch = 3) + val newestKey = ByteArray(32) { 0x61 } + assertTrue(store.upsert(newest, newestKey)) + + val stale = original.copy(epoch = 2) + assertFalse(store.upsert(stale, ByteArray(32) { 0x62 })) + assertNull(store.removeGroupForState(original.groupID, stateEpoch = 2)) + + assertEquals(newest, store.group(original.groupID)) + assertArrayEquals(newestKey, store.key(original.groupID)) + } + @Test fun `voluntary departure survives restart until a newer invite is accepted`() { val keys = MemoryGroupKeys() @@ -173,6 +189,22 @@ class GroupStoreTest { assertFalse(file.exists()) } + @Test + fun `panic wipe clears keys that have no recoverable metadata`() { + val keys = MemoryGroupKeys() + val orphanedGroupID = ByteArray(16) { 0x71 } + val orphanedKeyName = + "groupKey-${orphanedGroupID.joinToString("") { "%02x".format(it) }}" + keys.put(orphanedKeyName, ByteArray(32) { 0x72 }) + val store = GroupStore(keys, testOnly = true) + assertNotNull(store.key(orphanedGroupID)) + + assertTrue(store.wipe()) + + assertNull(store.key(orphanedGroupID)) + assertTrue(store.groups.value.isEmpty()) + } + private fun creator() = GroupMember("11".repeat(32), ByteArray(32) { 0x44 }, "creator") } @@ -188,6 +220,11 @@ private class MemoryGroupKeys : GroupKeyStorage { } override fun remove(key: String): Boolean = values.remove(key) != null + + override fun clear(): Boolean { + values.clear() + return true + } } private class MemoryGroupMetadata : GroupMetadataStorage {