Integrate encrypted private groups with current clients

This commit is contained in:
callebtc 2026-09-07 23:33:09 +03:00
parent 5334c08fac
commit ffa8ada6a3
30 changed files with 4068 additions and 28 deletions

View File

@ -0,0 +1,695 @@
package com.bitchat.android.groups
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.model.PeerCapabilities
import java.util.ArrayDeque
import java.util.Date
import java.util.UUID
data class GroupPeerIdentity(
val fingerprint: String,
val signingKey: ByteArray
)
data class GroupCommandResult(
val success: Boolean,
val message: String
)
enum class PeerGroupCapability {
SUPPORTED,
UNSUPPORTED,
UNKNOWN;
companion object {
fun fromPeerState(
capabilities: PeerCapabilities?,
hasVerifiedAnnouncement: Boolean
): PeerGroupCapability = when {
capabilities?.contains(PeerCapabilities.GROUPS) == true -> SUPPORTED
capabilities != null || hasVerifiedAnnouncement -> UNSUPPORTED
else -> UNKNOWN
}
}
}
interface GroupCoordinatorContext {
val groupStore: GroupStore
val nickname: String
val myPeerID: String
val selectedConversationID: String?
fun myNoiseFingerprint(): String
fun mySigningPublicKey(): ByteArray?
fun sign(data: ByteArray): ByteArray?
fun peerIDsForNickname(nickname: String): List<String>
fun isPeerConnected(peerID: String): Boolean
fun peerGroupCapability(peerID: String): PeerGroupCapability
fun peerNickname(peerID: String): String?
fun peerIdentity(peerID: String): GroupPeerIdentity?
fun connectedPeerID(fingerprint: String): String?
fun isFingerprintBlocked(fingerprint: String): Boolean
fun sendGroupInvite(payload: ByteArray, peerID: String)
fun sendGroupKeyUpdate(payload: ByteArray, peerID: String)
fun broadcastGroupMessage(payload: ByteArray)
fun appendGroupMessage(groupPeerID: String, message: BitchatMessage): Boolean
fun markGroupUnread(groupPeerID: String)
fun removeGroupConversation(groupPeerID: String)
fun openGroupConversation(groupPeerID: String)
fun closeGroupConversation()
fun addSystemMessage(message: String)
fun addGroupSystemMessage(groupPeerID: String, message: String)
fun notifyGroupMessage(groupPeerID: String, sender: String, message: String)
}
/**
* 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,
val authenticatedRemoteStaticKey: ByteArray,
val payload: ByteArray
) : PendingEvent()
data class KeyUpdate(
val peerID: String,
val authenticatedRemoteStaticKey: ByteArray,
val payload: ByteArray
) : PendingEvent()
data class Message(
val payload: ByteArray,
val receivedAtMs: Long
) : PendingEvent()
data class PeerAuthenticated(val peerID: String) : PendingEvent()
}
private 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()
val name = rawName.trim()
if (name.isEmpty()) return error("usage: /group create <name>")
if (name.codePointCount(0, name.length) > MAX_GROUP_NAME_LENGTH) {
return error("group name must be $MAX_GROUP_NAME_LENGTH characters or fewer")
}
val fingerprint = context.myNoiseFingerprint()
val signingKey = context.mySigningPublicKey()
if (!FINGERPRINT.matches(fingerprint) || signingKey?.size != 32) {
return error("your cryptographic identity is not ready")
}
val creator = GroupMember(fingerprint, signingKey, context.nickname)
val group = context.groupStore.createGroup(name, creator)
?: return error("could not create group")
context.openGroupConversation(group.peerID)
return success("created private group #${group.name}")
}
fun inviteMember(rawNickname: String): GroupCommandResult {
if (!context.groupStore.isReady) return loadingError()
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 = resolvePeer(selector) ?: return ambiguousPeerError(selector)
if (!context.isPeerConnected(peerID)) return error("$nickname is not connected")
when (context.peerGroupCapability(peerID)) {
PeerGroupCapability.SUPPORTED -> Unit
PeerGroupCapability.UNSUPPORTED ->
return error("$nickname does not support private groups")
PeerGroupCapability.UNKNOWN ->
return error("private-group support for $nickname is not confirmed yet; try again")
}
val identity = context.peerIdentity(peerID)
?: return error("$nickname does not have a verified mesh identity")
if (group.isMember(identity.fingerprint)) return error("$nickname is already a member")
if (group.members.size >= BitchatGroup.MAX_MEMBERS) {
return error("groups are limited to ${BitchatGroup.MAX_MEMBERS} members")
}
val member = GroupMember(
identity.fingerprint,
identity.signingKey,
context.peerNickname(peerID) ?: nickname
)
val (updated, key) = context.groupStore.rotateKey(
group.groupID,
group.members + member
) ?: return error("could not rotate the group key")
val payload = signedStatePayload(updated, key)
?: return error("could not sign the group invite")
context.sendGroupInvite(payload, peerID)
distributeState(payload, updated, setOf(identity.fingerprint))
return success("invited $nickname to #${updated.name}")
}
fun removeMember(rawNickname: String): GroupCommandResult {
if (!context.groupStore.isReady) return loadingError()
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 matchingMembers = group.members.filter {
it.nickname.equals(nickname, ignoreCase = true)
}.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")
}
val remaining = group.members.filterNot { it.fingerprint == member.fingerprint }
val (rotated, key) = context.groupStore.rotateKey(group.groupID, remaining)
?: return error("could not rotate the group key")
val payload = signedStatePayload(rotated, key)
?: return error("could not sign the group update")
distributeState(payload, rotated, emptySet())
notifyRemovedMember(member, rotated)
return success("removed ${member.nickname} and rotated the group key")
}
fun leaveGroup(): GroupCommandResult {
if (!context.groupStore.isReady) return loadingError()
val group = selectedGroup() ?: return error("open a private group first")
if (isCreator(group) && group.members.size > 1) {
return error("remove all other members before leaving this group")
}
val removed = if (isCreator(group)) {
context.groupStore.removeGroup(group.groupID)
} else {
context.groupStore.departGroup(group.groupID, group.epoch)
}
if (!removed) return error("could not leave group")
synchronized(lifecycleLock) {
dropFutureMessages(group.groupID)
}
context.closeGroupConversation()
context.removeGroupConversation(group.peerID)
return success("left #${group.name}")
}
fun listGroups(): GroupCommandResult {
if (!context.groupStore.isReady) return loadingError()
val groups = context.groupStore.groups.value
if (groups.isEmpty()) return success("you are not in any private groups")
val fingerprint = context.myNoiseFingerprint()
val lines = groups.joinToString("\n") { group ->
val role = if (group.creatorFingerprint == fingerprint) " (creator)" else ""
"#${group.name}$role — ${group.members.size}/${BitchatGroup.MAX_MEMBERS}"
}
return success("private groups:\n$lines")
}
fun sendMessage(content: String, groupPeerID: String) {
if (!context.groupStore.isReady) {
context.addGroupSystemMessage(groupPeerID, "private groups are still loading")
return
}
if (content.isEmpty() ||
content.codePointCount(0, content.length) > MAX_MESSAGE_LENGTH
) {
return
}
val group = context.groupStore.group(groupPeerID)
val key = group?.let { context.groupStore.key(it.groupID) }
if (group == null || key == null) {
context.addGroupSystemMessage(groupPeerID, "this private group is unavailable")
return
}
val signingKey = context.mySigningPublicKey()
if (signingKey?.size != 32) {
context.addGroupSystemMessage(groupPeerID, "your signing identity is unavailable")
return
}
val messageID = UUID.randomUUID().toString()
val timestamp = System.currentTimeMillis()
val payload = try {
GroupCrypto.sealMessage(
content = content,
messageID = messageID,
senderNickname = context.nickname,
senderSigningKey = signingKey,
timestampMs = timestamp,
groupID = group.groupID,
epoch = group.epoch,
key = key,
sign = context::sign
)
} catch (_: Exception) {
context.addGroupSystemMessage(groupPeerID, "could not encrypt group message")
return
}
context.appendGroupMessage(
groupPeerID,
BitchatMessage(
id = messageID,
sender = context.nickname,
content = content,
timestamp = Date(timestamp),
isPrivate = true,
recipientNickname = group.name,
senderPeerID = context.myPeerID,
deliveryStatus = DeliveryStatus.Sent
)
)
context.broadcastGroupMessage(payload)
}
fun handleMessage(payload: ByteArray, receivedAtMs: Long) {
val generation = inboundGeneration
if (!acceptsInboundEvents) return
synchronized(lifecycleLock) {
if (!acceptsInboundEvents || generation != inboundGeneration) return
if (deferIfLoading(PendingEvent.Message(payload.copyOf(), receivedAtMs))) return
processMessage(payload)
}
}
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) {
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)
} catch (_: Exception) {
return
}
val member = group.memberWithSigningKey(plaintext.senderSigningKey) ?: return
val ownSigningKey = context.mySigningPublicKey()
if (ownSigningKey != null && plaintext.senderSigningKey.contentEquals(ownSigningKey)) return
if (context.isFingerprintBlocked(member.fingerprint)) return
val now = System.currentTimeMillis()
val timestamp = plaintext.timestampMs.coerceIn(0, now)
val sender = member.nickname.ifBlank { plaintext.senderNickname }
val message = BitchatMessage(
id = plaintext.messageID,
sender = sender,
content = plaintext.content,
timestamp = Date(timestamp),
isPrivate = true,
recipientNickname = group.name,
senderPeerID = member.fingerprint.take(16)
)
if (!context.appendGroupMessage(group.peerID, message)) return
if (context.selectedConversationID != group.peerID) {
context.markGroupUnread(group.peerID)
if (now - timestamp < RECENT_NOTIFICATION_WINDOW_MS) {
context.notifyGroupMessage(group.peerID, "$sender @ ${group.name}", plaintext.content)
}
}
}
fun handleInvite(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
val generation = inboundGeneration
if (!acceptsInboundEvents) return
synchronized(lifecycleLock) {
if (!acceptsInboundEvents || generation != inboundGeneration) return
if (
deferIfLoading(
PendingEvent.Invite(
peerID,
authenticatedRemoteStaticKey.copyOf(),
payload.copyOf()
)
)
) {
return
}
applyState(peerID, authenticatedRemoteStaticKey, payload, isInvite = true)
}
}
fun handleKeyUpdate(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
val generation = inboundGeneration
if (!acceptsInboundEvents) return
synchronized(lifecycleLock) {
if (!acceptsInboundEvents || generation != inboundGeneration) return
if (
deferIfLoading(
PendingEvent.KeyUpdate(
peerID,
authenticatedRemoteStaticKey.copyOf(),
payload.copyOf()
)
)
) {
return
}
applyState(peerID, authenticatedRemoteStaticKey, payload, isInvite = false)
}
}
/**
* Replays the current creator-signed state after an authenticated peer
* reconnects. This uses the existing iOS GROUP_KEY_UPDATE payload and
* repairs updates that could not be delivered while the member was offline.
*/
fun handlePeerAuthenticated(peerID: String) {
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)
}
}
}
/**
* Drains packets received during asynchronous store initialization.
*/
fun onStoreReady() {
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
}
}
private fun applyState(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray,
isInvite: Boolean
) {
val state = GroupStatePayload.decode(payload) ?: return
val senderFingerprint = sha256(authenticatedRemoteStaticKey).toHex()
if (senderFingerprint != state.creatorFingerprint) return
if (!state.verifyCreatorSignature()) return
val ownFingerprint = context.myNoiseFingerprint()
val existing = context.groupStore.group(state.groupID)
// Reject stale state before interpreting a missing-self roster as a
// removal. Otherwise an old, valid removal notice could delete a
// membership restored by a later creator-signed re-invite.
if (existing != null && state.epoch < existing.epoch) return
val departureEpoch = context.groupStore.departureEpoch(state.groupID)
if (departureEpoch != null &&
(!isInvite || state.epoch <= departureEpoch)
) {
return
}
if (state.members.none { it.fingerprint == ownFingerprint }) {
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) {
context.groupStore.acceptInvite(state.asGroup(), state.key)
} else {
context.groupStore.upsert(state.asGroup(), state.key)
}
if (!stored) return
retryFutureMessages(state.groupID)
if (existing == null) {
val inviter = state.members.firstOrNull {
it.fingerprint == state.creatorFingerprint
}?.nickname ?: context.peerNickname(peerID) ?: "unknown"
val notice = "joined #${state.name}, invited by $inviter"
context.addSystemMessage(notice)
context.markGroupUnread(state.asGroup().peerID)
context.notifyGroupMessage(state.asGroup().peerID, inviter, notice)
}
}
private fun signedStatePayload(group: BitchatGroup, key: ByteArray): ByteArray? =
GroupStatePayload.makeSigned(group, key, context::sign)?.encode()
private fun distributeState(
payload: ByteArray,
group: BitchatGroup,
excludedFingerprints: Set<String>
) {
val ownFingerprint = context.myNoiseFingerprint()
group.members.forEach { member ->
if (member.fingerprint == ownFingerprint ||
member.fingerprint in excludedFingerprints
) {
return@forEach
}
context.connectedPeerID(member.fingerprint)?.let { peerID ->
context.sendGroupKeyUpdate(payload, peerID)
}
}
}
private fun notifyRemovedMember(member: GroupMember, rotated: BitchatGroup) {
val peerID = context.connectedPeerID(member.fingerprint) ?: return
val payload = signedStatePayload(rotated, ByteArray(BitchatGroup.KEY_LENGTH)) ?: return
context.sendGroupKeyUpdate(payload, peerID)
}
private fun selectedGroup(): BitchatGroup? =
context.selectedConversationID?.let(context.groupStore::group)
private fun isCreator(group: BitchatGroup): Boolean =
group.creatorFingerprint == context.myNoiseFingerprint()
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) {
if (context.groupStore.isReady) return@synchronized false
if (pendingEvents.size >= MAX_PENDING_EVENTS) pendingEvents.pollFirst()
pendingEvents.addLast(event)
true
}
private fun loadingError() =
error("private groups are still loading; try again")
private fun success(message: String) = GroupCommandResult(true, message)
private fun error(message: String) = GroupCommandResult(false, message)
private fun ByteArray.toHex(): String =
joinToString("") { "%02x".format(it) }
companion object {
private const val MAX_GROUP_NAME_LENGTH = 40
private const val MAX_MESSAGE_LENGTH = 60_000
private const val RECENT_NOTIFICATION_WINDOW_MS = 30_000L
private const val MAX_PENDING_EVENTS = 64
private 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

@ -0,0 +1,663 @@
package com.bitchat.android.groups
import com.bitchat.android.util.dataFromHexString
import com.bitchat.android.util.hexEncodedString
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
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
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
import org.bouncycastle.crypto.params.KeyParameter
import org.bouncycastle.crypto.signers.Ed25519Signer
data class GroupMember(
val fingerprint: String,
val signingKey: ByteArray,
val nickname: String
) {
override fun equals(other: Any?): Boolean =
this === other ||
(other is GroupMember &&
fingerprint == other.fingerprint &&
signingKey.contentEquals(other.signingKey) &&
nickname == other.nickname)
override fun hashCode(): Int =
31 * (31 * fingerprint.hashCode() + signingKey.contentHashCode()) + nickname.hashCode()
}
data class BitchatGroup(
val groupID: ByteArray,
val name: String,
val epoch: Long,
val members: List<GroupMember>,
val creatorFingerprint: String
) {
val peerID: String get() = GroupIds.peerID(groupID)
val creator: GroupMember? get() = members.firstOrNull { it.fingerprint == creatorFingerprint }
fun isMember(fingerprint: String): Boolean =
members.any { it.fingerprint == fingerprint }
fun memberWithSigningKey(signingKey: ByteArray): GroupMember? =
members.firstOrNull { it.signingKey.contentEquals(signingKey) }
override fun equals(other: Any?): Boolean =
this === other ||
(other is BitchatGroup &&
groupID.contentEquals(other.groupID) &&
name == other.name &&
epoch == other.epoch &&
members == other.members &&
creatorFingerprint == other.creatorFingerprint)
override fun hashCode(): Int {
var result = groupID.contentHashCode()
result = 31 * result + name.hashCode()
result = 31 * result + epoch.hashCode()
result = 31 * result + members.hashCode()
result = 31 * result + creatorFingerprint.hashCode()
return result
}
companion object {
const val MAX_MEMBERS = 16
const val GROUP_ID_LENGTH = 16
const val KEY_LENGTH = 32
const val MAX_EPOCH = 0xffff_ffffL
}
}
object GroupIds {
private const val PREFIX = "group_"
private val pattern = Regex("^group_[0-9a-f]{32}$")
fun peerID(groupID: ByteArray): String {
require(groupID.size == BitchatGroup.GROUP_ID_LENGTH)
return PREFIX + groupID.hexEncodedString()
}
fun groupID(peerID: String): ByteArray? {
val normalized = peerID.lowercase()
if (!pattern.matches(normalized)) return null
return normalized.removePrefix(PREFIX).dataFromHexString()
}
fun isGroup(peerID: String?): Boolean =
peerID != null && pattern.matches(peerID.lowercase())
}
class GroupTlvValueTooLongException : IllegalArgumentException("group TLV value exceeds UInt16")
internal object GroupTLV {
data class Field(val type: Int, val value: ByteArray)
fun put(type: Int, value: ByteArray, output: ByteArrayOutputStream) {
if (value.size > 0xffff) throw GroupTlvValueTooLongException()
output.write(type and 0xff)
output.write((value.size ushr 8) and 0xff)
output.write(value.size and 0xff)
output.write(value)
}
fun encode(vararg fields: Pair<Int, ByteArray>): ByteArray {
val output = ByteArrayOutputStream()
fields.forEach { (type, value) -> put(type, value, output) }
return output.toByteArray()
}
fun parse(data: ByteArray): List<Field>? {
val fields = mutableListOf<Field>()
var offset = 0
while (offset < data.size) {
if (offset + 3 > data.size) return null
val type = data[offset].toInt() and 0xff
val length =
((data[offset + 1].toInt() and 0xff) shl 8) or
(data[offset + 2].toInt() and 0xff)
offset += 3
if (offset + length > data.size) return null
fields += Field(type, data.copyOfRange(offset, offset + length))
offset += length
}
return fields
}
fun epochData(epoch: Long): ByteArray {
require(epoch in 0..BitchatGroup.MAX_EPOCH)
return ByteBuffer.allocate(Int.SIZE_BYTES)
.order(ByteOrder.BIG_ENDIAN)
.putInt(epoch.toInt())
.array()
}
fun epoch(data: ByteArray): Long? {
if (data.size != Int.SIZE_BYTES) return null
return ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN).int.toLong() and 0xffff_ffffL
}
fun timestampData(timestampMs: Long): ByteArray =
ByteBuffer.allocate(Long.SIZE_BYTES)
.order(ByteOrder.BIG_ENDIAN)
.putLong(timestampMs)
.array()
fun timestamp(data: ByteArray): Long? {
if (data.size != Long.SIZE_BYTES) return null
return ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN).long
}
fun strictUtf8(data: ByteArray): String? = try {
Charsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(data))
.toString()
} catch (_: Exception) {
null
}
}
object GroupRosterCoding {
private const val FINGERPRINT_LENGTH = 32
private const val SIGNING_KEY_LENGTH = 32
private const val MAX_NICKNAME_BYTES = 64
fun encode(members: List<GroupMember>): ByteArray? {
if (members.size > BitchatGroup.MAX_MEMBERS) return null
val output = ByteArrayOutputStream()
output.write(members.size)
members.forEach { member ->
val fingerprint = member.fingerprint.dataFromHexString()
if (fingerprint?.size != FINGERPRINT_LENGTH ||
member.signingKey.size != SIGNING_KEY_LENGTH
) {
return null
}
output.write(fingerprint)
output.write(member.signingKey)
val nickname = truncatedNicknameBytes(member.nickname)
output.write(nickname.size)
output.write(nickname)
}
return output.toByteArray()
}
fun decode(data: ByteArray): List<GroupMember>? {
if (data.isEmpty()) return null
val count = data[0].toInt() and 0xff
if (count > BitchatGroup.MAX_MEMBERS) return null
val members = mutableListOf<GroupMember>()
var offset = 1
repeat(count) {
val fixedLength = FINGERPRINT_LENGTH + SIGNING_KEY_LENGTH + 1
if (offset + fixedLength > data.size) return null
val fingerprint =
data.copyOfRange(offset, offset + FINGERPRINT_LENGTH).hexEncodedString()
offset += FINGERPRINT_LENGTH
val signingKey = data.copyOfRange(offset, offset + SIGNING_KEY_LENGTH)
offset += SIGNING_KEY_LENGTH
val nicknameLength = data[offset].toInt() and 0xff
offset += 1
if (offset + nicknameLength > data.size) return null
val nickname = GroupTLV.strictUtf8(
data.copyOfRange(offset, offset + nicknameLength)
) ?: return null
offset += nicknameLength
members += GroupMember(fingerprint, signingKey, nickname)
}
if (offset != data.size) return null
return members
}
private fun truncatedNicknameBytes(nickname: String): ByteArray {
val output = StringBuilder()
var offset = 0
while (offset < nickname.length) {
val codePoint = nickname.codePointAt(offset)
val candidate = output.toString() + String(Character.toChars(codePoint))
if (candidate.toByteArray(Charsets.UTF_8).size > MAX_NICKNAME_BYTES) break
output.appendCodePoint(codePoint)
offset += Character.charCount(codePoint)
}
return output.toString().toByteArray(Charsets.UTF_8)
}
}
class GroupStatePayload(
val groupID: ByteArray,
val name: String,
val key: ByteArray,
val epoch: Long,
val members: List<GroupMember>,
val creatorFingerprint: String,
val signature: ByteArray
) {
fun encode(): ByteArray? {
val roster = GroupRosterCoding.encode(members) ?: return null
val creator = creatorFingerprint.dataFromHexString()
if (creator?.size != 32) return null
return try {
GroupTLV.encode(
FIELD_GROUP_ID to groupID,
FIELD_NAME to name.toByteArray(Charsets.UTF_8),
FIELD_KEY to key,
FIELD_EPOCH to GroupTLV.epochData(epoch),
FIELD_ROSTER to roster,
FIELD_CREATOR_FINGERPRINT to creator,
FIELD_SIGNATURE to signature
)
} catch (_: GroupTlvValueTooLongException) {
null
}
}
fun verifyCreatorSignature(): Boolean {
if (members.size > BitchatGroup.MAX_MEMBERS) return false
val creator = members.firstOrNull { it.fingerprint == creatorFingerprint } ?: return false
val roster = GroupRosterCoding.encode(members) ?: return false
return GroupCrypto.verify(
signature,
signingContent(groupID, epoch, key, roster, name),
creator.signingKey
)
}
fun asGroup(): BitchatGroup =
BitchatGroup(groupID, name, epoch, members, creatorFingerprint)
override fun equals(other: Any?): Boolean =
this === other ||
(other is GroupStatePayload &&
groupID.contentEquals(other.groupID) &&
name == other.name &&
key.contentEquals(other.key) &&
epoch == other.epoch &&
members == other.members &&
creatorFingerprint == other.creatorFingerprint &&
signature.contentEquals(other.signature))
override fun hashCode(): Int {
var result = Arrays.hashCode(groupID)
result = 31 * result + name.hashCode()
result = 31 * result + Arrays.hashCode(key)
result = 31 * result + epoch.hashCode()
result = 31 * result + members.hashCode()
result = 31 * result + creatorFingerprint.hashCode()
result = 31 * result + Arrays.hashCode(signature)
return result
}
companion object {
private const val FIELD_GROUP_ID = 0x01
private const val FIELD_NAME = 0x02
private const val FIELD_KEY = 0x03
private const val FIELD_EPOCH = 0x04
private const val FIELD_ROSTER = 0x05
private const val FIELD_CREATOR_FINGERPRINT = 0x06
private const val FIELD_SIGNATURE = 0x07
private val SIGNING_DOMAIN = "bitchat-group-v1".toByteArray(Charsets.UTF_8)
fun signingContent(
groupID: ByteArray,
epoch: Long,
key: ByteArray,
rosterBlob: ByteArray,
name: String
): ByteArray = concat(
SIGNING_DOMAIN,
groupID,
GroupTLV.epochData(epoch),
sha256(key),
sha256(rosterBlob),
sha256(name.toByteArray(Charsets.UTF_8))
)
fun makeSigned(
group: BitchatGroup,
key: ByteArray,
sign: (ByteArray) -> ByteArray?
): GroupStatePayload? {
val roster = GroupRosterCoding.encode(group.members) ?: return null
val signature = sign(
signingContent(group.groupID, group.epoch, key, roster, group.name)
) ?: return null
if (signature.size != 64) return null
return GroupStatePayload(
group.groupID,
group.name,
key,
group.epoch,
group.members,
group.creatorFingerprint,
signature
)
}
fun decode(data: ByteArray): GroupStatePayload? {
val fields = GroupTLV.parse(data) ?: return null
var groupID: ByteArray? = null
var name: String? = null
var key: ByteArray? = null
var epoch: Long? = null
var members: List<GroupMember>? = null
var creatorFingerprint: String? = null
var signature: ByteArray? = null
fields.forEach { field ->
when (field.type) {
FIELD_GROUP_ID ->
if (field.value.size == BitchatGroup.GROUP_ID_LENGTH) groupID = field.value
FIELD_NAME -> name = GroupTLV.strictUtf8(field.value)
FIELD_KEY ->
if (field.value.size == BitchatGroup.KEY_LENGTH) key = field.value
FIELD_EPOCH -> epoch = GroupTLV.epoch(field.value)
FIELD_ROSTER -> members = GroupRosterCoding.decode(field.value)
FIELD_CREATOR_FINGERPRINT ->
if (field.value.size == 32) creatorFingerprint = field.value.hexEncodedString()
FIELD_SIGNATURE -> if (field.value.size == 64) signature = field.value
}
}
val decodedMembers = members ?: return null
if (decodedMembers.isEmpty()) return null
return GroupStatePayload(
groupID ?: return null,
name ?: return null,
key ?: return null,
epoch ?: return null,
decodedMembers,
creatorFingerprint ?: return null,
signature ?: return null
)
}
}
}
class GroupMessageEnvelope(
val groupID: ByteArray,
val epoch: Long,
val nonce: ByteArray,
val ciphertext: ByteArray
) {
fun encode(): ByteArray = GroupTLV.encode(
FIELD_GROUP_ID to groupID,
FIELD_EPOCH to GroupTLV.epochData(epoch),
FIELD_NONCE to nonce,
FIELD_CIPHERTEXT to ciphertext
)
override fun equals(other: Any?): Boolean =
this === other ||
(other is GroupMessageEnvelope &&
groupID.contentEquals(other.groupID) &&
epoch == other.epoch &&
nonce.contentEquals(other.nonce) &&
ciphertext.contentEquals(other.ciphertext))
override fun hashCode(): Int {
var result = groupID.contentHashCode()
result = 31 * result + epoch.hashCode()
result = 31 * result + nonce.contentHashCode()
result = 31 * result + ciphertext.contentHashCode()
return result
}
companion object {
private const val FIELD_GROUP_ID = 0x01
private const val FIELD_EPOCH = 0x02
private const val FIELD_NONCE = 0x03
private const val FIELD_CIPHERTEXT = 0x04
fun decode(data: ByteArray): GroupMessageEnvelope? {
val fields = GroupTLV.parse(data) ?: return null
var groupID: ByteArray? = null
var epoch: Long? = null
var nonce: ByteArray? = null
var ciphertext: ByteArray? = null
fields.forEach { field ->
when (field.type) {
FIELD_GROUP_ID ->
if (field.value.size == BitchatGroup.GROUP_ID_LENGTH) groupID = field.value
FIELD_EPOCH -> epoch = GroupTLV.epoch(field.value)
FIELD_NONCE -> if (field.value.size == 12) nonce = field.value
FIELD_CIPHERTEXT -> if (field.value.isNotEmpty()) ciphertext = field.value
}
}
return GroupMessageEnvelope(
groupID ?: return null,
epoch ?: return null,
nonce ?: return null,
ciphertext ?: return null
)
}
}
}
data class GroupMessagePlaintext(
val messageID: String,
val senderSigningKey: ByteArray,
val senderNickname: String,
val timestampMs: Long,
val content: String
) {
override fun equals(other: Any?): Boolean =
this === other ||
(other is GroupMessagePlaintext &&
messageID == other.messageID &&
senderSigningKey.contentEquals(other.senderSigningKey) &&
senderNickname == other.senderNickname &&
timestampMs == other.timestampMs &&
content == other.content)
override fun hashCode(): Int {
var result = messageID.hashCode()
result = 31 * result + senderSigningKey.contentHashCode()
result = 31 * result + senderNickname.hashCode()
result = 31 * result + timestampMs.hashCode()
result = 31 * result + content.hashCode()
return result
}
}
sealed class GroupCryptoException(message: String) : Exception(message) {
class MalformedPayload : GroupCryptoException("malformed group payload")
class SigningFailed : GroupCryptoException("group message signing failed")
class SealFailed : GroupCryptoException("group message sealing failed")
class DecryptionFailed : GroupCryptoException("group message decryption failed")
class BadSenderSignature : GroupCryptoException("bad group sender signature")
}
object GroupCrypto {
private const val FIELD_MESSAGE_ID = 0x01
private const val FIELD_SENDER_SIGNING_KEY = 0x02
private const val FIELD_SENDER_NICKNAME = 0x03
private const val FIELD_TIMESTAMP = 0x04
private const val FIELD_CONTENT = 0x05
private const val FIELD_SIGNATURE = 0x06
private val MESSAGE_SIGNING_DOMAIN =
"bitchat-group-msg-v1".toByteArray(Charsets.UTF_8)
private val random = SecureRandom()
fun messageSigningContent(
groupID: ByteArray,
epoch: Long,
messageID: String,
timestampMs: Long,
content: String
): ByteArray = concat(
MESSAGE_SIGNING_DOMAIN,
groupID,
GroupTLV.epochData(epoch),
messageID.toByteArray(Charsets.UTF_8),
GroupTLV.timestampData(timestampMs),
content.toByteArray(Charsets.UTF_8)
)
fun verify(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean {
if (signature.size != 64 || publicKey.size != 32) return false
return try {
val verifier = Ed25519Signer()
verifier.init(false, Ed25519PublicKeyParameters(publicKey, 0))
verifier.update(data, 0, data.size)
verifier.verifySignature(signature)
} catch (_: Exception) {
false
}
}
@Throws(GroupCryptoException::class, GroupTlvValueTooLongException::class)
fun sealMessage(
content: String,
messageID: String,
senderNickname: String,
senderSigningKey: ByteArray,
timestampMs: Long,
groupID: ByteArray,
epoch: Long,
key: ByteArray,
sign: (ByteArray) -> ByteArray?
): ByteArray {
if (!isCanonicalMessageID(messageID)) {
throw GroupCryptoException.MalformedPayload()
}
val signature = sign(
messageSigningContent(groupID, epoch, messageID, timestampMs, content)
)
if (signature?.size != 64) throw GroupCryptoException.SigningFailed()
val inner = GroupTLV.encode(
FIELD_MESSAGE_ID to messageID.toByteArray(Charsets.UTF_8),
FIELD_SENDER_SIGNING_KEY to senderSigningKey,
FIELD_SENDER_NICKNAME to senderNickname.toByteArray(Charsets.UTF_8),
FIELD_TIMESTAMP to GroupTLV.timestampData(timestampMs),
FIELD_CONTENT to content.toByteArray(Charsets.UTF_8),
FIELD_SIGNATURE to signature
)
if (key.size != BitchatGroup.KEY_LENGTH ||
groupID.size != BitchatGroup.GROUP_ID_LENGTH
) {
throw GroupCryptoException.SealFailed()
}
return try {
val nonce = ByteArray(12).also(random::nextBytes)
val aad = concat(groupID, GroupTLV.epochData(epoch))
val ciphertext = crypt(encrypt = true, key, nonce, aad, inner)
GroupMessageEnvelope(groupID, epoch, nonce, ciphertext).encode()
} catch (error: GroupTlvValueTooLongException) {
throw error
} catch (_: Exception) {
throw GroupCryptoException.SealFailed()
}
}
@Throws(GroupCryptoException::class)
fun openMessage(envelope: GroupMessageEnvelope, key: ByteArray): GroupMessagePlaintext {
if (key.size != BitchatGroup.KEY_LENGTH || envelope.ciphertext.size <= 16) {
throw GroupCryptoException.DecryptionFailed()
}
val inner = try {
crypt(
encrypt = false,
key,
envelope.nonce,
concat(envelope.groupID, GroupTLV.epochData(envelope.epoch)),
envelope.ciphertext
)
} catch (_: Exception) {
throw GroupCryptoException.DecryptionFailed()
}
val fields = GroupTLV.parse(inner) ?: throw GroupCryptoException.MalformedPayload()
var messageID: String? = null
var senderSigningKey: ByteArray? = null
var senderNickname: String? = null
var timestampMs: Long? = null
var content: String? = null
var signature: ByteArray? = null
fields.forEach { field ->
when (field.type) {
FIELD_MESSAGE_ID -> messageID = GroupTLV.strictUtf8(field.value)
FIELD_SENDER_SIGNING_KEY ->
if (field.value.size == 32) senderSigningKey = field.value
FIELD_SENDER_NICKNAME -> senderNickname = GroupTLV.strictUtf8(field.value)
FIELD_TIMESTAMP -> timestampMs = GroupTLV.timestamp(field.value)
FIELD_CONTENT -> content = GroupTLV.strictUtf8(field.value)
FIELD_SIGNATURE -> if (field.value.size == 64) signature = field.value
}
}
val decodedMessageID = messageID?.takeIf(::isCanonicalMessageID)
?: throw GroupCryptoException.MalformedPayload()
val decodedSigningKey = senderSigningKey ?: throw GroupCryptoException.MalformedPayload()
val decodedNickname = senderNickname ?: throw GroupCryptoException.MalformedPayload()
val decodedTimestamp = timestampMs ?: throw GroupCryptoException.MalformedPayload()
val decodedContent = content ?: throw GroupCryptoException.MalformedPayload()
val decodedSignature = signature ?: throw GroupCryptoException.MalformedPayload()
val signingContent = messageSigningContent(
envelope.groupID,
envelope.epoch,
decodedMessageID,
decodedTimestamp,
decodedContent
)
if (!verify(decodedSignature, signingContent, decodedSigningKey)) {
throw GroupCryptoException.BadSenderSignature()
}
return GroupMessagePlaintext(
decodedMessageID,
decodedSigningKey,
decodedNickname,
decodedTimestamp,
decodedContent
)
}
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,
key: ByteArray,
nonce: ByteArray,
aad: ByteArray,
input: ByteArray
): ByteArray {
val cipher = ChaCha20Poly1305()
cipher.init(encrypt, AEADParameters(KeyParameter(key), 128, nonce, aad))
val output = ByteArray(cipher.getOutputSize(input.size))
var length = cipher.processBytes(input, 0, input.size, output, 0)
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 =
MessageDigest.getInstance("SHA-256").digest(data)
internal fun concat(vararg arrays: ByteArray): ByteArray {
val output = ByteArrayOutputStream(arrays.sumOf(ByteArray::size))
arrays.forEach(output::write)
return output.toByteArray()
}

View File

@ -0,0 +1,552 @@
package com.bitchat.android.groups
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.util.Base64
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.bitchat.android.util.hexEncodedString
import com.google.gson.Gson
import com.google.gson.JsonParser
import com.google.gson.reflect.TypeToken
import java.io.File
import java.io.FileOutputStream
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.SecureRandom
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
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 {
fun read(): String?
fun write(contents: String): Boolean
fun delete(): Boolean
}
@SuppressLint("ApplySharedPref", "UseKtx")
private class EncryptedPreferencesGroupKeyStorage(context: Context) : GroupKeyStorage {
private val preferences: SharedPreferences
init {
val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
preferences = EncryptedSharedPreferences.create(
context,
"bitchat_private_groups",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
override fun get(key: String): ByteArray? = try {
preferences.getString(key, null)?.let {
Base64.decode(it, Base64.NO_WRAP)
}
} catch (_: Exception) {
null
}
override fun put(key: String, value: ByteArray): Boolean = try {
// The metadata must not advance unless the epoch key is durably stored.
preferences.edit()
.putString(key, Base64.encodeToString(value, Base64.NO_WRAP))
.commit()
} catch (_: Exception) {
false
}
override fun remove(key: String): Boolean = try {
// Removal is a security boundary, so report the synchronous result.
preferences.edit().remove(key).commit()
} 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 {
override fun read(): String? = try {
file.takeIf(File::exists)?.readText(Charsets.UTF_8)
} catch (_: Exception) {
null
}
override fun write(contents: String): Boolean {
var temporary: File? = null
return try {
val parent = file.parentFile
if (parent != null && !parent.exists() && !parent.mkdirs()) return false
temporary = File(parent, "${file.name}.tmp")
FileOutputStream(temporary).use { output ->
output.write(contents.toByteArray(Charsets.UTF_8))
output.fd.sync()
}
try {
Files.move(
temporary.toPath(),
file.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(
temporary.toPath(),
file.toPath(),
StandardCopyOption.REPLACE_EXISTING
)
}
true
} catch (_: Exception) {
temporary?.delete()
false
}
}
override fun delete(): Boolean = try {
val deleted = !file.exists() || file.delete()
if (deleted) {
file.parentFile
?.takeIf { it.listFiles().isNullOrEmpty() }
?.delete()
}
deleted
} catch (_: Exception) {
false
}
}
/**
* Persistent private-group metadata and epoch keys.
*
* Metadata is kept in app-private no-backup storage. Symmetric group keys are
* stored separately in EncryptedSharedPreferences backed by Android Keystore.
*
* The Android constructor is intentionally inert. [initialize] performs
* Keystore and disk access and must be called from a background dispatcher.
*/
class GroupStore private constructor(
private val keyStorageFactory: () -> GroupKeyStorage,
private val metadataStorageFactory: () -> GroupMetadataStorage?,
autoInitialize: Boolean
) {
private data class StoredMember(
val fingerprint: String,
val signingKey: String,
val nickname: String
)
private data class StoredGroup(
val groupID: String,
val name: String,
val epoch: Long,
val members: List<StoredMember>,
val creatorFingerprint: String
)
private data class StoredState(
val version: Int = 1,
val groups: List<StoredGroup>,
val departures: Map<String, Long>
)
private val lock = Any()
private val gson = Gson()
private val random by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { SecureRandom() }
private val _groups = MutableStateFlow<List<BitchatGroup>>(emptyList())
private val departures = mutableMapOf<String, Long>()
private var keyStorage: GroupKeyStorage? = null
private var metadataStorage: GroupMetadataStorage? = null
@Volatile
private var initialized = false
val groups: StateFlow<List<BitchatGroup>> = _groups.asStateFlow()
val isReady: Boolean
get() = initialized
constructor(context: Context) : this(
keyStorageFactory = {
EncryptedPreferencesGroupKeyStorage(context.applicationContext)
},
metadataStorageFactory = {
FileGroupMetadataStorage(
File(context.applicationContext.noBackupFilesDir, "groups/groups.json")
)
},
autoInitialize = false
)
internal constructor(
keyStorage: GroupKeyStorage,
metadataFile: File? = null,
testOnly: Boolean,
autoInitialize: Boolean = true
) : this(
keyStorageFactory = { keyStorage },
metadataStorageFactory = {
metadataFile?.let(::FileGroupMetadataStorage)
},
autoInitialize = autoInitialize
) {
require(testOnly)
}
internal constructor(
keyStorage: GroupKeyStorage,
metadataStorage: GroupMetadataStorage,
testOnly: Boolean,
autoInitialize: Boolean = true
) : this(
keyStorageFactory = { keyStorage },
metadataStorageFactory = { metadataStorage },
autoInitialize = autoInitialize
) {
require(testOnly)
}
init {
if (autoInitialize) initialize()
}
/**
* Initializes encrypted key storage and loads metadata. Callers using the
* Android constructor must invoke this away from the main thread.
*/
fun initialize(): Boolean = synchronized(lock) {
if (initialized) return@synchronized true
val keys = try {
keyStorageFactory()
} catch (error: Exception) {
Log.e(TAG, "Failed to initialize private-group key storage", error)
return@synchronized false
}
val metadata = try {
metadataStorageFactory()
} catch (error: Exception) {
Log.e(TAG, "Failed to initialize private-group metadata storage", error)
return@synchronized false
}
keyStorage = keys
metadataStorage = metadata
loadLocked(keys, metadata)
initialized = true
true
}
fun group(groupID: ByteArray): BitchatGroup? = synchronized(lock) {
_groups.value.firstOrNull { it.groupID.contentEquals(groupID) }?.deepCopy()
}
fun group(peerID: String): BitchatGroup? =
GroupIds.groupID(peerID)?.let(::group)
fun key(groupID: ByteArray): ByteArray? = synchronized(lock) {
keyStorage
?.get(keyName(groupID))
?.takeIf { it.size == BitchatGroup.KEY_LENGTH }
?.copyOf()
}
fun departureEpoch(groupID: ByteArray): Long? = synchronized(lock) {
departures[groupID.hexEncodedString()]
}
fun createGroup(name: String, creator: GroupMember): BitchatGroup? {
if (!isReady) return null
val groupID = ByteArray(BitchatGroup.GROUP_ID_LENGTH).also(random::nextBytes)
val key = ByteArray(BitchatGroup.KEY_LENGTH).also(random::nextBytes)
val group = BitchatGroup(
groupID = groupID,
name = name,
epoch = 1,
members = listOf(creator),
creatorFingerprint = creator.fingerprint
)
return group.takeIf { upsert(it, key) }
}
fun upsert(group: BitchatGroup, key: ByteArray): Boolean = synchronized(lock) {
upsertLocked(group, key, clearDeparture = false)
}
fun acceptInvite(group: BitchatGroup, key: ByteArray): Boolean = synchronized(lock) {
upsertLocked(group, key, clearDeparture = true)
}
fun rotateKey(
groupID: ByteArray,
members: List<GroupMember>
): Pair<BitchatGroup, ByteArray>? = synchronized(lock) {
if (!initialized) return@synchronized null
val existing = _groups.value.firstOrNull { it.groupID.contentEquals(groupID) }
?: return@synchronized null
val newKey = ByteArray(BitchatGroup.KEY_LENGTH).also(random::nextBytes)
val rotated = existing.copy(
epoch = (existing.epoch + 1) and BitchatGroup.MAX_EPOCH,
members = members.map { it.deepCopy() }
)
if (!upsertLocked(rotated, newKey, clearDeparture = false)) {
return@synchronized null
}
rotated.deepCopy() to newKey.copyOf()
}
fun removeGroup(groupID: ByteArray): Boolean = synchronized(lock) {
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(): 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()
keysCleared && metadataDeleted
}
private fun upsertLocked(
group: BitchatGroup,
key: ByteArray,
clearDeparture: Boolean
): Boolean {
val keys = keyStorage ?: return false
if (!initialized || !isValid(group, key)) return false
val updatedGroups = _groups.value.toMutableList()
val index = updatedGroups.indexOfFirst { it.groupID.contentEquals(group.groupID) }
if (index >= 0 && group.epoch < updatedGroups[index].epoch) return false
if (index >= 0) {
updatedGroups[index] = group.deepCopy()
} else {
updatedGroups += group.deepCopy()
}
val updatedDepartures = departures.toMutableMap()
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()
if (!keys.put(name, key.copyOf())) {
Log.e(TAG, "Failed to store private-group epoch key")
return false
}
if (!persistLocked(updatedGroups, updatedDepartures)) {
restoreKey(keys, name, previousKey)
return false
}
departures.clear()
departures.putAll(updatedDepartures)
_groups.value = updatedGroups.map { it.deepCopy() }
return true
}
private fun removeLocked(groupID: ByteArray, departureEpoch: Long?): Boolean {
val keys = keyStorage ?: return false
if (!initialized) return false
val updatedGroups = _groups.value.filterNot { it.groupID.contentEquals(groupID) }
val updatedDepartures = departures.toMutableMap()
if (departureEpoch != null) {
val id = groupID.hexEncodedString()
updatedDepartures[id] = maxOf(updatedDepartures[id] ?: -1, departureEpoch)
}
val name = keyName(groupID)
val previousKey = keys.get(name)?.copyOf()
if (previousKey != null && !keys.remove(name)) {
Log.e(TAG, "Failed to remove private-group epoch key")
return false
}
if (!persistLocked(updatedGroups, updatedDepartures)) {
restoreKey(keys, name, previousKey)
return false
}
departures.clear()
departures.putAll(updatedDepartures)
_groups.value = updatedGroups.map { it.deepCopy() }
return true
}
private fun restoreKey(
storage: GroupKeyStorage,
name: String,
previousKey: ByteArray?
) {
val restored = if (previousKey == null) {
storage.remove(name)
} else {
storage.put(name, previousKey)
}
if (!restored && previousKey != null) {
Log.e(TAG, "Failed to restore private-group epoch key after metadata failure")
}
}
private fun isValid(group: BitchatGroup, key: ByteArray): Boolean =
group.groupID.size == BitchatGroup.GROUP_ID_LENGTH &&
key.size == BitchatGroup.KEY_LENGTH &&
group.epoch in 0..BitchatGroup.MAX_EPOCH &&
group.members.isNotEmpty() &&
group.members.size <= BitchatGroup.MAX_MEMBERS &&
group.creator != null &&
group.members.all {
it.fingerprint.matches(Regex("^[0-9a-fA-F]{64}$")) &&
it.signingKey.size == 32
}
private fun persistLocked(
groups: List<BitchatGroup>,
departures: Map<String, Long>
): Boolean {
val storage = metadataStorage ?: return true
if (groups.isEmpty() && departures.isEmpty()) return storage.delete()
val state = StoredState(
groups = groups.map { group ->
StoredGroup(
groupID = Base64.encodeToString(group.groupID, Base64.NO_WRAP),
name = group.name,
epoch = group.epoch,
members = group.members.map { member ->
StoredMember(
fingerprint = member.fingerprint,
signingKey = Base64.encodeToString(
member.signingKey,
Base64.NO_WRAP
),
nickname = member.nickname
)
},
creatorFingerprint = group.creatorFingerprint
)
},
departures = departures.toSortedMap()
)
val persisted = storage.write(gson.toJson(state))
if (!persisted) Log.e(TAG, "Failed to persist private-group metadata")
return persisted
}
private fun loadLocked(
keys: GroupKeyStorage,
metadata: GroupMetadataStorage?
) {
val raw = metadata?.read() ?: return
val parsed = try {
val json = JsonParser.parseString(raw)
if (json.isJsonArray) {
val type = object : TypeToken<List<StoredGroup>>() {}.type
StoredState(groups = gson.fromJson(json, type), departures = emptyMap())
} else {
val objectValue = json.asJsonObject
val groupType = object : TypeToken<List<StoredGroup>>() {}.type
val departureType = object : TypeToken<Map<String, Long>>() {}.type
StoredState(
version = objectValue.get("version")?.asInt ?: 1,
groups = objectValue.get("groups")?.let {
gson.fromJson(it, groupType)
} ?: emptyList(),
departures = objectValue.get("departures")?.let {
gson.fromJson(it, departureType)
} ?: emptyMap()
)
}
} catch (_: Exception) {
null
} ?: return
departures.clear()
parsed.departures.forEach { (groupID, epoch) ->
if (GROUP_ID_HEX.matches(groupID) && epoch in 0..BitchatGroup.MAX_EPOCH) {
departures[groupID.lowercase()] = epoch
}
}
_groups.value = parsed.groups.mapNotNull { item ->
try {
val group = BitchatGroup(
groupID = Base64.decode(item.groupID, Base64.NO_WRAP),
name = item.name,
epoch = item.epoch,
members = item.members.map { member ->
GroupMember(
member.fingerprint,
Base64.decode(member.signingKey, Base64.NO_WRAP),
member.nickname
)
},
creatorFingerprint = item.creatorFingerprint
)
if (departures.containsKey(group.groupID.hexEncodedString())) {
keys.remove(keyName(group.groupID))
return@mapNotNull null
}
val storedKey = keys.get(keyName(group.groupID))
?.takeIf { it.size == BitchatGroup.KEY_LENGTH }
?: return@mapNotNull null
group.takeIf { isValid(it, storedKey) }?.deepCopy()
} catch (_: Exception) {
null
}
}
}
private fun keyName(groupID: ByteArray): String =
"groupKey-${groupID.hexEncodedString()}"
private fun BitchatGroup.deepCopy(): BitchatGroup = copy(
groupID = groupID.copyOf(),
members = members.map { it.deepCopy() }
)
private fun GroupMember.deepCopy(): GroupMember =
copy(signingKey = signingKey.copyOf())
companion object {
private const val TAG = "GroupStore"
private val GROUP_ID_HEX = Regex("^[0-9a-fA-F]{32}$")
}
}

View File

@ -557,6 +557,26 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
}
override fun onGroupInviteReceived(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
delegate?.didReceiveGroupInvite(peerID, authenticatedRemoteStaticKey, payload)
}
override fun onGroupKeyUpdateReceived(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
delegate?.didReceiveGroupKeyUpdate(peerID, authenticatedRemoteStaticKey, payload)
}
override fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) {
delegate?.didReceiveGroupMessage(payload, timestampMs)
}
}
// PacketProcessor delegates
@ -636,6 +656,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun handleVoiceFrame(routed: RoutedPacket): Boolean =
messageHandler.handlePublicVoiceFrame(routed)
override fun handleGroupMessage(routed: RoutedPacket) {
messageHandler.handleGroupMessage(routed)
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
}
override fun handleLeave(routed: RoutedPacket) {
serviceScope.launch { messageHandler.handleLeave(routed) }
@ -1227,6 +1251,42 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
sendNoisePayloadToPeer(payload, peerID, "verify response")
}
fun sendGroupInvite(payload: ByteArray, recipientPeerID: String) {
sendNoisePayloadToPeer(
NoisePayload(NoisePayloadType.GROUP_INVITE, payload),
recipientPeerID,
"group invite"
)
}
fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String) {
sendNoisePayloadToPeer(
NoisePayload(NoisePayloadType.GROUP_KEY_UPDATE, payload),
recipientPeerID,
"group key update"
)
}
fun broadcastGroupMessage(payload: ByteArray) {
if (payload.isEmpty()) return
serviceScope.launch {
val packet = BitchatPacket(
version = if (payload.size > 0xffff) 2u else 1u,
type = MessageType.GROUP_MESSAGE.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = SpecialRecipients.BROADCAST,
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
signature = null,
ttl = MAX_TTL
)
// The outer packet is intentionally unsigned. Authenticity is
// verified from the Ed25519 signature inside the ciphertext.
broadcastRoutedPacket(RoutedPacket(packet))
try { gossipSyncManager.onPublicPacketSeen(packet) } catch (_: Exception) { }
}
}
private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String, label: String) {
serviceScope.launch {
try {
@ -1689,6 +1749,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
fun getStaticNoisePublicKey(): ByteArray? {
return encryptionService.getStaticPublicKey()
}
fun getSigningPublicKey(): ByteArray? =
encryptionService.getSigningPublicKey()?.copyOf()
fun signData(data: ByteArray): ByteArray? =
encryptionService.signData(data)
/**
* Check if encryption icon should be shown for a peer

View File

@ -437,6 +437,26 @@ class MeshCore(
override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
}
override fun onGroupInviteReceived(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
delegate?.didReceiveGroupInvite(peerID, authenticatedRemoteStaticKey, payload)
}
override fun onGroupKeyUpdateReceived(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
delegate?.didReceiveGroupKeyUpdate(peerID, authenticatedRemoteStaticKey, payload)
}
override fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) {
delegate?.didReceiveGroupMessage(payload, timestampMs)
}
}
packetProcessor.delegate = object : PacketProcessorDelegate {
@ -491,6 +511,10 @@ class MeshCore(
override fun handleVoiceFrame(routed: RoutedPacket): Boolean =
messageHandler.handlePublicVoiceFrame(routed)
override fun handleGroupMessage(routed: RoutedPacket) {
messageHandler.handleGroupMessage(routed)
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
}
override fun handleLeave(routed: RoutedPacket) {
scope.launch { messageHandler.handleLeave(routed) }
@ -800,6 +824,38 @@ class MeshCore(
sendNoisePayloadToPeer(payload, peerID)
}
fun sendGroupInvite(payload: ByteArray, recipientPeerID: String) {
sendNoisePayloadToPeer(
NoisePayload(NoisePayloadType.GROUP_INVITE, payload),
recipientPeerID
)
}
fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String) {
sendNoisePayloadToPeer(
NoisePayload(NoisePayloadType.GROUP_KEY_UPDATE, payload),
recipientPeerID
)
}
fun broadcastGroupMessage(payload: ByteArray) {
if (payload.isEmpty()) return
scope.launch {
val packet = BitchatPacket(
version = if (payload.size > 0xffff) 2u else 1u,
type = MessageType.GROUP_MESSAGE.value,
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
recipientID = SpecialRecipients.BROADCAST,
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
signature = null,
ttl = maxTtl
)
dispatchGlobal(RoutedPacket(packet))
try { gossipSyncManager.onPublicPacketSeen(packet) } catch (_: Exception) { }
}
}
private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String) {
scope.launch {
try {
@ -993,6 +1049,12 @@ class MeshCore(
fun getStaticNoisePublicKey(): ByteArray? = encryptionService.getStaticPublicKey()
fun getSigningPublicKey(): ByteArray? =
encryptionService.getSigningPublicKey()?.copyOf()
fun signData(data: ByteArray): ByteArray? =
encryptionService.signData(data)
fun shouldShowEncryptionIcon(peerID: String): Boolean = encryptionService.hasEstablishedSession(peerID)
fun getEncryptedPeers(): List<String> = emptyList()

View File

@ -13,6 +13,17 @@ interface MeshDelegate {
fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {}
fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {}
fun didReceiveGroupInvite(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {}
fun didReceiveGroupKeyUpdate(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {}
fun didReceiveGroupMessage(payload: ByteArray, timestampMs: Long) {}
/** Current Noise generation either proved peer state or exhausted its 5-second watchdog. */
fun didResolvePrivateMediaPolicy(peerID: String) {}
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?

View File

@ -19,6 +19,9 @@ interface MeshService {
fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {}
fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
fun sendGroupInvite(payload: ByteArray, recipientPeerID: String)
fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String)
fun broadcastGroupMessage(payload: ByteArray)
fun sendFileBroadcast(file: BitchatFilePacket)
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket)
fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray)
@ -63,6 +66,8 @@ interface MeshService {
): Boolean
fun getIdentityFingerprint(): String
fun getStaticNoisePublicKey(): ByteArray?
fun getSigningPublicKey(): ByteArray?
fun signData(data: ByteArray): ByteArray?
fun shouldShowEncryptionIcon(peerID: String): Boolean
fun getEncryptedPeers(): List<String>

View File

@ -199,6 +199,20 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
com.bitchat.android.model.NoisePayloadType.VERIFY_RESPONSE -> {
delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong())
}
com.bitchat.android.model.NoisePayloadType.GROUP_INVITE -> {
delegate?.onGroupInviteReceived(
peerID,
decryption.authenticatedSession.remoteStaticKey.copyOf(),
noisePayload.data
)
}
com.bitchat.android.model.NoisePayloadType.GROUP_KEY_UPDATE -> {
delegate?.onGroupKeyUpdateReceived(
peerID,
decryption.authenticatedSession.remoteStaticKey.copyOf(),
noisePayload.data
)
}
}
} catch (e: Exception) {
@ -256,6 +270,13 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
consecutiveDecryptFailures[peerID] = failures
}
}
fun handleGroupMessage(routed: RoutedPacket) {
delegate?.onGroupMessageReceived(
routed.packet.payload,
routed.packet.timestamp.toLong()
)
}
/**
* Send delivery ACK for a received private message - exactly like iOS
@ -770,4 +791,15 @@ interface MessageHandlerDelegate {
fun onReadReceiptReceived(messageID: String, peerID: String)
fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long)
fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long)
fun onGroupInviteReceived(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {}
fun onGroupKeyUpdateReceived(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {}
fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) {}
}

View File

@ -153,6 +153,7 @@ class PacketProcessor(private val myPeerID: String) {
MessageType.MESSAGE -> handleMessage(routed)
MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler
MessageType.VOICE_FRAME -> validPacket = delegate?.handleVoiceFrame(routed) ?: false
MessageType.GROUP_MESSAGE -> handleGroupMessage(routed)
MessageType.LEAVE -> handleLeave(routed)
MessageType.FRAGMENT -> handleFragment(routed)
MessageType.REQUEST_SYNC -> handleRequestSync(routed)
@ -212,6 +213,12 @@ class PacketProcessor(private val myPeerID: String) {
private suspend fun handleMessage(routed: RoutedPacket) {
delegate?.handleMessage(routed)
}
private fun handleGroupMessage(routed: RoutedPacket) {
val peerID = routed.peerID ?: "unknown"
Log.d(TAG, "Processing private-group message from ${formatPeerForLog(peerID)}")
delegate?.handleGroupMessage(routed)
}
/**
* Handle leave message
@ -316,6 +323,7 @@ interface PacketProcessorDelegate {
suspend fun handleAnnounce(routed: RoutedPacket): Boolean
fun handleMessage(routed: RoutedPacket)
fun handleVoiceFrame(routed: RoutedPacket): Boolean = false
fun handleGroupMessage(routed: RoutedPacket) {}
fun handleLeave(routed: RoutedPacket)
fun handleFragment(packet: BitchatPacket): BitchatPacket?
fun handleRequestSync(routed: RoutedPacket)

View File

@ -166,6 +166,30 @@ class UnifiedMeshService(
}
}
override fun sendGroupInvite(payload: ByteArray, recipientPeerID: String) {
when {
isBleReady(recipientPeerID) -> bluetooth.sendGroupInvite(payload, recipientPeerID)
isWifiReady(recipientPeerID) ->
wifiService()?.sendGroupInvite(payload, recipientPeerID)
}
}
override fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String) {
when {
isBleReady(recipientPeerID) ->
bluetooth.sendGroupKeyUpdate(payload, recipientPeerID)
isWifiReady(recipientPeerID) ->
wifiService()?.sendGroupKeyUpdate(payload, recipientPeerID)
}
}
override fun broadcastGroupMessage(payload: ByteArray) {
when {
isBleEnabled() -> bluetooth.broadcastGroupMessage(payload)
else -> wifiService()?.broadcastGroupMessage(payload)
}
}
override fun sendFileBroadcast(file: BitchatFilePacket) {
when {
isBleEnabled() -> bluetooth.sendFileBroadcast(file)
@ -343,6 +367,12 @@ class UnifiedMeshService(
return bluetooth.getStaticNoisePublicKey() ?: wifiService()?.getStaticNoisePublicKey()
}
override fun getSigningPublicKey(): ByteArray? =
bluetooth.getSigningPublicKey() ?: wifiService()?.getSigningPublicKey()
override fun signData(data: ByteArray): ByteArray? =
bluetooth.signData(data) ?: wifiService()?.signData(data)
override fun shouldShowEncryptionIcon(peerID: String): Boolean {
return hasEstablishedSession(peerID)
}
@ -429,6 +459,26 @@ class UnifiedMeshService(
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
}
override fun didReceiveGroupInvite(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
delegate?.didReceiveGroupInvite(peerID, authenticatedRemoteStaticKey, payload)
}
override fun didReceiveGroupKeyUpdate(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
delegate?.didReceiveGroupKeyUpdate(peerID, authenticatedRemoteStaticKey, payload)
}
override fun didReceiveGroupMessage(payload: ByteArray, timestampMs: Long) {
delegate?.didReceiveGroupMessage(payload, timestampMs)
}
override fun didResolvePrivateMediaPolicy(peerID: String) {
delegate?.didResolvePrivateMediaPolicy(peerID)
}

View File

@ -22,6 +22,8 @@ enum class NoisePayloadType(val value: UByte) {
READ_RECEIPT(0x02u), // Message was read
DELIVERED(0x03u), // Message was delivered
VOICE_FRAME(0x08u), // Ephemeral live push-to-talk frame
GROUP_INVITE(0x06u), // Creator-signed private-group state
GROUP_KEY_UPDATE(0x07u), // Creator-signed roster/key rotation
VERIFY_CHALLENGE(0x10u), // Verification challenge
VERIFY_RESPONSE(0x11u), // Verification response
FILE_TRANSFER(0x20u),

View File

@ -47,7 +47,7 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable {
val NON_DESTRUCTIVE_NOISE_REPLACEMENT = PeerCapabilities(1L shl 10)
/** Capabilities implemented by this Android build. */
val LOCAL_SUPPORTED = PRIVATE_MEDIA
val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or GROUPS.rawValue)
/**
* Decode the low 64 bits and ignore any future extension bytes, which

View File

@ -245,6 +245,8 @@ class NostrDirectMessageHandler(
NoisePayloadType.VERIFY_CHALLENGE,
NoisePayloadType.VERIFY_RESPONSE,
NoisePayloadType.VOICE_FRAME,
NoisePayloadType.GROUP_INVITE,
NoisePayloadType.GROUP_KEY_UPDATE,
NoisePayloadType.PEER_STATE -> Unit // Peer state is bound to a live mesh Noise generation.
}
}

View File

@ -20,7 +20,8 @@ enum class MessageType(val value: UByte) {
FRAGMENT(0x20u), // Fragmentation for large packets
REQUEST_SYNC(0x21u), // GCS-based sync request
FILE_TRANSFER(0x22u), // New: File transfer packet (BLE voice notes, etc.)
VOICE_FRAME(0x29u); // Ephemeral live push-to-talk frame; never added to gossip sync
VOICE_FRAME(0x29u), // Ephemeral live push-to-talk frame; never added to gossip sync
GROUP_MESSAGE(0x25u); // Opaque private-group ciphertext broadcast
companion object {
fun fromValue(value: UByte): MessageType? {

View File

@ -408,6 +408,19 @@ object AppStateStore {
messageID in seenMessageIds
}
fun removePrivateConversation(peerID: String) {
synchronized(this) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val map = _privateMessages.value.toMutableMap()
val removedPeer = map.remove(peerID) != null
val removedConversation = map.remove(conversationID) != null
val changed = removedPeer || removedConversation
if (changed) {
_privateMessages.value = map
}
}
}
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
null -> 0
is DeliveryStatus.Sending -> 1

View File

@ -52,6 +52,7 @@ class GossipSyncManager(
// Stored packets for sync:
// - broadcast messages: keep up to seenCapacity() most recent, keyed by packetId
private val messages = LinkedHashMap<String, BitchatPacket>()
private val groupMessages = LinkedHashMap<String, BitchatPacket>()
private val fragments = LinkedHashMap<String, BitchatPacket>()
private val archiveFile = context?.applicationContext?.filesDir?.let { File(it, ARCHIVE_FILE) }
private var restoringArchive = false
@ -97,6 +98,7 @@ class GossipSyncManager(
messages.clear()
}
synchronized(fragments) { fragments.clear() }
synchronized(groupMessages) { groupMessages.clear() }
latestAnnouncementByPeer.clear()
archiveFile?.delete()
Log.d(TAG, "Cleared all gossip sync messages and announcements")
@ -120,17 +122,29 @@ class GossipSyncManager(
// Only ANNOUNCE or broadcast MESSAGE
val mt = MessageType.fromValue(packet.type)
val isBroadcastMessage = (mt == MessageType.MESSAGE && (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)))
val isGroupMessage = mt == MessageType.GROUP_MESSAGE &&
(packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
val isBroadcastFile = mt == MessageType.FILE_TRANSFER &&
(packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
val isAnnouncement = (mt == MessageType.ANNOUNCE)
val isFragment = (mt == MessageType.FRAGMENT || isBroadcastFile) &&
(packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
if (!isBroadcastMessage && !isAnnouncement && !isFragment) return
if (!isBroadcastMessage && !isAnnouncement && !isFragment && !isGroupMessage) return
val idBytes = PacketIdUtil.computeIdBytes(packet)
val id = idBytes.joinToString("") { b -> "%02x".format(b) }
if (isBroadcastMessage) {
if (isGroupMessage) {
val age = System.currentTimeMillis() - packet.timestamp.toLong()
if (age !in -PUBLIC_PACKET_FUTURE_SKEW_MS..PUBLIC_MESSAGE_MAX_AGE_MS) return
synchronized(groupMessages) {
groupMessages[id] = packet
while (groupMessages.size > 200) {
val iterator = groupMessages.entries.iterator()
iterator.next(); iterator.remove()
}
}
} else if (isBroadcastMessage) {
val now = System.currentTimeMillis()
val age = now - packet.timestamp.toLong()
if (age !in -PUBLIC_PACKET_FUTURE_SKEW_MS..PUBLIC_MESSAGE_MAX_AGE_MS) return
@ -178,7 +192,8 @@ class GossipSyncManager(
listOf(
SyncTypeFlags.PUBLIC_MESSAGES,
SyncTypeFlags.FRAGMENT,
SyncTypeFlags.FILE_TRANSFER
SyncTypeFlags.FILE_TRANSFER,
SyncTypeFlags.GROUP_MESSAGE
).forEach { types ->
val payload = buildGcsPayload(types)
val packet = BitchatPacket(
@ -194,7 +209,7 @@ class GossipSyncManager(
}
private fun sendRequestSyncToPeer(peerID: String) {
val types = SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES)
val types = SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES).union(SyncTypeFlags.GROUP_MESSAGE)
val payload = buildGcsPayload(types)
val packet = BitchatPacket(
@ -249,6 +264,14 @@ class GossipSyncManager(
}
}
if (requestedTypes.contains(MessageType.GROUP_MESSAGE)) {
synchronized(groupMessages) { groupMessages.values.toList() }.forEach { packet ->
if ((sinceTimestamp == null || packet.timestamp >= sinceTimestamp) &&
!mightContain(PacketIdUtil.computeIdBytes(packet))) {
delegate?.sendPacketToPeer(fromPeerID, packet.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS))
}
}
}
val toSendFragments = synchronized(fragments) { fragments.values.toList() }
for (pkt in toSendFragments) {
val type = MessageType.fromValue(pkt.type) ?: continue
@ -289,6 +312,9 @@ class GossipSyncManager(
internal fun buildGcsPayload(types: SyncTypeFlags): ByteArray {
// Collect only the packet types represented by this filter.
val list = ArrayList<BitchatPacket>()
if (types.contains(MessageType.GROUP_MESSAGE)) {
synchronized(groupMessages) { list.addAll(groupMessages.values) }
}
if (types.contains(MessageType.ANNOUNCE)) {
for ((_, pair) in latestAnnouncementByPeer) {
list.add(pair.second)

View File

@ -6,7 +6,10 @@ import com.bitchat.android.protocol.MessageType
@JvmInline
value class SyncTypeFlags private constructor(val rawValue: ULong) {
companion object {
private const val KNOWN_TYPE_MASK: ULong = 0xffu
private const val KNOWN_TYPE_MASK: ULong = 0x4ffu
val GROUP_MESSAGE = fromMessageTypes(MessageType.GROUP_MESSAGE)
fun of(vararg types: MessageType) = fromMessageTypes(*types)
val ANNOUNCE = fromMessageTypes(MessageType.ANNOUNCE)
val MESSAGE = fromMessageTypes(MessageType.MESSAGE)
@ -43,6 +46,7 @@ value class SyncTypeFlags private constructor(val rawValue: ULong) {
MessageType.FRAGMENT -> 5
MessageType.REQUEST_SYNC -> 6
MessageType.FILE_TRANSFER -> 7
MessageType.GROUP_MESSAGE -> 10
MessageType.COURIER_ENVELOPE,
MessageType.VOICE_FRAME -> null
}
@ -55,6 +59,8 @@ value class SyncTypeFlags private constructor(val rawValue: ULong) {
fun union(other: SyncTypeFlags): SyncTypeFlags = fromRawValue(rawValue or other.rawValue)
fun encoded(): ByteArray? = encode()
fun encode(): ByteArray? {
if (rawValue == 0uL) return null
var remaining = rawValue

View File

@ -43,6 +43,15 @@ import com.bitchat.android.features.voice.LiveVoicePreferences
import com.bitchat.android.features.voice.LiveVoiceTarget
import com.bitchat.android.features.voice.VoiceRecorder
import com.bitchat.android.groups.BitchatGroup
import com.bitchat.android.groups.GroupCommandResult
import com.bitchat.android.groups.GroupCoordinator
import com.bitchat.android.groups.GroupCoordinatorContext
import com.bitchat.android.groups.GroupIds
import com.bitchat.android.groups.GroupPeerIdentity
import com.bitchat.android.groups.GroupStore
import com.bitchat.android.groups.PeerGroupCapability
private data class ConversationLiveIdentityState(
val connectedPeerIDs: List<String>,
val peerNicknames: Map<String, String>,
@ -70,6 +79,9 @@ class ChatViewModel(
companion object {
private const val TAG = "ChatViewModel"
private const val CONVERSATION_DISCONNECT_GRACE_MS = 3_000L
private const val GROUP_COMMAND_USAGE =
"usage: /group create <name> · invite @name[#identity] · " +
"remove @name[#identity] · leave · list"
}
fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
@ -151,6 +163,8 @@ class ChatViewModel(
.select(com.bitchat.android.geohash.ChannelID.Mesh)
}
)
private val groupStore = GroupStore(application.applicationContext)
val groups: StateFlow<List<BitchatGroup>> = groupStore.groups
// Create Noise session delegate for clean dependency injection
private val noiseSessionDelegate = object : NoiseSessionDelegate {
@ -183,6 +197,149 @@ class ChatViewModel(
NotificationManagerCompat.from(application.applicationContext)
)
private val groupCoordinator = GroupCoordinator(object : GroupCoordinatorContext {
override val groupStore: GroupStore
get() = this@ChatViewModel.groupStore
override val nickname: String
get() = state.getNicknameValue()
override val myPeerID: String
get() = mesh.myPeerID
override val selectedConversationID: String?
get() = state.getSelectedPrivateChatPeerValue()
override fun myNoiseFingerprint(): String = mesh.getIdentityFingerprint()
override fun mySigningPublicKey(): ByteArray? = mesh.getSigningPublicKey()
override fun sign(data: ByteArray): ByteArray? = mesh.signData(data)
override fun peerIDsForNickname(nickname: String): List<String> =
mesh.getPeerNicknames().entries.filter {
it.value.equals(nickname, ignoreCase = true)
}.map { it.key }
override fun isPeerConnected(peerID: String): Boolean =
mesh.getPeerInfo(peerID)?.isConnected == true && mesh.hasEstablishedSession(peerID)
override fun peerGroupCapability(peerID: String): PeerGroupCapability {
val peerInfo = mesh.getPeerInfo(peerID) ?: return PeerGroupCapability.UNKNOWN
return PeerGroupCapability.fromPeerState(
peerInfo.capabilities,
peerInfo.hasVerifiedAnnouncement
)
}
override fun peerNickname(peerID: String): String? =
mesh.getPeerNicknames()[peerID]
override fun peerIdentity(peerID: String): GroupPeerIdentity? {
val info = mesh.getPeerInfo(peerID) ?: return null
if (info.signingPublicKey?.size != 32) return null
val liveFingerprint = mesh.getPeerFingerprint(peerID) ?: return null
val announcedNoiseKey = info.noisePublicKey ?: return null
val announcedFingerprint = ContactIdentityResolver.fingerprintHex(announcedNoiseKey)
if (!liveFingerprint.equals(announcedFingerprint, ignoreCase = true)) return null
return GroupPeerIdentity(
liveFingerprint.lowercase(),
info.signingPublicKey!!.copyOf()
)
}
override fun connectedPeerID(fingerprint: String): String? =
mesh.getPeerNicknames().keys.firstOrNull { peerID ->
mesh.getPeerInfo(peerID)?.isConnected == true &&
mesh.hasEstablishedSession(peerID) &&
mesh.getPeerFingerprint(peerID).equals(fingerprint, ignoreCase = true)
}
override fun isFingerprintBlocked(fingerprint: String): Boolean =
dataManager.isUserBlocked(fingerprint)
override fun sendGroupInvite(payload: ByteArray, peerID: String) {
mesh.sendGroupInvite(payload, peerID)
}
override fun sendGroupKeyUpdate(payload: ByteArray, peerID: String) {
mesh.sendGroupKeyUpdate(payload, peerID)
}
override fun broadcastGroupMessage(payload: ByteArray) {
mesh.broadcastGroupMessage(payload)
}
override fun appendGroupMessage(
groupPeerID: String,
message: BitchatMessage
): Boolean {
val existing = state.getPrivateChatsValue()[groupPeerID].orEmpty()
if (existing.any { it.id == message.id }) return false
messageManager.addPrivateMessage(groupPeerID, message)
if (state.getSelectedPrivateChatPeerValue() == groupPeerID) {
try {
com.bitchat.android.services.AppStateStore.markPrivateMessageRead(message.id)
} catch (_: Exception) {
}
}
return true
}
override fun markGroupUnread(groupPeerID: String) {
state.setUnreadPrivateMessages(
state.getUnreadPrivateMessagesValue() + groupPeerID
)
}
override fun removeGroupConversation(groupPeerID: String) {
messageManager.removePrivateChat(groupPeerID)
}
override fun openGroupConversation(groupPeerID: String) {
privateChatManager.startPrivateChat(groupPeerID, mesh)
showPrivateChatSheet(groupPeerID)
}
override fun closeGroupConversation() {
endPrivateChat()
}
override fun addSystemMessage(message: String) {
messageManager.addSystemMessage(message)
}
override fun addGroupSystemMessage(groupPeerID: String, message: String) {
messageManager.addPrivateMessage(
groupPeerID,
BitchatMessage(
sender = "system",
content = message,
timestamp = Date(),
isPrivate = true,
recipientNickname = groupStore.group(groupPeerID)?.name
)
)
}
override fun notifyGroupMessage(
groupPeerID: String,
sender: String,
message: String
) {
notificationManager.showPrivateMessageNotification(groupPeerID, sender, message)
}
})
fun handleGroupCommand(arguments: List<String>): GroupCommandResult {
val subcommand = arguments.firstOrNull()?.lowercase()
?: return GroupCommandResult(false, GROUP_COMMAND_USAGE)
val value = arguments.drop(1).joinToString(" ")
return when (subcommand) {
"create" -> groupCoordinator.createGroup(value)
"invite" -> groupCoordinator.inviteMember(value)
"remove" -> groupCoordinator.removeMember(value)
"leave" -> groupCoordinator.leaveGroup()
"list" -> groupCoordinator.listGroups()
else -> GroupCommandResult(false, GROUP_COMMAND_USAGE)
}
}
private val verificationHandler = VerificationHandler(
context = application.applicationContext,
scope = viewModelScope,
@ -425,6 +582,11 @@ class ChatViewModel(
init {
observeConversationPresenceWithDisconnectGrace()
viewModelScope.launch(Dispatchers.IO) {
if (groupStore.initialize()) {
groupCoordinator.onStoreReady()
}
}
// Note: Mesh service delegate is now set by MainActivity
loadAndInitialize()
ContactDirectory.initialize(getApplication()) { mesh }
@ -976,6 +1138,10 @@ class ChatViewModel(
val currentChannelValue = state.getCurrentChannelValue()
if (selectedPeer != null) {
if (GroupIds.isGroup(selectedPeer)) {
groupCoordinator.sendMessage(content, selectedPeer)
return
}
// If the selected peer is a temporary Nostr alias or a noise-hex identity, resolve to a canonical target
selectedPeer = ContactDirectory.canonicalConversationId(
com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
@ -1426,6 +1592,27 @@ class ChatViewModel(
override fun didResolvePrivateMediaPolicy(peerID: String) {
mediaSendingManager.retryPendingPrivateMedia(peerID)
groupCoordinator.handlePeerAuthenticated(peerID)
}
override fun didReceiveGroupInvite(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
groupCoordinator.handleInvite(peerID, authenticatedRemoteStaticKey, payload)
}
override fun didReceiveGroupKeyUpdate(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
groupCoordinator.handleKeyUpdate(peerID, authenticatedRemoteStaticKey, payload)
}
override fun didReceiveGroupMessage(payload: ByteArray, timestampMs: Long) {
groupCoordinator.handleMessage(payload, timestampMs)
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
@ -1457,7 +1644,8 @@ class ChatViewModel(
}
private suspend fun performPanicClearAllData() {
Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data")
groupCoordinator.suspendForPanic()
Log.w(TAG, "Panic wipe started")
try {
com.bitchat.android.geohash.LocationChannelManager
.getInstance(getApplication())
@ -1480,6 +1668,7 @@ class ChatViewModel(
messageManager.clearAllMessages()
channelManager.clearAllChannels()
privateChatManager.clearAllPrivateChats()
val groupsCleared = groupStore.wipe()
dataManager.clearAllData()
conversationListPreferences.clearAll()
@ -1523,7 +1712,7 @@ class ChatViewModel(
state.setNickname(newNickname)
dataManager.saveNickname(newNickname)
if (!conversationsCleared) {
if (!conversationsCleared || !groupsCleared) {
// Privacy wins over availability: keep private-message admission and transports
// stopped if SQLite could not prove that the conversation history was erased.
Log.e(TAG, "🚨 PANIC MODE INCOMPLETE - conversation database wipe failed")
@ -1534,6 +1723,7 @@ class ChatViewModel(
com.bitchat.android.services.AppStateStore
.resumePrivateConversationsAfterPanic()
recreateMeshServiceAfterPanic()
groupCoordinator.resumeAfterPanic()
Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}")
}

View File

@ -1,5 +1,6 @@
package com.bitchat.android.ui
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import java.util.Date
@ -23,6 +24,7 @@ class CommandProcessor(
CommandSuggestion("/block", emptyList(), "[nickname]", "block or list blocked peers"),
CommandSuggestion("/channels", emptyList(), null, "show all discovered channels"),
CommandSuggestion("/clear", emptyList(), null, "clear chat messages"),
CommandSuggestion("/group", emptyList(), "<create|invite|remove|leave|list>", "manage private groups"),
CommandSuggestion("/hug", emptyList(), "<nickname>", "send someone a warm hug"),
CommandSuggestion("/j", listOf("/join"), "<channel>", "join or create a channel"),
CommandSuggestion("/m", listOf("/msg"), "<nickname> [message]", "send private message"),
@ -51,11 +53,49 @@ class CommandProcessor(
"/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage, viewModel)
"/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage, viewModel)
"/channels" -> handleChannelsCommand()
"/group" -> handleGroupCommand(parts, viewModel)
else -> handleUnknownCommand(cmd)
}
return true
}
private fun handleGroupCommand(parts: List<String>, viewModel: ChatViewModel?) {
if (viewModel == null) {
addCommandOutput("private groups are unavailable")
return
}
val selectedPeer = state.getSelectedPrivateChatPeerValue()
if (viewModel.selectedLocationChannel.value is ChannelID.Location ||
selectedPeer?.startsWith("nostr_") == true ||
selectedPeer?.startsWith("nostr:") == true
) {
addCommandOutput("groups are only for mesh peers in #mesh")
return
}
val result = viewModel.handleGroupCommand(
parts.drop(1).filter(String::isNotBlank)
)
addCommandOutput(result.message)
}
private fun addCommandOutput(message: String) {
val destination = state.getSelectedPrivateChatPeerValue()
if (destination != null) {
messageManager.addPrivateMessage(
destination,
BitchatMessage(
sender = "system",
content = message,
timestamp = Date(),
isPrivate = true
)
)
} else {
messageManager.addSystemMessage(message)
}
}
private fun handleJoinCommand(parts: List<String>, myPeerID: String) {
if (parts.size > 1) {
@ -517,7 +557,9 @@ class CommandProcessor(
state.getSelectedPrivateChatPeerValue() == null &&
state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location
return (baseCommands + channelCommands).filterNot {
isPublicGeohash && it.command == "/pay"
(isPublicGeohash && it.command == "/pay") ||
(it.command == "/group" && (state.selectedLocationChannel.value is ChannelID.Location ||
state.getSelectedPrivateChatPeerValue()?.startsWith("nostr") == true))
}
}

View File

@ -46,6 +46,7 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -60,6 +61,8 @@ import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.favorites.FavoriteRelationship
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.groups.BitchatGroup
import com.bitchat.android.groups.GroupIds
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
@ -96,6 +99,8 @@ fun MeshPeerListSheet(
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val groups by viewModel.groups.collectAsStateWithLifecycle()
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
@ -447,6 +452,21 @@ fun MeshPeerListSheet(
}
}
}
if (selectedLocationChannel !is ChannelID.Location && groups.isNotEmpty()) {
item(key = "groups_section") {
GroupSection(
groups = groups,
myFingerprint = viewModel.getMyFingerprint(),
unreadConversationIDs = unreadPrivateMessages,
colorScheme = colorScheme,
onGroupClick = { groupPeerID ->
viewModel.showPrivateChatSheet(groupPeerID)
onDismiss()
}
)
}
}
}
// TopBar (animated)
@ -568,6 +588,103 @@ fun MeshPeerListSheet(
private val PeerRowIconSize = 22.dp
private const val CONVERSATION_SEARCH_THRESHOLD = 8
@Composable
private fun GroupSection(
groups: List<BitchatGroup>,
myFingerprint: String,
unreadConversationIDs: Set<String>,
colorScheme: ColorScheme,
onGroupClick: (String) -> Unit
) {
Column(modifier = Modifier.padding(top = 16.dp)) {
Text(
text = stringResource(R.string.groups).uppercase(),
style = MaterialTheme.typography.labelLarge,
color = colorScheme.onSurface.copy(alpha = 0.7f),
fontWeight = FontWeight.Bold,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(top = 8.dp, bottom = 4.dp)
)
groups.forEach { group ->
val isCreator = group.creatorFingerprint.equals(myFingerprint, ignoreCase = true)
val hasUnread = group.peerID in unreadConversationIDs
val memberCountLabel = stringResource(R.string.group_member_count, group.members.size)
val creatorLabel = stringResource(R.string.group_creator)
val unreadLabel = stringResource(R.string.cd_unread_private_messages)
val accessibilityDescription = buildList {
add(group.name)
add(memberCountLabel)
if (isCreator) add(creatorLabel)
if (hasUnread) add(unreadLabel)
}.joinToString()
Surface(
onClick = { onGroupClick(group.peerID) },
color = Color.Transparent,
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 2.dp)
.semantics(mergeDescendants = true) {
contentDescription = accessibilityDescription
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 48.dp)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Filled.Groups,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = colorScheme.primary
)
Text(
text = "#${group.name}",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
color = colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = memberCountLabel,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = colorScheme.onSurfaceVariant
)
if (isCreator) {
Icon(
imageVector = Icons.Filled.WorkspacePremium,
contentDescription = creatorLabel,
modifier = Modifier.size(14.dp),
tint = Color(0xFFFFD700)
)
}
Spacer(modifier = Modifier.weight(1f))
if (hasUnread) {
Icon(
imageVector = Icons.Filled.Mail,
contentDescription = unreadLabel,
modifier = Modifier.size(16.dp),
tint = Color(0xFFFF9500)
)
}
}
}
}
}
}
@Composable
private fun ChannelRow(
channel: String,
@ -1687,6 +1804,9 @@ fun PrivateChatSheet(
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
val peerFavoritedUs by viewModel.peerFavoritedUs.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
val groups by viewModel.groups.collectAsStateWithLifecycle()
val group = remember(peerID, groups) { groups.firstOrNull { it.peerID == peerID } }
val isGroupConversation = GroupIds.isGroup(peerID)
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
@ -1713,6 +1833,7 @@ fun PrivateChatSheet(
val isConnected = activeMeshPeerID?.let { connectedPeers.contains(it) } == true || connectedPeers.contains(peerID) || isDirect
val isNostrReachableFavorite =
!isConnected && favoriteRelationship?.isMutual == true && favoriteRelationship.peerNostrPublicKey != null
val privateGroupLabel = stringResource(R.string.private_group)
// Compute display name and title text reactively
val displayName = remember(peerID, peerNicknames, favoriteRelationship) {
@ -1722,8 +1843,10 @@ fun PrivateChatSheet(
?: favoriteRelationship?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
?: viewModel.resolvePeerDisplayNameForFingerprint(peerID)
}
val titleText = remember(peerID, peerNicknames, favoriteRelationship) {
if (isNostrPeer) {
val titleText = remember(peerID, peerNicknames, favoriteRelationship, group) {
if (isGroupConversation) {
group?.let { "#${it.name} (${it.members.size})" } ?: privateGroupLabel
} else if (isNostrPeer) {
val gh = GeohashConversationRegistry.get(peerID) ?: "geohash"
val fullPubkey = GeohashAliasRegistry.get(peerID) ?: ""
val name = if (fullPubkey.isNotEmpty()) {
@ -1737,7 +1860,7 @@ fun PrivateChatSheet(
}
}
val conversationID = contactResolution.conversationID
val conversationID = if (isGroupConversation) peerID else contactResolution.conversationID
val messages = privateChats[conversationID] ?: privateChats[peerID] ?: emptyList()
val sessionState = resolveConversationSessionState(
conversationID = peerID,
@ -1747,8 +1870,15 @@ fun PrivateChatSheet(
val fingerprint = activeMeshPeerID?.let { peerFingerprints[it] }
?: peerFingerprints[peerID]
?: ContactIdentityResolver.fingerprintFromContactConversationId(peerID)
val isFavorite = remember(favoritePeers, fingerprint, peerID, favoriteRelationship) {
if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID)
val isFavorite = remember(
favoritePeers,
fingerprint,
peerID,
favoriteRelationship,
isGroupConversation
) {
!isGroupConversation &&
if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID)
}
val theyFavoritedUs = remember(peerFavoritedUs, fingerprint, favoriteRelationship) {
(fingerprint != null && peerFavoritedUs.contains(fingerprint)) ||
@ -1834,8 +1964,33 @@ fun PrivateChatSheet(
onImageClick = { _, _, _ -> /* handle image click */ }
)
// Input section. No divider here: ChatInputSection draws its own fade and
// hairline.
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
// Input section
if (isGroupConversation) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.semantics(mergeDescendants = true) {},
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = Color(0xFFFF9500)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = stringResource(R.string.group_encryption_caption),
style = MaterialTheme.typography.labelSmall,
color = Color(0xFFFF9500)
)
}
}
var messageText by remember(peerID) {
mutableStateOf(
androidx.compose.ui.text.input.TextFieldValue(
@ -1885,7 +2040,7 @@ fun PrivateChatSheet(
currentChannel = null,
nickname = nickname,
colorScheme = colorScheme,
showMediaButtons = true
showMediaButtons = !isGroupConversation
)
}
@ -1910,6 +2065,7 @@ fun PrivateChatSheet(
},
title = titleText
) {
if (!isGroupConversation) {
ConversationHeaderAction(
onClick = { viewModel.toggleFavorite(peerID) },
contentDescription = if (isFavorite) {
@ -1938,7 +2094,9 @@ fun PrivateChatSheet(
)
}
if (isVerified) {
}
if (!isGroupConversation && isVerified) {
ConversationHeaderStatus {
Icon(
imageVector = Icons.Filled.Verified,
@ -1953,7 +2111,7 @@ fun PrivateChatSheet(
// Keep the lock nearest the close action: from right to left the security
// cluster reads close, encryption, verification, then favorite.
if (!isNostrPeer && !isNostrReachableFavorite) {
if (!isGroupConversation && !isNostrPeer && !isNostrReachableFavorite) {
ConversationHeaderAction(
onClick = { viewModel.showSecurityVerificationSheet() },
contentDescription = stringResource(R.string.verify_title)

View File

@ -196,6 +196,22 @@ class MessageManager(private val state: ChatState) {
state.setPrivateChats(updatedChats)
clearPrivateUnreadMessages(conversationID)
}
fun removePrivateChat(peerID: String) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val updatedChats = state.getPrivateChatsValue().toMutableMap()
updatedChats.remove(peerID)
updatedChats.remove(conversationID)
state.setPrivateChats(updatedChats)
val unread = state.getUnreadPrivateMessagesValue().toMutableSet()
unread.remove(peerID)
unread.remove(conversationID)
state.setUnreadPrivateMessages(unread)
try {
com.bitchat.android.services.AppStateStore.removePrivateConversation(conversationID)
} catch (_: Exception) {
}
}
fun initializePrivateChat(peerID: String) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)

View File

@ -1,6 +1,7 @@
package com.bitchat.android.ui
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.groups.GroupIds
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.mesh.PeerFingerprintManager
@ -474,6 +475,11 @@ class PrivateChatManager(
meshService: MeshService
) {
val canonicalConversationID = ContactDirectory.canonicalConversationId(conversationID)
if (GroupIds.isGroup(canonicalConversationID)) {
unreadReceivedMessages.remove(canonicalConversationID)
messageManager.clearPrivateUnreadMessages(canonicalConversationID)
return
}
// Collect candidate messages: all incoming messages from this peer in the conversation
val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap<String, List<BitchatMessage>>() }

View File

@ -1404,6 +1404,18 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
meshCore.sendVerifyResponse(peerID, noiseKeyHex, nonceA)
}
override fun sendGroupInvite(payload: ByteArray, recipientPeerID: String) {
meshCore.sendGroupInvite(payload, recipientPeerID)
}
override fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String) {
meshCore.sendGroupKeyUpdate(payload, recipientPeerID)
}
override fun broadcastGroupMessage(payload: ByteArray) {
meshCore.broadcastGroupMessage(payload)
}
/**
* Broadcasts a file (TLV payload) to all peers. Uses protocol version 2 to support
* large payloads and generates a deterministic transferId (sha256 of payload) for UI/state.
@ -1524,6 +1536,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
override fun getStaticNoisePublicKey(): ByteArray? = meshCore.getStaticNoisePublicKey()
override fun getSigningPublicKey(): ByteArray? = meshCore.getSigningPublicKey()
override fun signData(data: ByteArray): ByteArray? = meshCore.signData(data)
/**
* @return true if the UI should show an “encrypted” indicator for this peer.
*/

View File

@ -19,6 +19,12 @@
<string name="back">Back</string>
<string name="people">People</string>
<string name="channels">Channels</string>
<string name="groups" tools:ignore="MissingTranslation">Groups</string>
<string name="private_group" tools:ignore="MissingTranslation">Private group</string>
<string name="group_member_count" tools:ignore="MissingTranslation">%1$d members</string>
<string name="group_creator" tools:ignore="MissingTranslation">Creator</string>
<string name="group_encryption_caption" tools:ignore="MissingTranslation">Encrypted to group members</string>
<string name="cd_group_chat" tools:ignore="MissingTranslation">Private group chat</string>
<string name="online_users">Online Users</string>
<string name="no_one_connected">No one connected</string>
<string name="emergency_clear_hint">Triple tap to clear all data</string>

View File

@ -0,0 +1,724 @@
package com.bitchat.android.groups
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.PeerCapabilities
import java.security.MessageDigest
import java.util.Date
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class GroupCoordinatorTest {
@Test
fun `peer group capability distinguishes unknown unsupported and supported state`() {
assertEquals(
PeerGroupCapability.UNKNOWN,
PeerGroupCapability.fromPeerState(null, hasVerifiedAnnouncement = false)
)
assertEquals(
PeerGroupCapability.UNSUPPORTED,
PeerGroupCapability.fromPeerState(null, hasVerifiedAnnouncement = true)
)
assertEquals(
PeerGroupCapability.UNSUPPORTED,
PeerGroupCapability.fromPeerState(
PeerCapabilities.PRIVATE_MEDIA,
hasVerifiedAnnouncement = false
)
)
assertEquals(
PeerGroupCapability.SUPPORTED,
PeerGroupCapability.fromPeerState(
PeerCapabilities.GROUPS,
hasVerifiedAnnouncement = false
)
)
}
@Test
fun `creator invite rotates epoch and sends creator-signed state`() {
val localKey = privateKey(0x11)
val inviteeKey = privateKey(0x22)
val context = FakeGroupContext(localKey, "11".repeat(32))
context.peerIDs["alice"] = "22".repeat(8)
context.connected += "22".repeat(8)
context.peerNames["22".repeat(8)] = "alice"
context.identities["22".repeat(8)] = GroupPeerIdentity(
"22".repeat(32),
inviteeKey.generatePublicKey().encoded
)
val created = GroupCoordinator(context).createGroup("trail crew")
assertTrue(created.success)
val original = context.groupStore.groups.value.single()
val originalKey = context.groupStore.key(original.groupID)!!
val result = GroupCoordinator(context).inviteMember("@alice")
assertTrue(result.success)
val updated = context.groupStore.group(original.groupID)!!
val updatedKey = context.groupStore.key(updated.groupID)!!
assertEquals(2, updated.epoch)
assertEquals(2, updated.members.size)
assertFalse(originalKey.contentEquals(updatedKey))
assertEquals(1, context.invites.size)
val state = GroupStatePayload.decode(context.invites.single().second)!!
assertTrue(state.verifyCreatorSignature())
assertEquals(updated, state.asGroup())
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)
val inviteeKey = privateKey(0x13)
val peerID = "13".repeat(8)
val context = FakeGroupContext(localKey, "12".repeat(32))
context.peerIDs["alice"] = peerID
context.connected += peerID
context.peerNames[peerID] = "alice"
context.identities[peerID] = GroupPeerIdentity(
"13".repeat(32),
inviteeKey.generatePublicKey().encoded
)
val coordinator = GroupCoordinator(context)
assertTrue(coordinator.createGroup("trail crew").success)
val original = context.groupStore.groups.value.single()
context.groupCapabilities[peerID] = PeerGroupCapability.UNSUPPORTED
val unsupported = coordinator.inviteMember("@alice")
assertFalse(unsupported.success)
assertTrue(unsupported.message.contains("does not support"))
assertEquals(original, context.groupStore.groups.value.single())
assertTrue(context.invites.isEmpty())
context.groupCapabilities[peerID] = PeerGroupCapability.UNKNOWN
val unknown = coordinator.inviteMember("@alice")
assertFalse(unknown.success)
assertTrue(unknown.message.contains("not confirmed"))
assertEquals(original, context.groupStore.groups.value.single())
assertTrue(context.invites.isEmpty())
}
@Test
fun `authenticated reconnect replays current signed state to retained member`() {
val localKey = privateKey(0x16)
val memberKey = privateKey(0x17)
val peerID = "17".repeat(8)
val memberFingerprint = "17".repeat(32)
val context = FakeGroupContext(localKey, "16".repeat(32))
context.peerIDs["alice"] = peerID
context.connected += peerID
context.peerNames[peerID] = "alice"
context.identities[peerID] = GroupPeerIdentity(
memberFingerprint,
memberKey.generatePublicKey().encoded
)
val coordinator = GroupCoordinator(context)
assertTrue(coordinator.createGroup("trail crew").success)
assertTrue(coordinator.inviteMember("@alice").success)
val current = context.groupStore.groups.value.single()
val currentKey = context.groupStore.key(current.groupID)!!
context.updates.clear()
coordinator.handlePeerAuthenticated(peerID)
assertEquals(1, context.updates.size)
val (recipient, bytes) = context.updates.single()
assertEquals(peerID, recipient)
val state = GroupStatePayload.decode(bytes)!!
assertTrue(state.verifyCreatorSignature())
assertEquals(current, state.asGroup())
assertArrayEquals(currentKey, state.key)
context.updates.clear()
context.groupCapabilities[peerID] = PeerGroupCapability.UNSUPPORTED
coordinator.handlePeerAuthenticated(peerID)
assertTrue(context.updates.isEmpty())
}
@Test
fun `invite is accepted only from authenticated creator`() {
val creatorKey = privateKey(0x31)
val localKey = privateKey(0x32)
val creatorStatic = ByteArray(32) { 0x41 }
val creatorFingerprint = fingerprint(creatorStatic)
val localFingerprint = "52".repeat(32)
val context = FakeGroupContext(localKey, localFingerprint)
val group = incomingGroup(
creatorKey,
creatorFingerprint,
localKey,
localFingerprint
)
val payload = signedState(group, creatorKey, ByteArray(32) { 0x61 })
val coordinator = GroupCoordinator(context)
coordinator.handleInvite("creator", ByteArray(32) { 0x7f }, payload)
assertTrue(context.groupStore.groups.value.isEmpty())
coordinator.handleInvite("creator", creatorStatic, payload)
assertEquals(group, context.groupStore.groups.value.single())
assertTrue(group.peerID in context.unread)
assertEquals(1, context.notifications.size)
}
@Test
fun `creator removal state drops key conversation and membership`() {
val creatorKey = privateKey(0x71)
val localKey = privateKey(0x72)
val creatorStatic = ByteArray(32) { 0x73 }
val creatorFingerprint = fingerprint(creatorStatic)
val localFingerprint = "74".repeat(32)
val context = FakeGroupContext(localKey, localFingerprint)
val original = incomingGroup(
creatorKey,
creatorFingerprint,
localKey,
localFingerprint
)
assertTrue(context.groupStore.upsert(original, ByteArray(32) { 0x75 }))
context.selected = original.peerID
val removedState = original.copy(
epoch = original.epoch + 1,
members = listOf(original.members.first())
)
val payload = signedState(removedState, creatorKey, ByteArray(32))
GroupCoordinator(context).handleKeyUpdate("creator", creatorStatic, payload)
assertNull(context.groupStore.group(original.groupID))
assertNull(context.groupStore.key(original.groupID))
assertTrue(original.peerID in context.removedConversations)
assertNull(context.selected)
assertTrue(context.systemMessages.single().contains("removed"))
}
@Test
fun `stale removal state cannot delete a newer membership`() {
val creatorKey = privateKey(0x76)
val localKey = privateKey(0x77)
val creatorStatic = ByteArray(32) { 0x78 }
val creatorFingerprint = fingerprint(creatorStatic)
val localFingerprint = "79".repeat(32)
val context = FakeGroupContext(localKey, localFingerprint)
val current = incomingGroup(
creatorKey,
creatorFingerprint,
localKey,
localFingerprint
).copy(epoch = 3)
assertTrue(context.groupStore.upsert(current, ByteArray(32) { 0x7a }))
context.selected = current.peerID
val staleRemoval = current.copy(
epoch = 2,
members = listOf(current.members.first())
)
val payload = signedState(staleRemoval, creatorKey, ByteArray(32))
GroupCoordinator(context).handleKeyUpdate("creator", creatorStatic, payload)
assertEquals(current, context.groupStore.group(current.groupID))
assertNotNull(context.groupStore.key(current.groupID))
assertEquals(current.peerID, context.selected)
assertTrue(context.removedConversations.isEmpty())
assertTrue(context.systemMessages.isEmpty())
}
@Test
fun `creator cannot leave while other members remain`() {
val localKey = privateKey(0x14)
val memberKey = privateKey(0x15)
val context = FakeGroupContext(localKey, "14".repeat(32))
val coordinator = GroupCoordinator(context)
assertTrue(coordinator.createGroup("trail crew").success)
val created = context.groupStore.groups.value.single()
val withMember = created.copy(
members = created.members + GroupMember(
"15".repeat(32),
memberKey.generatePublicKey().encoded,
"alice"
)
)
assertTrue(
context.groupStore.upsert(
withMember,
context.groupStore.key(created.groupID)!!
)
)
val result = coordinator.leaveGroup()
assertFalse(result.success)
assertTrue(result.message.contains("remove all other members"))
assertEquals(withMember, context.groupStore.group(created.groupID))
assertEquals(withMember.peerID, context.selected)
assertTrue(context.removedConversations.isEmpty())
}
@Test
fun `voluntary leave ignores key updates until a newer explicit invite`() {
val creatorKey = privateKey(0x18)
val localKey = privateKey(0x19)
val creatorStatic = ByteArray(32) { 0x1a }
val creatorFingerprint = fingerprint(creatorStatic)
val localFingerprint = "19".repeat(32)
val context = FakeGroupContext(localKey, localFingerprint)
val original = incomingGroup(
creatorKey,
creatorFingerprint,
localKey,
localFingerprint
)
assertTrue(context.groupStore.upsert(original, ByteArray(32) { 0x1b }))
context.selected = original.peerID
val coordinator = GroupCoordinator(context)
assertTrue(coordinator.leaveGroup().success)
assertNull(context.groupStore.group(original.groupID))
assertEquals(original.epoch, context.groupStore.departureEpoch(original.groupID))
val nextState = original.copy(epoch = original.epoch + 1)
coordinator.handleKeyUpdate(
"creator",
creatorStatic,
signedState(nextState, creatorKey, ByteArray(32) { 0x1c })
)
assertNull(context.groupStore.group(original.groupID))
coordinator.handleInvite(
"creator",
creatorStatic,
signedState(original, creatorKey, ByteArray(32) { 0x1d })
)
assertNull(context.groupStore.group(original.groupID))
coordinator.handleInvite(
"creator",
creatorStatic,
signedState(nextState, creatorKey, ByteArray(32) { 0x1e })
)
assertEquals(nextState, context.groupStore.group(original.groupID))
assertNull(context.groupStore.departureEpoch(original.groupID))
}
@Test
fun `packets wait for asynchronous group store initialization`() {
val creatorKey = privateKey(0x1f)
val localKey = privateKey(0x20)
val creatorStatic = ByteArray(32) { 0x21 }
val creatorFingerprint = fingerprint(creatorStatic)
val localFingerprint = "20".repeat(32)
val store = GroupStore(
TestGroupKeys(),
testOnly = true,
autoInitialize = false
)
val context = FakeGroupContext(localKey, localFingerprint, store)
val group = incomingGroup(
creatorKey,
creatorFingerprint,
localKey,
localFingerprint
)
val coordinator = GroupCoordinator(context)
val command = coordinator.createGroup("too early")
assertFalse(command.success)
assertTrue(command.message.contains("still loading"))
coordinator.handleInvite(
"creator",
creatorStatic,
signedState(group, creatorKey, ByteArray(32) { 0x22 })
)
assertTrue(store.groups.value.isEmpty())
assertTrue(store.initialize())
coordinator.onStoreReady()
assertEquals(group, store.group(group.groupID))
}
@Test
fun `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)
val localKey = privateKey(0x22)
val creatorFingerprint = "81".repeat(32)
val localFingerprint = "82".repeat(32)
val context = FakeGroupContext(localKey, localFingerprint)
val group = incomingGroup(
creatorKey,
creatorFingerprint,
localKey,
localFingerprint
)
val key = ByteArray(32) { 0x33 }
assertTrue(context.groupStore.upsert(group, key))
val coordinator = GroupCoordinator(context)
val payload = sealedMessage(group, key, creatorKey, MESSAGE_ID_1, "hello")
coordinator.handleMessage(payload, System.currentTimeMillis())
coordinator.handleMessage(payload, System.currentTimeMillis())
val messages = context.messages[group.peerID].orEmpty()
assertEquals(1, messages.size)
assertEquals("hello", messages.single().content)
assertEquals("creator", messages.single().sender)
assertTrue(group.peerID in context.unread)
assertEquals(1, context.notifications.size)
context.blocked += creatorFingerprint
coordinator.handleMessage(
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,
localKey: Ed25519PrivateKeyParameters,
localFingerprint: String
) = BitchatGroup(
groupID = ByteArray(16) { it.toByte() },
name = "ops",
epoch = 1,
members = listOf(
GroupMember(
creatorFingerprint,
creatorKey.generatePublicKey().encoded,
"creator"
),
GroupMember(
localFingerprint,
localKey.generatePublicKey().encoded,
"local"
)
),
creatorFingerprint = creatorFingerprint
)
private fun signedState(
group: BitchatGroup,
creatorKey: Ed25519PrivateKeyParameters,
key: ByteArray
): ByteArray =
GroupStatePayload.makeSigned(group, key) { sign(creatorKey, it) }
?.encode()
?: error("state should encode")
private fun sealedMessage(
group: BitchatGroup,
key: ByteArray,
senderKey: Ed25519PrivateKeyParameters,
messageID: String,
content: String
): ByteArray = GroupCrypto.sealMessage(
content = content,
messageID = messageID,
senderNickname = "untrusted nickname",
senderSigningKey = senderKey.generatePublicKey().encoded,
timestampMs = System.currentTimeMillis(),
groupID = group.groupID,
epoch = group.epoch,
key = key
) { sign(senderKey, it) }
private fun privateKey(seed: Int) =
Ed25519PrivateKeyParameters(ByteArray(32) { seed.toByte() }, 0)
private fun sign(
privateKey: Ed25519PrivateKeyParameters,
data: ByteArray
): ByteArray {
val signer = Ed25519Signer()
signer.init(true, privateKey)
signer.update(data, 0, data.size)
return signer.generateSignature()
}
private fun fingerprint(noiseKey: ByteArray): String =
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(
private val localKey: Ed25519PrivateKeyParameters,
private val localFingerprint: String,
override val groupStore: GroupStore = GroupStore(TestGroupKeys(), testOnly = true)
) : GroupCoordinatorContext {
override val nickname = "local"
override val myPeerID = localFingerprint.take(16)
override val selectedConversationID: String?
get() = selected
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>()
val identities = mutableMapOf<String, GroupPeerIdentity>()
val connectedFingerprints = mutableMapOf<String, String>()
val blocked = mutableSetOf<String>()
val invites = mutableListOf<Pair<String, ByteArray>>()
val updates = mutableListOf<Pair<String, ByteArray>>()
val broadcasts = mutableListOf<ByteArray>()
val messages = mutableMapOf<String, MutableList<BitchatMessage>>()
val unread = mutableSetOf<String>()
val removedConversations = mutableSetOf<String>()
val systemMessages = mutableListOf<String>()
val notifications = mutableListOf<Triple<String, String, String>>()
override fun myNoiseFingerprint() = localFingerprint
override fun mySigningPublicKey(): ByteArray = localKey.generatePublicKey().encoded
override fun sign(data: ByteArray): ByteArray {
val signer = Ed25519Signer()
signer.init(true, localKey)
signer.update(data, 0, data.size)
return signer.generateSignature()
}
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
override fun peerNickname(peerID: String) = peerNames[peerID]
override fun peerIdentity(peerID: String) = identities[peerID]
override fun connectedPeerID(fingerprint: String) = connectedFingerprints[fingerprint]
override fun isFingerprintBlocked(fingerprint: String) = fingerprint in blocked
override fun sendGroupInvite(payload: ByteArray, peerID: String) {
invites += peerID to payload
}
override fun sendGroupKeyUpdate(payload: ByteArray, peerID: String) {
updates += peerID to payload
}
override fun broadcastGroupMessage(payload: ByteArray) {
broadcasts += payload
}
override fun appendGroupMessage(
groupPeerID: String,
message: BitchatMessage
): Boolean {
val conversation = messages.getOrPut(groupPeerID) { mutableListOf() }
if (conversation.any { it.id == message.id }) return false
conversation += message
return true
}
override fun markGroupUnread(groupPeerID: String) {
unread += groupPeerID
}
override fun removeGroupConversation(groupPeerID: String) {
removedConversations += groupPeerID
messages.remove(groupPeerID)
unread.remove(groupPeerID)
}
override fun openGroupConversation(groupPeerID: String) {
selected = groupPeerID
}
override fun closeGroupConversation() {
selected = null
}
override fun addSystemMessage(message: String) {
systemMessages += message
}
override fun addGroupSystemMessage(groupPeerID: String, message: String) {
appendGroupMessage(
groupPeerID,
BitchatMessage(
sender = "system",
content = message,
timestamp = Date(),
isPrivate = true
)
)
}
override fun notifyGroupMessage(
groupPeerID: String,
sender: String,
message: String
) {
notifications += Triple(groupPeerID, sender, message)
}
}
private class TestGroupKeys : GroupKeyStorage {
private val values = mutableMapOf<String, ByteArray>()
override fun get(key: String): ByteArray? = values[key]?.copyOf()
override fun put(key: String, value: ByteArray): Boolean {
values[key] = value.copyOf()
return true
}
override fun remove(key: String): Boolean = values.remove(key) != null
override fun clear(): Boolean {
values.clear()
return true
}
}

View File

@ -0,0 +1,291 @@
package com.bitchat.android.groups
import java.security.MessageDigest
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
class GroupProtocolTest {
private val creatorPrivate = Ed25519PrivateKeyParameters(ByteArray(32) { 0x11 }, 0)
private val memberPrivate = Ed25519PrivateKeyParameters(ByteArray(32) { 0x22 }, 0)
private val creator = member(creatorPrivate, "creator", 0x31)
private val member = member(memberPrivate, "alice", 0x41)
private val group = BitchatGroup(
groupID = ByteArray(16) { it.toByte() },
name = "hike",
epoch = 7,
members = listOf(creator, member),
creatorFingerprint = creator.fingerprint
)
private val groupKey = ByteArray(32) { (it + 1).toByte() }
@Test
fun `creator-signed state round trips and verifies`() {
val payload = GroupStatePayload.makeSigned(group, groupKey) { sign(creatorPrivate, it) }!!
val decoded = GroupStatePayload.decode(payload.encode()!!)!!
assertEquals(payload, decoded)
assertTrue(decoded.verifyCreatorSignature())
assertEquals(group, decoded.asGroup())
}
@Test
fun `creator signature covers name epoch key and roster`() {
val payload = GroupStatePayload.makeSigned(group, groupKey) { sign(creatorPrivate, it) }!!
assertFalse(copyState(payload, name = "ops").verifyCreatorSignature())
assertFalse(copyState(payload, epoch = payload.epoch + 1).verifyCreatorSignature())
assertFalse(copyState(payload, key = ByteArray(32) { 9 }).verifyCreatorSignature())
assertFalse(
copyState(
payload,
members = payload.members.map {
if (it == member) it.copy(nickname = "mallory") else it
}
).verifyCreatorSignature()
)
}
@Test
fun `state rejects creator missing from roster and oversized roster`() {
val noCreator = copyState(
GroupStatePayload.makeSigned(group, groupKey) { sign(creatorPrivate, it) }!!,
members = listOf(member)
)
assertFalse(noCreator.verifyCreatorSignature())
val tooMany = List(BitchatGroup.MAX_MEMBERS + 1) { index ->
member(memberPrivate, "m$index", index)
}
assertNull(GroupRosterCoding.encode(tooMany))
}
@Test
fun `roster nickname truncation remains valid UTF-8`() {
val longNickname = "€".repeat(40)
val encoded = GroupRosterCoding.encode(listOf(creator.copy(nickname = longNickname)))!!
val decoded = GroupRosterCoding.decode(encoded)!!
assertTrue(decoded.single().nickname.toByteArray(Charsets.UTF_8).size <= 64)
assertTrue(decoded.single().nickname.all { it != '\uFFFD' })
}
@Test
fun `group message seals opens and verifies sender`() {
val encoded = GroupCrypto.sealMessage(
content = "meet at the ridge",
messageID = MESSAGE_ID_1,
senderNickname = member.nickname,
senderSigningKey = member.signingKey,
timestampMs = 1_725_000_000_123,
groupID = group.groupID,
epoch = group.epoch,
key = groupKey
) { sign(memberPrivate, it) }
val envelope = GroupMessageEnvelope.decode(encoded)!!
val opened = GroupCrypto.openMessage(envelope, groupKey)
assertEquals(MESSAGE_ID_1, opened.messageID)
assertEquals("meet at the ridge", opened.content)
assertEquals(member.nickname, opened.senderNickname)
assertArrayEquals(member.signingKey, opened.senderSigningKey)
}
@Test
fun `message cannot move between keys groups or epochs`() {
val encoded = sealedMessage()
val envelope = GroupMessageEnvelope.decode(encoded)!!
assertThrows(GroupCryptoException.DecryptionFailed::class.java) {
GroupCrypto.openMessage(envelope, ByteArray(32) { 0x7f })
}
assertThrows(GroupCryptoException.DecryptionFailed::class.java) {
GroupCrypto.openMessage(
GroupMessageEnvelope(
envelope.groupID,
envelope.epoch + 1,
envelope.nonce,
envelope.ciphertext
),
groupKey
)
}
assertThrows(GroupCryptoException.DecryptionFailed::class.java) {
GroupCrypto.openMessage(
GroupMessageEnvelope(
ByteArray(16) { 0x55 },
envelope.epoch,
envelope.nonce,
envelope.ciphertext
),
groupKey
)
}
}
@Test
fun `bad sender signature is rejected after valid AEAD`() {
val encoded = GroupCrypto.sealMessage(
content = "forged",
messageID = MESSAGE_ID_2,
senderNickname = member.nickname,
senderSigningKey = member.signingKey,
timestampMs = 42,
groupID = group.groupID,
epoch = group.epoch,
key = groupKey
) { ByteArray(64) }
assertThrows(GroupCryptoException.BadSenderSignature::class.java) {
GroupCrypto.openMessage(GroupMessageEnvelope.decode(encoded)!!, groupKey)
}
}
@Test
fun `malformed envelopes and state are rejected`() {
assertNull(GroupMessageEnvelope.decode(byteArrayOf(1, 0)))
assertNull(GroupStatePayload.decode(byteArrayOf(1, 0)))
val envelope = GroupMessageEnvelope.decode(sealedMessage())!!
val tampered = envelope.ciphertext.copyOf().also {
it[it.lastIndex] = (it.last().toInt() xor 1).toByte()
}
assertThrows(GroupCryptoException.DecryptionFailed::class.java) {
GroupCrypto.openMessage(
GroupMessageEnvelope(envelope.groupID, envelope.epoch, envelope.nonce, tampered),
groupKey
)
}
}
@Test
fun `group IDs use iOS virtual conversation form`() {
val peerID = GroupIds.peerID(group.groupID)
assertEquals("group_000102030405060708090a0b0c0d0e0f", peerID)
assertTrue(GroupIds.isGroup(peerID))
assertArrayEquals(group.groupID, GroupIds.groupID(peerID))
assertNull(GroupIds.groupID("group_not-hex"))
}
@Test
fun `message signing content covers epoch`() {
val first = GroupCrypto.messageSigningContent(group.groupID, 1, "id", 9, "hello")
val second = GroupCrypto.messageSigningContent(group.groupID, 2, "id", 9, "hello")
assertFalse(MessageDigest.isEqual(first, second))
}
@Test
fun `opens deterministic vectors emitted by iOS CryptoKit`() {
val state = GroupStatePayload.decode(IOS_STATE_VECTOR.hexBytes())
assertNotNull(state)
assertEquals("hike", state!!.name)
assertEquals(7, state.epoch)
assertTrue(state.verifyCreatorSignature())
val plaintext = GroupCrypto.openMessage(
GroupMessageEnvelope.decode(IOS_MESSAGE_VECTOR.hexBytes())!!,
groupKey
)
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_ID_3,
senderNickname = member.nickname,
senderSigningKey = member.signingKey,
timestampMs = 1234,
groupID = group.groupID,
epoch = group.epoch,
key = groupKey
) { sign(memberPrivate, it) }
private fun member(
privateKey: Ed25519PrivateKeyParameters,
nickname: String,
fingerprintSeed: Int
): GroupMember = GroupMember(
fingerprint = ByteArray(32) { fingerprintSeed.toByte() }
.joinToString("") { "%02x".format(it) },
signingKey = privateKey.generatePublicKey().encoded,
nickname = nickname
)
private fun sign(privateKey: Ed25519PrivateKeyParameters, data: ByteArray): ByteArray {
val signer = Ed25519Signer()
signer.init(true, privateKey)
signer.update(data, 0, data.size)
return signer.generateSignature()
}
private fun copyState(
source: GroupStatePayload,
groupID: ByteArray = source.groupID,
name: String = source.name,
key: ByteArray = source.key,
epoch: Long = source.epoch,
members: List<GroupMember> = source.members,
creatorFingerprint: String = source.creatorFingerprint,
signature: ByteArray = source.signature
): GroupStatePayload = GroupStatePayload(
groupID,
name,
key,
epoch,
members,
creatorFingerprint,
signature
)
private fun String.hexBytes(): ByteArray =
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

@ -0,0 +1,246 @@
package com.bitchat.android.groups
import com.google.gson.JsonParser
import java.io.File
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class GroupStoreTest {
@get:Rule
val temporaryFolder = TemporaryFolder()
@Test
fun `create read rotate and remove`() {
val keys = MemoryGroupKeys()
val store = GroupStore(keys, testOnly = true)
val group = store.createGroup("hike", creator())!!
val originalKey = store.key(group.groupID)!!
assertEquals(1, group.epoch)
assertEquals(group, store.group(group.groupID))
assertArrayEquals(originalKey, store.key(group.groupID))
val newMember = GroupMember("22".repeat(32), ByteArray(32) { 0x33 }, "alice")
val (rotated, newKey) = store.rotateKey(group.groupID, listOf(creator(), newMember))!!
assertEquals(2, rotated.epoch)
assertEquals(2, rotated.members.size)
assertFalse(originalKey.contentEquals(newKey))
assertArrayEquals(newKey, store.key(group.groupID))
store.removeGroup(group.groupID)
assertNull(store.group(group.groupID))
assertNull(store.key(group.groupID))
}
@Test
fun `persistence reloads metadata only when key survives`() {
val keys = MemoryGroupKeys()
val file = File(temporaryFolder.root, "groups.json")
val first = GroupStore(keys, file, testOnly = true)
val group = first.createGroup("ops", creator())!!
val reloaded = GroupStore(keys, file, testOnly = true)
assertEquals(listOf(group), reloaded.groups.value)
val legacyGroups = JsonParser.parseString(file.readText())
.asJsonObject
.getAsJsonArray("groups")
file.writeText(legacyGroups.toString())
val reloadedFromLegacyMetadata = GroupStore(keys, file, testOnly = true)
assertEquals(listOf(group), reloadedFromLegacyMetadata.groups.value)
keys.remove("groupKey-${group.groupID.joinToString("") { "%02x".format(it) }}")
val withoutKey = GroupStore(keys, file, testOnly = true)
assertTrue(withoutKey.groups.value.isEmpty())
}
@Test
fun `metadata failure rolls back epoch key and in-memory state`() {
val keys = MemoryGroupKeys()
val metadata = MemoryGroupMetadata()
val store = GroupStore(keys, metadata, testOnly = true)
val group = store.createGroup("ops", creator())!!
val originalKey = store.key(group.groupID)!!
val originalMetadata = metadata.contents
val newMember = GroupMember("22".repeat(32), ByteArray(32) { 0x33 }, "alice")
metadata.failWrites = true
val rotation = store.rotateKey(group.groupID, group.members + newMember)
assertNull(rotation)
assertEquals(group, store.group(group.groupID))
assertArrayEquals(originalKey, store.key(group.groupID))
assertEquals(originalMetadata, metadata.contents)
metadata.failWrites = false
val reloaded = GroupStore(keys, metadata, testOnly = true)
assertEquals(group, reloaded.group(group.groupID))
assertArrayEquals(originalKey, reloaded.key(group.groupID))
}
@Test
fun `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()
val file = File(temporaryFolder.root, "groups.json")
val store = GroupStore(keys, file, testOnly = true)
val group = store.createGroup("ops", creator())!!
assertTrue(store.departGroup(group.groupID, group.epoch))
assertNull(store.group(group.groupID))
assertNull(store.key(group.groupID))
assertEquals(group.epoch, store.departureEpoch(group.groupID))
val reloaded = GroupStore(keys, file, testOnly = true)
assertNull(reloaded.group(group.groupID))
assertEquals(group.epoch, reloaded.departureEpoch(group.groupID))
val reinvited = group.copy(epoch = group.epoch + 1)
val newKey = ByteArray(32) { 0x55 }
assertTrue(reloaded.acceptInvite(reinvited, newKey))
assertEquals(reinvited, reloaded.group(group.groupID))
assertArrayEquals(newKey, reloaded.key(group.groupID))
assertNull(reloaded.departureEpoch(group.groupID))
}
@Test
fun `android-style store remains inert until background initialization`() {
val keys = MemoryGroupKeys()
val file = File(temporaryFolder.root, "groups.json")
val seeded = GroupStore(keys, file, testOnly = true)
val group = seeded.createGroup("ops", creator())!!
val deferred = GroupStore(
keys,
file,
testOnly = true,
autoInitialize = false
)
assertFalse(deferred.isReady)
assertTrue(deferred.groups.value.isEmpty())
assertNull(deferred.createGroup("too early", creator()))
assertTrue(deferred.initialize())
assertTrue(deferred.isReady)
assertEquals(group, deferred.group(group.groupID))
}
@Test
fun `creator and roster cap are enforced`() {
val store = GroupStore(MemoryGroupKeys(), testOnly = true)
val missingCreator = BitchatGroup(
groupID = ByteArray(16),
name = "bad",
epoch = 1,
members = listOf(GroupMember("22".repeat(32), ByteArray(32), "member")),
creatorFingerprint = "11".repeat(32)
)
assertFalse(store.upsert(missingCreator, ByteArray(32)))
val tooMany = BitchatGroup(
groupID = ByteArray(16),
name = "full",
epoch = 1,
members = List(17) { index ->
GroupMember("%02x".format(index).repeat(32), ByteArray(32) { index.toByte() }, "m$index")
},
creatorFingerprint = "00".repeat(32)
)
assertFalse(store.upsert(tooMany, ByteArray(32)))
}
@Test
fun `panic wipe clears keys metadata and memory`() {
val keys = MemoryGroupKeys()
val file = File(temporaryFolder.root, "groups.json")
val store = GroupStore(keys, file, testOnly = true)
val group = store.createGroup("gone", creator())!!
assertTrue(file.exists())
assertNotNull(store.key(group.groupID))
store.wipe()
assertTrue(store.groups.value.isEmpty())
assertNull(store.key(group.groupID))
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")
}
private class MemoryGroupKeys : GroupKeyStorage {
private val values = mutableMapOf<String, ByteArray>()
override fun get(key: String): ByteArray? = values[key]?.copyOf()
override fun put(key: String, value: ByteArray): Boolean {
values[key] = value.copyOf()
return true
}
override fun remove(key: String): Boolean = values.remove(key) != null
override fun clear(): Boolean {
values.clear()
return true
}
}
private class MemoryGroupMetadata : GroupMetadataStorage {
var contents: String? = null
var failWrites = false
override fun read(): String? = contents
override fun write(contents: String): Boolean {
if (failWrites) return false
this.contents = contents
return true
}
override fun delete(): Boolean {
contents = null
return true
}
}

View File

@ -88,18 +88,15 @@ class IdentityAnnouncementTest {
}
@Test
fun `local announcement send advertises private media`() {
fun `local announcement send advertises private media and groups`() {
val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!!
assertArrayEquals(
byteArrayOf(0x05, 0x02, 0x00, 0x01),
byteArrayOf(0x05, 0x02, 0x08, 0x01),
encoded.takeLast(4).toByteArray()
)
assertTrue(
IdentityAnnouncement.decode(encoded)!!
.capabilities!!
.contains(PeerCapabilities.PRIVATE_MEDIA)
)
assertEquals(PeerCapabilities.PRIVATE_MEDIA, PeerCapabilities.LOCAL_SUPPORTED)
val capabilities = IdentityAnnouncement.decode(encoded)!!.capabilities!!
assertTrue(capabilities.contains(PeerCapabilities.PRIVATE_MEDIA))
assertTrue(capabilities.contains(PeerCapabilities.GROUPS))
}
}

View File

@ -0,0 +1,116 @@
package com.bitchat.android.sync
import com.bitchat.android.model.RequestSyncPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class GossipSyncGroupTest {
@Test
fun `typed group request serves only opaque group packets`() {
val fixture = fixture()
val publicPacket = packet(MessageType.MESSAGE, byteArrayOf(1))
val groupPacket = packet(MessageType.GROUP_MESSAGE, byteArrayOf(2))
fixture.manager.onPublicPacketSeen(publicPacket)
fixture.manager.onPublicPacketSeen(groupPacket)
fixture.manager.handleRequestSync(
PEER_ID,
RequestSyncPacket(
p = 5,
m = 1,
data = ByteArray(0),
types = SyncTypeFlags.GROUP_MESSAGE
)
)
assertEquals(listOf(MessageType.GROUP_MESSAGE), fixture.delegate.sentTypes)
fixture.scope.cancel()
}
@Test
fun `initial request combines public and group sync bits`() {
val fixture = fixture()
fixture.manager.scheduleInitialSyncToPeer(PEER_ID, delayMs = 0)
assertTrue(fixture.delegate.requestLatch.await(2, TimeUnit.SECONDS))
val request = RequestSyncPacket.decode(fixture.delegate.lastRequestPayload!!)!!
val types = requireNotNull(request.types)
assertTrue(types.contains(MessageType.ANNOUNCE))
assertTrue(types.contains(MessageType.MESSAGE))
assertTrue(types.contains(MessageType.GROUP_MESSAGE))
fixture.scope.cancel()
}
private fun fixture(): Fixture {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val manager = GossipSyncManager(
myPeerID = MY_ID,
scope = scope,
configProvider = object : GossipSyncManager.ConfigProvider {
override fun seenCapacity() = 100
override fun gcsMaxBytes() = 400
override fun gcsTargetFpr() = 0.01
}
)
val delegate = RecordingSyncDelegate()
manager.delegate = delegate
return Fixture(scope, manager, delegate)
}
private fun packet(type: MessageType, payload: ByteArray) = BitchatPacket(
type = type.value,
senderID = ByteArray(8) { 0x11 },
recipientID = SpecialRecipients.BROADCAST,
timestamp = 1u,
payload = payload,
ttl = 1u
)
private data class Fixture(
val scope: CoroutineScope,
val manager: GossipSyncManager,
val delegate: RecordingSyncDelegate
)
companion object {
private const val MY_ID = "0011223344556677"
private const val PEER_ID = "8899aabbccddeeff"
}
}
private class RecordingSyncDelegate : GossipSyncManager.Delegate {
val sentTypes = mutableListOf<MessageType>()
val requestLatch = CountDownLatch(1)
@Volatile
var lastRequestPayload: ByteArray? = null
override fun sendPacket(packet: BitchatPacket) {
record(packet)
}
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
record(packet)
}
override fun signPacketForBroadcast(packet: BitchatPacket) = packet
private fun record(packet: BitchatPacket) {
val type = MessageType.fromValue(packet.type) ?: return
if (type == MessageType.REQUEST_SYNC) {
lastRequestPayload = packet.payload
requestLatch.countDown()
} else {
sentTypes += type
}
}
}

View File

@ -0,0 +1,38 @@
package com.bitchat.android.sync
import com.bitchat.android.model.RequestSyncPacket
import com.bitchat.android.protocol.MessageType
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SyncTypeFlagsGroupTest {
@Test
fun `group bit ten widens little endian flags to two bytes`() {
assertArrayEquals(
byteArrayOf(0x00, 0x04),
SyncTypeFlags.GROUP_MESSAGE.encoded()
)
assertTrue(SyncTypeFlags.decode(byteArrayOf(0x00, 0x04))!!.contains(MessageType.GROUP_MESSAGE))
}
@Test
fun `unknown sync bits are ignored`() {
val decoded = SyncTypeFlags.decode(byteArrayOf(0x00, 0x0c))!!
assertTrue(decoded.contains(MessageType.GROUP_MESSAGE))
assertArrayEquals(byteArrayOf(0x00, 0x04), decoded.encoded())
}
@Test
fun `request sync round trips group types and legacy omission`() {
val typed = RequestSyncPacket(5, 100, byteArrayOf(1, 2), SyncTypeFlags.GROUP_MESSAGE)
val decoded = RequestSyncPacket.decode(typed.encode())!!
val types = requireNotNull(decoded.types)
assertTrue(types.contains(MessageType.GROUP_MESSAGE))
assertFalse(types.contains(MessageType.MESSAGE))
val legacy = RequestSyncPacket(5, 100, byteArrayOf(1, 2))
assertTrue(RequestSyncPacket.decode(legacy.encode())!!.types == null)
}
}