fix: harden private group state handling

This commit is contained in:
callebtc 2026-07-27 02:36:49 +02:00
parent c26489f47a
commit 0795dc16b3
7 changed files with 603 additions and 148 deletions

View File

@ -44,7 +44,7 @@ interface GroupCoordinatorContext {
fun mySigningPublicKey(): ByteArray?
fun sign(data: ByteArray): ByteArray?
fun peerIDForNickname(nickname: String): String?
fun peerIDsForNickname(nickname: String): List<String>
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<PendingEvent>()
private val futureMessages = ArrayDeque<FutureMessage>()
@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 <nickname>")
val selector = parseMemberSelector(rawNickname)
?: return error("usage: /group invite <nickname>[#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 <nickname>")
val selector = parseMemberSelector(rawNickname)
?: return error("usage: /group remove <nickname>[#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<GroupMember>): 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<ByteArray>()
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}$")
}
}

View File

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

View File

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

View File

@ -61,7 +61,8 @@ class ChatViewModel(
companion object {
private const val TAG = "ChatViewModel"
private const val GROUP_COMMAND_USAGE =
"usage: /group create <name> · invite @name · remove @name · leave · list"
"usage: /group create <name> · 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<String> =
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}")
}

View File

@ -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<String, String>()
val additionalPeerIDs = mutableMapOf<String, MutableList<String>>()
val connected = mutableSetOf<String>()
val groupCapabilities = mutableMapOf<String, PeerGroupCapability>()
val peerNames = mutableMapOf<String, String>()
@ -486,7 +630,8 @@ private class FakeGroupContext(
return signer.generateSignature()
}
override fun peerIDForNickname(nickname: String) = peerIDs[nickname]
override fun peerIDsForNickname(nickname: String): List<String> =
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
}
}

View File

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

View File

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