mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-22 07:06:05 +00:00
fix: harden private group state handling
This commit is contained in:
parent
c26489f47a
commit
0795dc16b3
@ -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}$")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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}")
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user