mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Integrate encrypted private groups with current clients
This commit is contained in:
parent
5334c08fac
commit
ffa8ada6a3
695
app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt
Normal file
695
app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt
Normal 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}$")
|
||||
}
|
||||
}
|
||||
663
app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt
Normal file
663
app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt
Normal 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()
|
||||
}
|
||||
552
app/src/main/java/com/bitchat/android/groups/GroupStore.kt
Normal file
552
app/src/main/java/com/bitchat/android/groups/GroupStore.kt
Normal 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}$")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}")
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>>() }
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
246
app/src/test/kotlin/com/bitchat/android/groups/GroupStoreTest.kt
Normal file
246
app/src/test/kotlin/com/bitchat/android/groups/GroupStoreTest.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user